From 1bac12ac049c9f015abf1e25dcdfacbb96a6ef92 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:38:19 -0500 Subject: [PATCH 1/6] WIP --- fixtures/github.py | 9 ++ src/sentry/integrations/github/webhook.py | 58 +++++++- .../integrations/github/webhook_types.py | 35 +++++ .../github/test_check_run_webhook.py | 138 ++++++++++++++++++ .../seer/error_prediction/test_webhooks.py | 138 ++++++++++++++++++ 5 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 tests/sentry/integrations/github/test_check_run_webhook.py create mode 100644 tests/sentry/seer/error_prediction/test_webhooks.py diff --git a/fixtures/github.py b/fixtures/github.py index c070ea6cb1826a..8c42947ccac947 100644 --- a/fixtures/github.py +++ b/fixtures/github.py @@ -3541,3 +3541,12 @@ "site_admin": false } }""" + +# Simplified example of a check_run rerequested action event +CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE = b"""{ + "action": "rerequested", + "check_run": { + "external_id": "4663713", + "html_url": "https://github.com/test/repo/runs/4" + } +}""" diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index c0936aeb2d0e85..f4c3dd8ff33a7f 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -24,7 +24,7 @@ from sentry.constants import EXTENSION_LANGUAGE_MAP, ObjectStatus from sentry.identity.services.identity.service import identity_service from sentry.integrations.base import IntegrationDomain -from sentry.integrations.github.webhook_types import GithubWebhookType +from sentry.integrations.github.webhook_types import GitHubWebhookCheckRunEvent, GithubWebhookType from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.services.integration.service import integration_service @@ -82,10 +82,10 @@ def provider(self) -> str: return IntegrationProviderSlug.GITHUB.value @abstractmethod - def _handle(self, integration: RpcIntegration, event: Mapping[str, Any], **kwargs) -> None: + def _handle(self, integration: RpcIntegration, event: GitHubWebhookEvent, **kwargs) -> None: pass - def __call__(self, event: Mapping[str, Any], **kwargs) -> None: + def __call__(self, event: GitHubWebhookEvent, **kwargs: object) -> None: external_id = get_github_external_id(event=event, host=kwargs.get("host")) result = integration_service.organization_contexts( @@ -784,6 +784,57 @@ def _handle( handle_github_pr_webhook_for_autofix(organization, action, pull_request, user) +class CheckRunEventWebhook(GitHubWebhook): + """ + Handles GitHub check_run webhook events. + https://docs.github.com/en/webhooks/webhook-events-and-payloads#check_run + """ + + @property + def event_type(self) -> IntegrationWebhookEventType: + return IntegrationWebhookEventType.INBOUND_SYNC + + def _handle( + self, + integration: RpcIntegration, + event: GitHubWebhookEvent, + **kwargs, + ) -> None: + check_run = event.get("check_run", {}) + action = event.get("action") + assert action is not None + extra = { + "action": action, + "check_run_url": check_run.get("html_url"), + "check_run_name": check_run.get("name"), + "check_run_external_id": check_run.get("external_id"), + } + + # Get organization from kwargs (populated by GitHubWebhook base class) + organization = kwargs.get("organization") + if not organization: + logger.warning("github.webhook.check_run.no-organization", extra=extra) + return + + logger.info("github.webhook.check_run.received", extra=extra) + + try: + # XXX: A better interface would be to register methods + # that need to implement an interface to handle the webhook. + from sentry.seer.error_prediction.webhooks import ( + handle_github_check_run_for_error_prediction, + ) + + handle_github_check_run_for_error_prediction( + organization=organization, + check_run=check_run, + action=action, + integration=integration, + ) + except Exception: + logger.exception("github.webhook.check_run", extra=extra) + + @all_silo_endpoint class GitHubIntegrationsWebhookEndpoint(Endpoint): """ @@ -804,6 +855,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, + GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, } def get_handler(self, event_type: str) -> type[GitHubWebhook] | None: diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 06a1575eca1d1b..23d8e6fc0f610d 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from enum import StrEnum +from typing import TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -14,3 +17,35 @@ class GithubWebhookType(StrEnum): PULL_REQUEST_REVIEW_COMMENT = "pull_request_review_comment" PULL_REQUEST_REVIEW = "pull_request_review" PUSH = "push" + CHECK_RUN = "check_run" + + +class GitHubCheckRun(TypedDict, total=False): + """Minimal GitHub Check Run Object.""" + + id: int + external_id: str # This is the ID of a row in Seer + html_url: str + name: str + + +class GitHubWebhookEvent(TypedDict, total=False): + """ + General GitHub Webhook Event Payload Type. + + This is a flexible type that can represent any GitHub webhook event. + """ + + action: str + check_run: GitHubCheckRun + + +class GitHubWebhookCheckRunEvent(TypedDict, total=False): + """ + Minimal GitHub Check Run Webhook Event Payload Type. + + Reference: https://docs.github.com/en/webhooks/webhook-events-and-payloads#check_run + """ + + action: str + check_run: GitHubCheckRun diff --git a/tests/sentry/integrations/github/test_check_run_webhook.py b/tests/sentry/integrations/github/test_check_run_webhook.py new file mode 100644 index 00000000000000..ec78a040cc7a26 --- /dev/null +++ b/tests/sentry/integrations/github/test_check_run_webhook.py @@ -0,0 +1,138 @@ +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from fixtures.github import ( + CHECK_RUN_COMPLETED_EVENT_EXAMPLE, + CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE, +) +from sentry import options +from sentry.silo.base import SiloMode +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.silo import assume_test_silo_mode + + +class CheckRunEventWebhookTest(APITestCase): + def setUp(self) -> None: + self.url = "/extensions/github/webhook/" + self.secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.secret) + + def _create_integration_and_send_check_run_event(self, event_data): + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + with assume_test_silo_mode(SiloMode.CONTROL): + integration = self.create_integration( + organization=self.organization, + external_id="12345", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + integration.add_organization(self.project.organization.id, self.user) + + # Signatures computed for CHECK_RUN_COMPLETED_EVENT_EXAMPLE + # If using different event data, signatures need to be recomputed + if event_data == CHECK_RUN_COMPLETED_EVENT_EXAMPLE: + sha1_sig = "sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d" + sha256_sig = "sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5" + else: + # For CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE + sha1_sig = "sha1=e5069c934b7e82ed4cb5e2c53ce0cf2e80982c1f" + sha256_sig = "sha256=b1cc854b6f8074beb5b8575122922d01609c81606a5ed551fc93ac0145a39170" + + response = self.client.post( + path=self.url, + data=event_data, + content_type="application/json", + HTTP_X_GITHUB_EVENT="check_run", + HTTP_X_HUB_SIGNATURE=sha1_sig, + HTTP_X_HUB_SIGNATURE_256=sha256_sig, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + + assert response.status_code == 204 + return response + + @patch("sentry.integrations.github.webhook.CheckRunEventWebhook.__call__") + def test_check_run_completed_event_triggers_handler( + self, mock_event_handler: MagicMock + ) -> None: + """Test that check_run completed events trigger the webhook handler.""" + self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + assert mock_event_handler.called + + @patch("sentry.integrations.github.webhook.CheckRunEventWebhook.__call__") + def test_check_run_requested_action_event_triggers_handler( + self, mock_event_handler: MagicMock + ) -> None: + """Test that check_run requested_action events trigger the webhook handler.""" + self._create_integration_and_send_check_run_event(CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE) + assert mock_event_handler.called + + @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") + @with_feature("organizations:gen-ai-features") + def test_check_run_completed_calls_error_prediction( + self, mock_error_prediction: MagicMock + ) -> None: + """Test that completed check_run events call the error prediction handler.""" + self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + + assert mock_error_prediction.called + + # Verify the handler was called with correct arguments + call_args = mock_error_prediction.call_args + assert call_args is not None + kwargs = call_args.kwargs + + assert "organization" in kwargs + assert kwargs["organization"].id == self.organization.id + assert "check_run" in kwargs + assert kwargs["check_run"]["id"] == 4 + assert kwargs["check_run"]["status"] == "completed" + assert kwargs["action"] == "completed" + assert "repository" in kwargs + assert kwargs["repository"]["full_name"] == "baxterthehacker/public-repo" + + @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") + @patch("sentry.integrations.github.webhook.logger") + @with_feature("organizations:gen-ai-features") + def test_check_run_logs_received_event( + self, mock_logger: MagicMock, mock_error_prediction: MagicMock + ) -> None: + """Test that check_run events are logged when received.""" + self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + + # Verify logging occurred + mock_logger.info.assert_called() + log_calls = [call for call in mock_logger.info.call_args_list] + + # Check for the "received" log + received_logs = [ + call for call in log_calls if "github.webhook.check_run.received" in str(call) + ] + assert len(received_logs) > 0 + + @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") + @with_feature("organizations:gen-ai-features") + def test_check_run_handles_error_prediction_exception( + self, mock_error_prediction: MagicMock + ) -> None: + """Test that exceptions in error prediction handler are caught and logged.""" + mock_error_prediction.side_effect = Exception("Test error") + self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + + def test_check_run_without_integration_returns_204(self) -> None: + """Test that check_run events without integration return 204.""" + # Don't create an integration, just send the event + response = self.client.post( + path=self.url, + data=CHECK_RUN_COMPLETED_EVENT_EXAMPLE, + content_type="application/json", + HTTP_X_GITHUB_EVENT="check_run", + HTTP_X_HUB_SIGNATURE="sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d", + HTTP_X_HUB_SIGNATURE_256="sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5", + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + + # Should still return 204 even without integration + assert response.status_code == 204 diff --git a/tests/sentry/seer/error_prediction/test_webhooks.py b/tests/sentry/seer/error_prediction/test_webhooks.py new file mode 100644 index 00000000000000..830f3d7b1b1467 --- /dev/null +++ b/tests/sentry/seer/error_prediction/test_webhooks.py @@ -0,0 +1,138 @@ +from unittest.mock import patch + +import responses + +from sentry.seer.error_prediction.webhooks import forward_github_check_run_for_error_prediction +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature + + +class ForwardGithubCheckRunForErrorPredictionTest(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization() + self.check_run = { + "external_id": "4663713", + "html_url": "https://github.com/test/repo/runs/4", + } + + def test_skips_when_feature_not_enabled(self): + """Test that the handler returns early when gen-ai-features is not enabled.""" + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action="rerequested", + ) + + # Verify debug log for disabled feature + mock_logger.debug.assert_called_once() + assert "feature_disabled" in mock_logger.debug.call_args[0][0] + + @with_feature("organizations:gen-ai-features") + def test_skips_non_handled_actions(self): + """Test that non-handled actions are skipped.""" + non_handled_actions = ["created", "completed", "requested_action", None] + + for action in non_handled_actions: + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action=action, + ) + + # Verify debug log for skipped action + mock_logger.debug.assert_called_once() + assert "skipped_action" in mock_logger.debug.call_args[0][0] + + @with_feature("organizations:gen-ai-features") + @responses.activate + def test_forwards_rerequested_action_to_seer(self): + """Test that rerequested action forwards payload to Seer.""" + responses.add( + responses.POST, + "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + json={"success": True}, + status=200, + ) + + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action="rerequested", + ) + + # Verify request was made + assert len(responses.calls) == 1 + + # Verify success logging + mock_logger.info.assert_called_once() + assert "forwarded_successfully" in mock_logger.info.call_args[0][0] + + @with_feature("organizations:gen-ai-features") + @responses.activate + def test_handles_minimal_check_run_payload(self): + """Test that minimal check_run with missing fields is handled.""" + minimal_check_run = {} # No external_id or html_url + + responses.add( + responses.POST, + "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + json={"success": True}, + status=200, + ) + + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=minimal_check_run, + action="rerequested", + ) + + # Should succeed even with minimal payload + assert len(responses.calls) == 1 + + @with_feature("organizations:gen-ai-features") + @responses.activate + def test_handles_seer_error_response(self): + """Test that Seer errors are caught and logged.""" + responses.add( + responses.POST, + "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + json={"error": "Internal server error"}, + status=500, + ) + + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action="rerequested", + ) + + # Verify exception logging + mock_logger.exception.assert_called_once() + assert "forward_failed" in mock_logger.exception.call_args[0][0] + + @with_feature("organizations:gen-ai-features") + @responses.activate + def test_includes_signed_headers(self): + """Test that request includes signed headers for Seer authentication.""" + responses.add( + responses.POST, + "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + json={"success": True}, + status=200, + ) + + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action="rerequested", + ) + + # Verify request has content-type header + request = responses.calls[0].request + assert request.headers["content-type"] == "application/json;charset=utf-8" + # Note: sign_with_seer_secret headers are also included but harder to verify in tests From 748ec06215f742b0728d560dc00b56cf5b956f59 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:10:47 -0500 Subject: [PATCH 2/6] More changes --- src/sentry/integrations/github/webhook.py | 45 +++-------- .../integrations/github/webhook_types.py | 32 -------- src/sentry/seer/error_prediction/__init__.py | 1 + src/sentry/seer/error_prediction/webhooks.py | 78 +++++++++++++++++++ src/sentry/seer/signed_seer_api.py | 50 ++++++++++++ .../seer/error_prediction/test_webhooks.py | 34 ++++---- 6 files changed, 159 insertions(+), 81 deletions(-) create mode 100644 src/sentry/seer/error_prediction/__init__.py create mode 100644 src/sentry/seer/error_prediction/webhooks.py diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index f4c3dd8ff33a7f..a3f07986ad7eff 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -24,7 +24,7 @@ from sentry.constants import EXTENSION_LANGUAGE_MAP, ObjectStatus from sentry.identity.services.identity.service import identity_service from sentry.integrations.base import IntegrationDomain -from sentry.integrations.github.webhook_types import GitHubWebhookCheckRunEvent, GithubWebhookType +from sentry.integrations.github.webhook_types import GithubWebhookType from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.services.integration.service import integration_service @@ -82,10 +82,10 @@ def provider(self) -> str: return IntegrationProviderSlug.GITHUB.value @abstractmethod - def _handle(self, integration: RpcIntegration, event: GitHubWebhookEvent, **kwargs) -> None: + def _handle(self, integration: RpcIntegration, event: Mapping[str, Any], **kwargs) -> None: pass - def __call__(self, event: GitHubWebhookEvent, **kwargs: object) -> None: + def __call__(self, event: Mapping[str, Any], **kwargs: object) -> None: external_id = get_github_external_id(event=event, host=kwargs.get("host")) result = integration_service.organization_contexts( @@ -792,47 +792,26 @@ class CheckRunEventWebhook(GitHubWebhook): @property def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.INBOUND_SYNC + return IntegrationWebhookEventType.PULL_REQUEST def _handle( self, integration: RpcIntegration, - event: GitHubWebhookEvent, + event: Mapping[str, Any], **kwargs, ) -> None: - check_run = event.get("check_run", {}) - action = event.get("action") - assert action is not None - extra = { - "action": action, - "check_run_url": check_run.get("html_url"), - "check_run_name": check_run.get("name"), - "check_run_external_id": check_run.get("external_id"), - } - # Get organization from kwargs (populated by GitHubWebhook base class) organization = kwargs.get("organization") - if not organization: - logger.warning("github.webhook.check_run.no-organization", extra=extra) + if organization is None: + logger.warning("github.webhook.check_run.missing-organization") return - logger.info("github.webhook.check_run.received", extra=extra) - - try: - # XXX: A better interface would be to register methods - # that need to implement an interface to handle the webhook. - from sentry.seer.error_prediction.webhooks import ( - handle_github_check_run_for_error_prediction, - ) + # XXX: Add support for registering functions to call + from sentry.seer.error_prediction.webhooks import ( + forward_github_check_run_for_error_prediction, + ) - handle_github_check_run_for_error_prediction( - organization=organization, - check_run=check_run, - action=action, - integration=integration, - ) - except Exception: - logger.exception("github.webhook.check_run", extra=extra) + forward_github_check_run_for_error_prediction(organization=organization, event=event) @all_silo_endpoint diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 23d8e6fc0f610d..33fba1347d2d9a 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,7 +1,6 @@ from __future__ import annotations from enum import StrEnum -from typing import TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -18,34 +17,3 @@ class GithubWebhookType(StrEnum): PULL_REQUEST_REVIEW = "pull_request_review" PUSH = "push" CHECK_RUN = "check_run" - - -class GitHubCheckRun(TypedDict, total=False): - """Minimal GitHub Check Run Object.""" - - id: int - external_id: str # This is the ID of a row in Seer - html_url: str - name: str - - -class GitHubWebhookEvent(TypedDict, total=False): - """ - General GitHub Webhook Event Payload Type. - - This is a flexible type that can represent any GitHub webhook event. - """ - - action: str - check_run: GitHubCheckRun - - -class GitHubWebhookCheckRunEvent(TypedDict, total=False): - """ - Minimal GitHub Check Run Webhook Event Payload Type. - - Reference: https://docs.github.com/en/webhooks/webhook-events-and-payloads#check_run - """ - - action: str - check_run: GitHubCheckRun diff --git a/src/sentry/seer/error_prediction/__init__.py b/src/sentry/seer/error_prediction/__init__.py new file mode 100644 index 00000000000000..d68ab5a3f20605 --- /dev/null +++ b/src/sentry/seer/error_prediction/__init__.py @@ -0,0 +1 @@ +# Error prediction module for Seer integration diff --git a/src/sentry/seer/error_prediction/webhooks.py b/src/sentry/seer/error_prediction/webhooks.py new file mode 100644 index 00000000000000..2dd39e2a48da3d --- /dev/null +++ b/src/sentry/seer/error_prediction/webhooks.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from enum import StrEnum +from typing import Any + +from sentry import features +from sentry.models.organization import Organization +from sentry.seer.signed_seer_api import post_to_seer +from sentry.utils import metrics + +logger = logging.getLogger(__name__) + + +# https://docs.github.com/en/webhooks/webhook-events-and-payloads#check_run +class CheckRunAction(StrEnum): + COMPLETED = "completed" + CREATED = "created" + REQUESTED_ACTION = "requested_action" + REREQUESTED = "rerequested" + + +HANDLED_ACTIONS = [CheckRunAction.REREQUESTED] +SEER_ERROR_PREDICTION_PATH = "/v1/automation/codegen/pr-review/github" + + +def forward_github_check_run_for_error_prediction( + organization: Organization, + event: Mapping[str, Any], +) -> None: + """ + Handle GitHub check_run webhook events for error prediction. + + This is called when a check_run event is received from GitHub, + which can trigger error prediction analysis and PR comments. + + Args: + organization: The Sentry organization + event: The webhook event payload + """ + check_run = event.get("check_run", {}) + assert check_run is not None, "check_run is required" + action = event.get("action") + assert action is not None, "action is required" + extra = { + "organization_id": organization.id, + "action": action, + "check_run_html_url": check_run.get("html_url"), + "check_run_name": check_run.get("name"), + "check_run_external_id": check_run.get("external_id"), + } + # Check if error prediction/AI features are enabled for this org + if not features.has("organizations:gen-ai-features", organization): + logger.debug("seer.error_prediction.check_run.feature_disabled", extra=extra) + return + + # Only handle relevant actions + if action not in HANDLED_ACTIONS: + logger.debug("seer.error_prediction.check_run.skipped_action", extra=extra) + return + + # Forward minimal payload to Seer for error prediction + payload = { + "action": action, + "check_run": { + "external_id": check_run.get("external_id"), + "html_url": check_run.get("html_url"), + }, + } + success = False + try: + post_to_seer(path=SEER_ERROR_PREDICTION_PATH, payload=payload) + success = True + except Exception: + logger.exception("seer.error_prediction.check_run.forward.exception", extra=extra) + finally: + metrics.incr("seer.error_prediction.check_run.forward.outcome", tags={"success": success}) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 9bd2c7f045a5fd..35f5467df9423f 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -1,10 +1,13 @@ import hashlib import hmac import logging +from collections.abc import Mapping from random import random from typing import Any from urllib.parse import urlparse +import orjson +import requests import sentry_sdk from django.conf import settings from urllib3 import BaseHTTPResponse, HTTPConnectionPool, Retry @@ -53,6 +56,53 @@ def make_signed_seer_api_request( ) +@sentry_sdk.tracing.trace +def post_to_seer( + path: str, + payload: Mapping[str, Any], + timeout: int | float = 5, + base_url: str | None = None, +) -> requests.Response: + """ + Simple wrapper to POST data to Seer with automatic signing. + + This is a simpler alternative to make_signed_seer_api_request for one-off requests. + Use this for webhook forwarding and simple API calls. Use make_signed_seer_api_request + if you need connection pooling for high-volume requests. + + Args: + path: The API path (e.g., "/v1/automation/codegen/pr-review/github") + payload: The data to send (will be JSON-serialized) + timeout: Request timeout in seconds (default: 5) + base_url: Base URL override (defaults to settings.SEER_AUTOFIX_URL) + + Returns: + requests.Response object + + Raises: + requests.HTTPError: If the response status indicates an error + """ + base_url = base_url or settings.SEER_AUTOFIX_URL + body = orjson.dumps(payload) + + with metrics.timer( + "seer.request_to_seer", + sample_rate=1.0, + tags={"endpoint": path}, + ): + response = requests.post( + f"{base_url}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret(body), + }, + timeout=timeout, + ) + response.raise_for_status() + return response + + def sign_with_seer_secret(body: bytes) -> dict[str, str]: auth_headers: dict[str, str] = {} if random() < options.get("seer.api.use-shared-secret"): diff --git a/tests/sentry/seer/error_prediction/test_webhooks.py b/tests/sentry/seer/error_prediction/test_webhooks.py index 830f3d7b1b1467..ca1a554bd00561 100644 --- a/tests/sentry/seer/error_prediction/test_webhooks.py +++ b/tests/sentry/seer/error_prediction/test_webhooks.py @@ -1,6 +1,7 @@ from unittest.mock import patch import responses +from django.conf import settings from sentry.seer.error_prediction.webhooks import forward_github_check_run_for_error_prediction from sentry.testutils.cases import TestCase @@ -50,36 +51,35 @@ def test_skips_non_handled_actions(self): @responses.activate def test_forwards_rerequested_action_to_seer(self): """Test that rerequested action forwards payload to Seer.""" + from django.conf import settings + responses.add( responses.POST, - "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", json={"success": True}, status=200, ) - with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_check_run_for_error_prediction( - organization=self.organization, - check_run=self.check_run, - action="rerequested", - ) - - # Verify request was made - assert len(responses.calls) == 1 + forward_github_check_run_for_error_prediction( + organization=self.organization, + check_run=self.check_run, + action="rerequested", + ) - # Verify success logging - mock_logger.info.assert_called_once() - assert "forwarded_successfully" in mock_logger.info.call_args[0][0] + # Verify request was made + assert len(responses.calls) == 1 @with_feature("organizations:gen-ai-features") @responses.activate def test_handles_minimal_check_run_payload(self): """Test that minimal check_run with missing fields is handled.""" + from django.conf import settings + minimal_check_run = {} # No external_id or html_url responses.add( responses.POST, - "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", json={"success": True}, status=200, ) @@ -97,9 +97,11 @@ def test_handles_minimal_check_run_payload(self): @responses.activate def test_handles_seer_error_response(self): """Test that Seer errors are caught and logged.""" + from django.conf import settings + responses.add( responses.POST, - "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", json={"error": "Internal server error"}, status=500, ) @@ -121,7 +123,7 @@ def test_includes_signed_headers(self): """Test that request includes signed headers for Seer authentication.""" responses.add( responses.POST, - "https://seer.sentry.io/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", json={"success": True}, status=200, ) From 1b810c79be437017e758a752fe95c5a307583434 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:34:18 -0500 Subject: [PATCH 3/6] More changes --- fixtures/github.py | 4 ++ src/sentry/integrations/github/webhook.py | 8 +-- src/sentry/seer/error_prediction/webhooks.py | 2 +- .../github/test_check_run_webhook.py | 25 +++----- .../seer/error_prediction/test_webhooks.py | 59 ++++++++++--------- 5 files changed, 45 insertions(+), 53 deletions(-) diff --git a/fixtures/github.py b/fixtures/github.py index 8c42947ccac947..a41d08dc7bf8d4 100644 --- a/fixtures/github.py +++ b/fixtures/github.py @@ -3550,3 +3550,7 @@ "html_url": "https://github.com/test/repo/runs/4" } }""" + +CHECK_RUN_COMPLETED_EVENT_EXAMPLE = b"""{ + "action": "completed" +}""" diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index a3f07986ad7eff..405d71ab42666d 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -85,7 +85,7 @@ def provider(self) -> str: def _handle(self, integration: RpcIntegration, event: Mapping[str, Any], **kwargs) -> None: pass - def __call__(self, event: Mapping[str, Any], **kwargs: object) -> None: + def __call__(self, event: Mapping[str, Any], **kwargs: Mapping[str, Any]) -> None: external_id = get_github_external_id(event=event, host=kwargs.get("host")) result = integration_service.organization_contexts( @@ -807,11 +807,9 @@ def _handle( return # XXX: Add support for registering functions to call - from sentry.seer.error_prediction.webhooks import ( - forward_github_check_run_for_error_prediction, - ) + from sentry.seer.error_prediction.webhooks import forward_github_event_for_error_prediction - forward_github_check_run_for_error_prediction(organization=organization, event=event) + forward_github_event_for_error_prediction(organization=organization, event=event) @all_silo_endpoint diff --git a/src/sentry/seer/error_prediction/webhooks.py b/src/sentry/seer/error_prediction/webhooks.py index 2dd39e2a48da3d..6ccd8839443e92 100644 --- a/src/sentry/seer/error_prediction/webhooks.py +++ b/src/sentry/seer/error_prediction/webhooks.py @@ -25,7 +25,7 @@ class CheckRunAction(StrEnum): SEER_ERROR_PREDICTION_PATH = "/v1/automation/codegen/pr-review/github" -def forward_github_check_run_for_error_prediction( +def forward_github_event_for_error_prediction( organization: Organization, event: Mapping[str, Any], ) -> None: diff --git a/tests/sentry/integrations/github/test_check_run_webhook.py b/tests/sentry/integrations/github/test_check_run_webhook.py index ec78a040cc7a26..d6abb5a8326ed9 100644 --- a/tests/sentry/integrations/github/test_check_run_webhook.py +++ b/tests/sentry/integrations/github/test_check_run_webhook.py @@ -4,7 +4,7 @@ from fixtures.github import ( CHECK_RUN_COMPLETED_EVENT_EXAMPLE, - CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, ) from sentry import options from sentry.silo.base import SiloMode @@ -30,15 +30,10 @@ def _create_integration_and_send_check_run_event(self, event_data): ) integration.add_organization(self.project.organization.id, self.user) - # Signatures computed for CHECK_RUN_COMPLETED_EVENT_EXAMPLE + # Signatures computed for CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE # If using different event data, signatures need to be recomputed - if event_data == CHECK_RUN_COMPLETED_EVENT_EXAMPLE: - sha1_sig = "sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d" - sha256_sig = "sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5" - else: - # For CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE - sha1_sig = "sha1=e5069c934b7e82ed4cb5e2c53ce0cf2e80982c1f" - sha256_sig = "sha256=b1cc854b6f8074beb5b8575122922d01609c81606a5ed551fc93ac0145a39170" + sha1_sig = "sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d" + sha256_sig = "sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5" response = self.client.post( path=self.url, @@ -53,20 +48,14 @@ def _create_integration_and_send_check_run_event(self, event_data): assert response.status_code == 204 return response - @patch("sentry.integrations.github.webhook.CheckRunEventWebhook.__call__") - def test_check_run_completed_event_triggers_handler( - self, mock_event_handler: MagicMock - ) -> None: - """Test that check_run completed events trigger the webhook handler.""" - self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) - assert mock_event_handler.called - @patch("sentry.integrations.github.webhook.CheckRunEventWebhook.__call__") def test_check_run_requested_action_event_triggers_handler( self, mock_event_handler: MagicMock ) -> None: """Test that check_run requested_action events trigger the webhook handler.""" - self._create_integration_and_send_check_run_event(CHECK_RUN_REQUESTED_ACTION_EVENT_EXAMPLE) + self._create_integration_and_send_check_run_event( + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE + ) assert mock_event_handler.called @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") diff --git a/tests/sentry/seer/error_prediction/test_webhooks.py b/tests/sentry/seer/error_prediction/test_webhooks.py index ca1a554bd00561..469bf8232fee23 100644 --- a/tests/sentry/seer/error_prediction/test_webhooks.py +++ b/tests/sentry/seer/error_prediction/test_webhooks.py @@ -3,7 +3,7 @@ import responses from django.conf import settings -from sentry.seer.error_prediction.webhooks import forward_github_check_run_for_error_prediction +from sentry.seer.error_prediction.webhooks import forward_github_event_for_error_prediction from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature @@ -12,18 +12,20 @@ class ForwardGithubCheckRunForErrorPredictionTest(TestCase): def setUp(self): super().setUp() self.organization = self.create_organization() - self.check_run = { - "external_id": "4663713", - "html_url": "https://github.com/test/repo/runs/4", + self.event = { + "action": "rerequested", + "check_run": { + "external_id": "4663713", + "html_url": "https://github.com/test/repo/runs/4", + }, } def test_skips_when_feature_not_enabled(self): """Test that the handler returns early when gen-ai-features is not enabled.""" with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_check_run_for_error_prediction( + forward_github_event_for_error_prediction( organization=self.organization, - check_run=self.check_run, - action="rerequested", + event=self.event, ) # Verify debug log for disabled feature @@ -37,10 +39,16 @@ def test_skips_non_handled_actions(self): for action in non_handled_actions: with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_check_run_for_error_prediction( + event = { + "action": action, + "check_run": { + "external_id": "4663713", + "html_url": "https://github.com/test/repo/runs/4", + }, + } + forward_github_event_for_error_prediction( organization=self.organization, - check_run=self.check_run, - action=action, + event=event, ) # Verify debug log for skipped action @@ -51,8 +59,6 @@ def test_skips_non_handled_actions(self): @responses.activate def test_forwards_rerequested_action_to_seer(self): """Test that rerequested action forwards payload to Seer.""" - from django.conf import settings - responses.add( responses.POST, f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", @@ -60,10 +66,9 @@ def test_forwards_rerequested_action_to_seer(self): status=200, ) - forward_github_check_run_for_error_prediction( + forward_github_event_for_error_prediction( organization=self.organization, - check_run=self.check_run, - action="rerequested", + event=self.event, ) # Verify request was made @@ -73,9 +78,10 @@ def test_forwards_rerequested_action_to_seer(self): @responses.activate def test_handles_minimal_check_run_payload(self): """Test that minimal check_run with missing fields is handled.""" - from django.conf import settings - - minimal_check_run = {} # No external_id or html_url + minimal_event = { + "action": "rerequested", + "check_run": {}, # No external_id or html_url + } responses.add( responses.POST, @@ -84,10 +90,9 @@ def test_handles_minimal_check_run_payload(self): status=200, ) - forward_github_check_run_for_error_prediction( + forward_github_event_for_error_prediction( organization=self.organization, - check_run=minimal_check_run, - action="rerequested", + event=minimal_event, ) # Should succeed even with minimal payload @@ -97,8 +102,6 @@ def test_handles_minimal_check_run_payload(self): @responses.activate def test_handles_seer_error_response(self): """Test that Seer errors are caught and logged.""" - from django.conf import settings - responses.add( responses.POST, f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", @@ -107,10 +110,9 @@ def test_handles_seer_error_response(self): ) with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_check_run_for_error_prediction( + forward_github_event_for_error_prediction( organization=self.organization, - check_run=self.check_run, - action="rerequested", + event=self.event, ) # Verify exception logging @@ -128,10 +130,9 @@ def test_includes_signed_headers(self): status=200, ) - forward_github_check_run_for_error_prediction( + forward_github_event_for_error_prediction( organization=self.organization, - check_run=self.check_run, - action="rerequested", + event=self.event, ) # Verify request has content-type header From 918e045b349dcbd2b4600eed4e9897d5dbdd0615 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Fri, 5 Dec 2025 10:14:36 -0500 Subject: [PATCH 4/6] Determine success of forward + others --- src/sentry/integrations/github/webhook.py | 5 +- src/sentry/seer/error_prediction/webhooks.py | 38 ++-- src/sentry/testutils/helpers/github.py | 137 +++++++++++++++ .../github/test_check_run_webhook.py | 165 ++++++++---------- .../seer/error_prediction/test_webhooks.py | 29 +-- 5 files changed, 250 insertions(+), 124 deletions(-) create mode 100644 src/sentry/testutils/helpers/github.py diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index 405d71ab42666d..0949e7cc69e15b 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -86,7 +86,10 @@ def _handle(self, integration: RpcIntegration, event: Mapping[str, Any], **kwarg pass def __call__(self, event: Mapping[str, Any], **kwargs: Mapping[str, Any]) -> None: - external_id = get_github_external_id(event=event, host=kwargs.get("host")) + host = kwargs.get("host") + external_id = get_github_external_id( + event=event, host=host if isinstance(host, str) else None + ) result = integration_service.organization_contexts( external_id=external_id, provider=self.provider diff --git a/src/sentry/seer/error_prediction/webhooks.py b/src/sentry/seer/error_prediction/webhooks.py index 6ccd8839443e92..710dbad90a06c6 100644 --- a/src/sentry/seer/error_prediction/webhooks.py +++ b/src/sentry/seer/error_prediction/webhooks.py @@ -22,13 +22,15 @@ class CheckRunAction(StrEnum): HANDLED_ACTIONS = [CheckRunAction.REREQUESTED] -SEER_ERROR_PREDICTION_PATH = "/v1/automation/codegen/pr-review/github" +# This needs to match the value defined in the Seer API: +# https://github.com/getsentry/seer/blob/main/src/seer/automation/codegen/tasks.py +SEER_ERROR_PREDICTION_PATH = "/v1/automation/codegen/pr-review/github/check-run" def forward_github_event_for_error_prediction( organization: Organization, event: Mapping[str, Any], -) -> None: +) -> bool: """ Handle GitHub check_run webhook events for error prediction. @@ -38,27 +40,35 @@ def forward_github_event_for_error_prediction( Args: organization: The Sentry organization event: The webhook event payload + + Returns: + True if the event was forwarded successfully, False otherwise """ - check_run = event.get("check_run", {}) - assert check_run is not None, "check_run is required" + check_run = event.get("check_run") action = event.get("action") - assert action is not None, "action is required" + extra = { "organization_id": organization.id, "action": action, - "check_run_html_url": check_run.get("html_url"), - "check_run_name": check_run.get("name"), - "check_run_external_id": check_run.get("external_id"), + "check_run_html_url": check_run.get("html_url") if check_run else None, + "check_run_name": check_run.get("name") if check_run else None, + "check_run_external_id": check_run.get("external_id") if check_run else None, } + # Check if error prediction/AI features are enabled for this org if not features.has("organizations:gen-ai-features", organization): logger.debug("seer.error_prediction.check_run.feature_disabled", extra=extra) - return + return False # Only handle relevant actions if action not in HANDLED_ACTIONS: logger.debug("seer.error_prediction.check_run.skipped_action", extra=extra) - return + return False + + # Validate required fields after feature/action checks + if not check_run: + logger.warning("seer.error_prediction.check_run.missing_check_run", extra=extra) + return False # Forward minimal payload to Seer for error prediction payload = { @@ -68,11 +78,13 @@ def forward_github_event_for_error_prediction( "html_url": check_run.get("html_url"), }, } - success = False + outcome = "failure" try: post_to_seer(path=SEER_ERROR_PREDICTION_PATH, payload=payload) - success = True + outcome = "success" except Exception: logger.exception("seer.error_prediction.check_run.forward.exception", extra=extra) finally: - metrics.incr("seer.error_prediction.check_run.forward.outcome", tags={"success": success}) + metrics.incr("seer.error_prediction.check_run.forward.outcome", tags={"outcome": outcome}) + + return outcome == "success" diff --git a/src/sentry/testutils/helpers/github.py b/src/sentry/testutils/helpers/github.py new file mode 100644 index 00000000000000..0e57b0182ce6cc --- /dev/null +++ b/src/sentry/testutils/helpers/github.py @@ -0,0 +1,137 @@ +""" +Utilities for testing GitHub integration webhooks. +""" + +from __future__ import annotations + +import hashlib +import hmac +from datetime import datetime, timedelta +from typing import Any +from uuid import uuid4 + +from sentry import options +from sentry.silo.base import SiloMode +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import assume_test_silo_mode + + +def compute_github_webhook_signature(body: bytes, secret: str, method: str = "sha256") -> str: + """ + Compute GitHub webhook signature for testing. + + This uses the same HMAC logic as GitHub's webhook signature validation. + + Args: + body: The request body as bytes + secret: The webhook secret + method: Hash method ('sha1' or 'sha256') + + Returns: + Signature string in format "method=hexdigest" + """ + if method == "sha256": + mod = hashlib.sha256 + elif method == "sha1": + mod = hashlib.sha1 + else: + raise ValueError(f"Unsupported hash method: {method}") + + signature = hmac.new(key=secret.encode("utf-8"), msg=body, digestmod=mod).hexdigest() + return f"{method}={signature}" + + +class GitHubWebhookTestCase(APITestCase): + """ + Base test case for GitHub webhook tests. + + Provides common utilities for: + - Setting up GitHub integrations + - Computing webhook signatures + - Sending webhook events with proper authentication + """ + + def setUp(self) -> None: + super().setUp() + self.github_webhook_url = "/extensions/github/webhook/" + self.github_webhook_secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.github_webhook_secret) + + def create_github_integration( + self, + external_id: str = "12345", + access_token: str = "1234", + **metadata_overrides: Any, + ): + """ + Create a GitHub integration for testing. + + Args: + external_id: GitHub installation ID + access_token: GitHub access token + **metadata_overrides: Additional metadata fields + + Returns: + The created integration + """ + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + metadata = { + "access_token": access_token, + "expires_at": future_expires.isoformat(), + **metadata_overrides, + } + + with assume_test_silo_mode(SiloMode.CONTROL): + integration = self.create_integration( + organization=self.organization, + external_id=external_id, + provider="github", + metadata=metadata, + ) + integration.add_organization(self.project.organization.id, self.user) + + return integration + + def send_github_webhook_event( + self, + event_type: str, + event_data: str | bytes, + **extra_headers: str, + ): + """ + Send a GitHub webhook event with proper signatures and headers. + + Args: + event_type: GitHub event type (e.g., "push", "pull_request", "check_run") + event_data: The webhook event payload (as JSON string or bytes) + **extra_headers: Additional HTTP headers + + Returns: + Response from the webhook endpoint + """ + # Convert to bytes if needed + event_bytes = event_data.encode("utf-8") if isinstance(event_data, str) else event_data + + # Compute signatures + sha1_sig = compute_github_webhook_signature(event_bytes, self.github_webhook_secret, "sha1") + sha256_sig = compute_github_webhook_signature( + event_bytes, self.github_webhook_secret, "sha256" + ) + + # Build headers + headers = { + "HTTP_X_GITHUB_EVENT": event_type, + "HTTP_X_HUB_SIGNATURE": sha1_sig, + "HTTP_X_HUB_SIGNATURE_256": sha256_sig, + "HTTP_X_GITHUB_DELIVERY": str(uuid4()), + **extra_headers, + } + + # The DRF APIClient stubs can misinterpret **extra headers as a positional arg + client: Any = self.client + return client.post( + self.github_webhook_url, + data=event_data, + content_type="application/json", + **headers, + ) diff --git a/tests/sentry/integrations/github/test_check_run_webhook.py b/tests/sentry/integrations/github/test_check_run_webhook.py index d6abb5a8326ed9..4a0b6e3a9b8843 100644 --- a/tests/sentry/integrations/github/test_check_run_webhook.py +++ b/tests/sentry/integrations/github/test_check_run_webhook.py @@ -1,50 +1,23 @@ -from datetime import datetime, timedelta from unittest.mock import MagicMock, patch -from uuid import uuid4 + +import responses +from django.conf import settings +from rest_framework.response import Response from fixtures.github import ( CHECK_RUN_COMPLETED_EVENT_EXAMPLE, CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, ) -from sentry import options -from sentry.silo.base import SiloMode -from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature -from sentry.testutils.silo import assume_test_silo_mode - - -class CheckRunEventWebhookTest(APITestCase): - def setUp(self) -> None: - self.url = "/extensions/github/webhook/" - self.secret = "b3002c3e321d4b7880360d397db2ccfd" - options.set("github-app.webhook-secret", self.secret) - - def _create_integration_and_send_check_run_event(self, event_data): - future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) - with assume_test_silo_mode(SiloMode.CONTROL): - integration = self.create_integration( - organization=self.organization, - external_id="12345", - provider="github", - metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, - ) - integration.add_organization(self.project.organization.id, self.user) - - # Signatures computed for CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE - # If using different event data, signatures need to be recomputed - sha1_sig = "sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d" - sha256_sig = "sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5" - - response = self.client.post( - path=self.url, - data=event_data, - content_type="application/json", - HTTP_X_GITHUB_EVENT="check_run", - HTTP_X_HUB_SIGNATURE=sha1_sig, - HTTP_X_HUB_SIGNATURE_256=sha256_sig, - HTTP_X_GITHUB_DELIVERY=str(uuid4()), - ) +from .testutils import GitHubWebhookTestCase + + +class CheckRunEventWebhookTest(GitHubWebhookTestCase): + def _send_check_run_event(self, event_data: bytes | str) -> Response: + """Helper to send check_run event.""" + self.create_github_integration() + response = self.send_github_webhook_event("check_run", event_data) assert response.status_code == 204 return response @@ -53,75 +26,75 @@ def test_check_run_requested_action_event_triggers_handler( self, mock_event_handler: MagicMock ) -> None: """Test that check_run requested_action events trigger the webhook handler.""" - self._create_integration_and_send_check_run_event( - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE - ) + self._send_check_run_event(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) assert mock_event_handler.called - @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") + @responses.activate @with_feature("organizations:gen-ai-features") - def test_check_run_completed_calls_error_prediction( - self, mock_error_prediction: MagicMock - ) -> None: - """Test that completed check_run events call the error prediction handler.""" - self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) - - assert mock_error_prediction.called - - # Verify the handler was called with correct arguments - call_args = mock_error_prediction.call_args - assert call_args is not None - kwargs = call_args.kwargs - - assert "organization" in kwargs - assert kwargs["organization"].id == self.organization.id - assert "check_run" in kwargs - assert kwargs["check_run"]["id"] == 4 - assert kwargs["check_run"]["status"] == "completed" - assert kwargs["action"] == "completed" - assert "repository" in kwargs - assert kwargs["repository"]["full_name"] == "baxterthehacker/public-repo" - - @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") - @patch("sentry.integrations.github.webhook.logger") + def test_check_run_rerequested_forwards_to_seer(self) -> None: + """Test that rerequested check_run events forward to Seer.""" + # Mock the Seer API endpoint + responses.add( + responses.POST, + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + json={"success": True}, + status=200, + ) + + self._send_check_run_event(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) + + # Verify the request was made to Seer + assert len(responses.calls) == 1 + request = responses.calls[0].request + + # Verify request body contains expected data + import orjson + + body = orjson.loads(request.body) + assert body["action"] == "rerequested" + assert "check_run" in body + assert "external_id" in body["check_run"] + assert "html_url" in body["check_run"] + + @responses.activate @with_feature("organizations:gen-ai-features") - def test_check_run_logs_received_event( - self, mock_logger: MagicMock, mock_error_prediction: MagicMock - ) -> None: - """Test that check_run events are logged when received.""" - self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + def test_check_run_completed_is_skipped(self) -> None: + """Test that completed check_run events are skipped (not handled).""" + # Mock the Seer API endpoint (should NOT be called) + responses.add( + responses.POST, + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + json={"success": True}, + status=200, + ) - # Verify logging occurred - mock_logger.info.assert_called() - log_calls = [call for call in mock_logger.info.call_args_list] + self._send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) - # Check for the "received" log - received_logs = [ - call for call in log_calls if "github.webhook.check_run.received" in str(call) - ] - assert len(received_logs) > 0 + # Verify NO request was made to Seer (completed action is not handled) + assert len(responses.calls) == 0 - @patch("sentry.seer.error_prediction.webhooks.handle_github_check_run_for_error_prediction") + @responses.activate @with_feature("organizations:gen-ai-features") - def test_check_run_handles_error_prediction_exception( - self, mock_error_prediction: MagicMock - ) -> None: - """Test that exceptions in error prediction handler are caught and logged.""" - mock_error_prediction.side_effect = Exception("Test error") - self._create_integration_and_send_check_run_event(CHECK_RUN_COMPLETED_EVENT_EXAMPLE) + def test_check_run_handles_seer_error_gracefully(self) -> None: + """Test that Seer API errors are caught and logged without failing the webhook.""" + # Mock Seer API to return an error + responses.add( + responses.POST, + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + json={"error": "Internal server error"}, + status=500, + ) + + # Should not raise an exception, just log it + self._send_check_run_event(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) + + # Verify the request was attempted + assert len(responses.calls) == 1 def test_check_run_without_integration_returns_204(self) -> None: """Test that check_run events without integration return 204.""" # Don't create an integration, just send the event - response = self.client.post( - path=self.url, - data=CHECK_RUN_COMPLETED_EVENT_EXAMPLE, - content_type="application/json", - HTTP_X_GITHUB_EVENT="check_run", - HTTP_X_HUB_SIGNATURE="sha1=b4094fea7a98e82f508191a34d3f92d646b76e7d", - HTTP_X_HUB_SIGNATURE_256="sha256=b1d21a975b158ce2ebb04538af7aab22373be3dc4193fc47c5feb555462a77f5", - HTTP_X_GITHUB_DELIVERY=str(uuid4()), - ) + response = self.send_github_webhook_event("check_run", CHECK_RUN_COMPLETED_EVENT_EXAMPLE) # Should still return 204 even without integration assert response.status_code == 204 diff --git a/tests/sentry/seer/error_prediction/test_webhooks.py b/tests/sentry/seer/error_prediction/test_webhooks.py index 469bf8232fee23..8fa2d94f7e4a12 100644 --- a/tests/sentry/seer/error_prediction/test_webhooks.py +++ b/tests/sentry/seer/error_prediction/test_webhooks.py @@ -12,7 +12,7 @@ class ForwardGithubCheckRunForErrorPredictionTest(TestCase): def setUp(self): super().setUp() self.organization = self.create_organization() - self.event = { + self.action_rerequested_event = { "action": "rerequested", "check_run": { "external_id": "4663713", @@ -23,11 +23,11 @@ def setUp(self): def test_skips_when_feature_not_enabled(self): """Test that the handler returns early when gen-ai-features is not enabled.""" with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, - event=self.event, + event=self.action_rerequested_event, ) - + assert not success # Verify debug log for disabled feature mock_logger.debug.assert_called_once() assert "feature_disabled" in mock_logger.debug.call_args[0][0] @@ -46,11 +46,11 @@ def test_skips_non_handled_actions(self): "html_url": "https://github.com/test/repo/runs/4", }, } - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, event=event, ) - + assert not success # Verify debug log for skipped action mock_logger.debug.assert_called_once() assert "skipped_action" in mock_logger.debug.call_args[0][0] @@ -66,10 +66,11 @@ def test_forwards_rerequested_action_to_seer(self): status=200, ) - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, event=self.event, ) + assert success # Verify request was made assert len(responses.calls) == 1 @@ -90,11 +91,11 @@ def test_handles_minimal_check_run_payload(self): status=200, ) - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, event=minimal_event, ) - + assert success # Should succeed even with minimal payload assert len(responses.calls) == 1 @@ -110,14 +111,14 @@ def test_handles_seer_error_response(self): ) with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, event=self.event, ) - + assert not success # Verify exception logging mock_logger.exception.assert_called_once() - assert "forward_failed" in mock_logger.exception.call_args[0][0] + assert "check_run.forward.exception" in mock_logger.exception.call_args[0][0] @with_feature("organizations:gen-ai-features") @responses.activate @@ -130,11 +131,11 @@ def test_includes_signed_headers(self): status=200, ) - forward_github_event_for_error_prediction( + success = forward_github_event_for_error_prediction( organization=self.organization, event=self.event, ) - + assert success # Verify request has content-type header request = responses.calls[0].request assert request.headers["content-type"] == "application/json;charset=utf-8" From 0a1bd89ba5273c1f3a6d3e14a31ffed8cfe5de3b Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Fri, 12 Dec 2025 13:42:52 +0100 Subject: [PATCH 5/6] handle webhook instead of forwarding Change the logic to actually handle the webhook payload, extract the info that is relevant to send to Seer and then send it (to an updated endpoint) See https://github.com/getsentry/seer/pull/4173 for more details. --- src/sentry/integrations/github/webhook.py | 4 +- src/sentry/seer/error_prediction/webhooks.py | 49 +++++---- .../github/test_check_run_webhook.py | 27 +++-- .../seer/error_prediction/test_webhooks.py | 99 +++++++++++-------- 4 files changed, 101 insertions(+), 78 deletions(-) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index 0949e7cc69e15b..97146bddb688bf 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -810,9 +810,9 @@ def _handle( return # XXX: Add support for registering functions to call - from sentry.seer.error_prediction.webhooks import forward_github_event_for_error_prediction + from sentry.seer.error_prediction.webhooks import handle_github_check_run_event - forward_github_event_for_error_prediction(organization=organization, event=event) + handle_github_check_run_event(organization=organization, event=event) @all_silo_endpoint diff --git a/src/sentry/seer/error_prediction/webhooks.py b/src/sentry/seer/error_prediction/webhooks.py index 710dbad90a06c6..c804cbb2dff22e 100644 --- a/src/sentry/seer/error_prediction/webhooks.py +++ b/src/sentry/seer/error_prediction/webhooks.py @@ -5,10 +5,10 @@ from enum import StrEnum from typing import Any -from sentry import features from sentry.models.organization import Organization from sentry.seer.signed_seer_api import post_to_seer from sentry.utils import metrics +from sentry.utils.seer import can_use_prevent_ai_features logger = logging.getLogger(__name__) @@ -23,26 +23,27 @@ class CheckRunAction(StrEnum): HANDLED_ACTIONS = [CheckRunAction.REREQUESTED] # This needs to match the value defined in the Seer API: -# https://github.com/getsentry/seer/blob/main/src/seer/automation/codegen/tasks.py -SEER_ERROR_PREDICTION_PATH = "/v1/automation/codegen/pr-review/github/check-run" +# https://github.com/getsentry/seer/blob/main/src/seer/automation/codegen/pr_review_coding_agent.py +SEER_PR_REVIEW_RERUN_PATH = "/v1/automation/codegen/pr-review/rerun" -def forward_github_event_for_error_prediction( +def handle_github_check_run_event( organization: Organization, event: Mapping[str, Any], ) -> bool: """ - Handle GitHub check_run webhook events for error prediction. + Handle GitHub check_run webhook events for PR review rerun. - This is called when a check_run event is received from GitHub, - which can trigger error prediction analysis and PR comments. + This is called when a check_run event is received from GitHub. + When a user clicks "Re-run" on a check run in GitHub UI, we forward + the original run ID to Seer so it can rerun the PR review. Args: organization: The Sentry organization event: The webhook event payload Returns: - True if the event was forwarded successfully, False otherwise + True if the event was handled successfully, False otherwise """ check_run = event.get("check_run") action = event.get("action") @@ -55,8 +56,9 @@ def forward_github_event_for_error_prediction( "check_run_external_id": check_run.get("external_id") if check_run else None, } - # Check if error prediction/AI features are enabled for this org - if not features.has("organizations:gen-ai-features", organization): + # Check if the org has opted in to Prevent AI features (Code Review) + # This checks feature flags, org options, and billing plan type + if not can_use_prevent_ai_features(organization): logger.debug("seer.error_prediction.check_run.feature_disabled", extra=extra) return False @@ -70,17 +72,26 @@ def forward_github_event_for_error_prediction( logger.warning("seer.error_prediction.check_run.missing_check_run", extra=extra) return False - # Forward minimal payload to Seer for error prediction - payload = { - "action": action, - "check_run": { - "external_id": check_run.get("external_id"), - "html_url": check_run.get("html_url"), - }, - } + # Extract and validate external_id (required for Seer to identify the original run) + raw_external_id = check_run.get("external_id") + if not raw_external_id: + logger.warning("seer.error_prediction.check_run.missing_external_id", extra=extra) + return False + + try: + original_run_id = int(raw_external_id) + except (TypeError, ValueError): + logger.warning( + "seer.error_prediction.check_run.invalid_external_id", + extra={**extra, "raw_external_id": raw_external_id}, + ) + return False + + # Forward the original run ID to Seer for PR review rerun + payload = {"original_run_id": original_run_id} outcome = "failure" try: - post_to_seer(path=SEER_ERROR_PREDICTION_PATH, payload=payload) + post_to_seer(path=SEER_PR_REVIEW_RERUN_PATH, payload=payload) outcome = "success" except Exception: logger.exception("seer.error_prediction.check_run.forward.exception", extra=extra) diff --git a/tests/sentry/integrations/github/test_check_run_webhook.py b/tests/sentry/integrations/github/test_check_run_webhook.py index 4a0b6e3a9b8843..661d8d77437107 100644 --- a/tests/sentry/integrations/github/test_check_run_webhook.py +++ b/tests/sentry/integrations/github/test_check_run_webhook.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch +import orjson import responses from django.conf import settings from rest_framework.response import Response @@ -9,8 +10,7 @@ CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, ) from sentry.testutils.helpers.features import with_feature - -from .testutils import GitHubWebhookTestCase +from sentry.testutils.helpers.github import GitHubWebhookTestCase class CheckRunEventWebhookTest(GitHubWebhookTestCase): @@ -30,13 +30,13 @@ def test_check_run_requested_action_event_triggers_handler( assert mock_event_handler.called @responses.activate - @with_feature("organizations:gen-ai-features") + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_check_run_rerequested_forwards_to_seer(self) -> None: - """Test that rerequested check_run events forward to Seer.""" + """Test that rerequested check_run events forward original_run_id to Seer.""" # Mock the Seer API endpoint responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"success": True}, status=200, ) @@ -47,23 +47,18 @@ def test_check_run_rerequested_forwards_to_seer(self) -> None: assert len(responses.calls) == 1 request = responses.calls[0].request - # Verify request body contains expected data - import orjson - + # Verify request body contains original_run_id extracted from external_id body = orjson.loads(request.body) - assert body["action"] == "rerequested" - assert "check_run" in body - assert "external_id" in body["check_run"] - assert "html_url" in body["check_run"] + assert body == {"original_run_id": 4663713} @responses.activate - @with_feature("organizations:gen-ai-features") + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_check_run_completed_is_skipped(self) -> None: """Test that completed check_run events are skipped (not handled).""" # Mock the Seer API endpoint (should NOT be called) responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"success": True}, status=200, ) @@ -74,13 +69,13 @@ def test_check_run_completed_is_skipped(self) -> None: assert len(responses.calls) == 0 @responses.activate - @with_feature("organizations:gen-ai-features") + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_check_run_handles_seer_error_gracefully(self) -> None: """Test that Seer API errors are caught and logged without failing the webhook.""" # Mock Seer API to return an error responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"error": "Internal server error"}, status=500, ) diff --git a/tests/sentry/seer/error_prediction/test_webhooks.py b/tests/sentry/seer/error_prediction/test_webhooks.py index 8fa2d94f7e4a12..49838638bfaaf6 100644 --- a/tests/sentry/seer/error_prediction/test_webhooks.py +++ b/tests/sentry/seer/error_prediction/test_webhooks.py @@ -1,14 +1,15 @@ from unittest.mock import patch +import orjson import responses from django.conf import settings -from sentry.seer.error_prediction.webhooks import forward_github_event_for_error_prediction +from sentry.seer.error_prediction.webhooks import handle_github_check_run_event from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature -class ForwardGithubCheckRunForErrorPredictionTest(TestCase): +class HandleGithubCheckRunEventTest(TestCase): def setUp(self): super().setUp() self.organization = self.create_organization() @@ -20,10 +21,11 @@ def setUp(self): }, } - def test_skips_when_feature_not_enabled(self): - """Test that the handler returns early when gen-ai-features is not enabled.""" + def test_skips_when_prevent_ai_features_disabled(self): + """Test that the handler returns early when AI features are not enabled.""" + # Without enabling feature flags, can_use_prevent_ai_features returns False with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - success = forward_github_event_for_error_prediction( + success = handle_github_check_run_event( organization=self.organization, event=self.action_rerequested_event, ) @@ -32,7 +34,7 @@ def test_skips_when_feature_not_enabled(self): mock_logger.debug.assert_called_once() assert "feature_disabled" in mock_logger.debug.call_args[0][0] - @with_feature("organizations:gen-ai-features") + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_skips_non_handled_actions(self): """Test that non-handled actions are skipped.""" non_handled_actions = ["created", "completed", "requested_action", None] @@ -46,7 +48,7 @@ def test_skips_non_handled_actions(self): "html_url": "https://github.com/test/repo/runs/4", }, } - success = forward_github_event_for_error_prediction( + success = handle_github_check_run_event( organization=self.organization, event=event, ) @@ -55,88 +57,103 @@ def test_skips_non_handled_actions(self): mock_logger.debug.assert_called_once() assert "skipped_action" in mock_logger.debug.call_args[0][0] - @with_feature("organizations:gen-ai-features") @responses.activate + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_forwards_rerequested_action_to_seer(self): - """Test that rerequested action forwards payload to Seer.""" + """Test that rerequested action forwards original_run_id to Seer.""" responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"success": True}, status=200, ) - success = forward_github_event_for_error_prediction( + success = handle_github_check_run_event( organization=self.organization, - event=self.event, + event=self.action_rerequested_event, ) assert success - # Verify request was made + # Verify request was made with correct payload assert len(responses.calls) == 1 + body = orjson.loads(responses.calls[0].request.body) + assert body == {"original_run_id": 4663713} - @with_feature("organizations:gen-ai-features") - @responses.activate - def test_handles_minimal_check_run_payload(self): - """Test that minimal check_run with missing fields is handled.""" - minimal_event = { + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) + def test_fails_when_external_id_missing(self): + """Test that missing external_id returns False.""" + event = { "action": "rerequested", - "check_run": {}, # No external_id or html_url + "check_run": {"html_url": "https://github.com/test/repo/runs/4"}, } - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", - json={"success": True}, - status=200, - ) + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + success = handle_github_check_run_event( + organization=self.organization, + event=event, + ) + assert not success + mock_logger.warning.assert_called_once() + assert "missing_external_id" in mock_logger.warning.call_args[0][0] - success = forward_github_event_for_error_prediction( - organization=self.organization, - event=minimal_event, - ) - assert success - # Should succeed even with minimal payload - assert len(responses.calls) == 1 + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) + def test_fails_when_external_id_not_numeric(self): + """Test that non-numeric external_id returns False.""" + event = { + "action": "rerequested", + "check_run": { + "external_id": "not-a-number", + "html_url": "https://github.com/test/repo/runs/4", + }, + } + + with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: + success = handle_github_check_run_event( + organization=self.organization, + event=event, + ) + assert not success + mock_logger.warning.assert_called_once() + assert "invalid_external_id" in mock_logger.warning.call_args[0][0] - @with_feature("organizations:gen-ai-features") @responses.activate + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_handles_seer_error_response(self): """Test that Seer errors are caught and logged.""" responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"error": "Internal server error"}, status=500, ) with patch("sentry.seer.error_prediction.webhooks.logger") as mock_logger: - success = forward_github_event_for_error_prediction( + success = handle_github_check_run_event( organization=self.organization, - event=self.event, + event=self.action_rerequested_event, ) assert not success # Verify exception logging mock_logger.exception.assert_called_once() assert "check_run.forward.exception" in mock_logger.exception.call_args[0][0] - @with_feature("organizations:gen-ai-features") @responses.activate + @with_feature({"organizations:gen-ai-features", "organizations:seat-based-seer-enabled"}) def test_includes_signed_headers(self): """Test that request includes signed headers for Seer authentication.""" responses.add( responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/github", + f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/pr-review/rerun", json={"success": True}, status=200, ) - success = forward_github_event_for_error_prediction( + success = handle_github_check_run_event( organization=self.organization, - event=self.event, + event=self.action_rerequested_event, ) assert success + # Verify request has content-type header request = responses.calls[0].request assert request.headers["content-type"] == "application/json;charset=utf-8" - # Note: sign_with_seer_secret headers are also included but harder to verify in tests From a075f1c795f5e471f1ab8254c68b2776efb3b01d Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Fri, 12 Dec 2025 14:57:37 +0100 Subject: [PATCH 6/6] possibly fix tests --- fixtures/github.py | 16 +++++++++++++++- .../github/test_check_run_webhook.py | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/fixtures/github.py b/fixtures/github.py index a41d08dc7bf8d4..fdaa3d9c868984 100644 --- a/fixtures/github.py +++ b/fixtures/github.py @@ -3543,8 +3543,16 @@ }""" # Simplified example of a check_run rerequested action event +# Note: installation.id must match the external_id used in create_github_integration (default: "12345") +# Note: repository.id must match a Repository created for the organization (use create_repo in tests) CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE = b"""{ "action": "rerequested", + "installation": {"id": 12345}, + "repository": { + "id": 35129377, + "full_name": "getsentry/sentry", + "html_url": "https://github.com/getsentry/sentry" + }, "check_run": { "external_id": "4663713", "html_url": "https://github.com/test/repo/runs/4" @@ -3552,5 +3560,11 @@ }""" CHECK_RUN_COMPLETED_EVENT_EXAMPLE = b"""{ - "action": "completed" + "action": "completed", + "installation": {"id": 12345}, + "repository": { + "id": 35129377, + "full_name": "getsentry/sentry", + "html_url": "https://github.com/getsentry/sentry" + } }""" diff --git a/tests/sentry/integrations/github/test_check_run_webhook.py b/tests/sentry/integrations/github/test_check_run_webhook.py index 661d8d77437107..156cfbd89b109f 100644 --- a/tests/sentry/integrations/github/test_check_run_webhook.py +++ b/tests/sentry/integrations/github/test_check_run_webhook.py @@ -16,7 +16,14 @@ class CheckRunEventWebhookTest(GitHubWebhookTestCase): def _send_check_run_event(self, event_data: bytes | str) -> Response: """Helper to send check_run event.""" - self.create_github_integration() + integration = self.create_github_integration() + # Create a repository that matches the fixture's repository.id (35129377) + self.create_repo( + project=self.project, + provider="integrations:github", + external_id="35129377", + integration_id=integration.id, + ) response = self.send_github_webhook_event("check_run", event_data) assert response.status_code == 204 return response