-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(webhooks): Forward check run events to Seer #104455
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,8 +85,11 @@ def provider(self) -> str: | |
| def _handle(self, integration: RpcIntegration, event: Mapping[str, Any], **kwargs) -> None: | ||
| pass | ||
|
|
||
| def __call__(self, event: Mapping[str, Any], **kwargs) -> None: | ||
| external_id = get_github_external_id(event=event, host=kwargs.get("host")) | ||
| def __call__(self, event: Mapping[str, Any], **kwargs: Mapping[str, Any]) -> None: | ||
| 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 | ||
|
|
@@ -784,6 +787,34 @@ 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.PULL_REQUEST | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be CHECK_RUN?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could be. |
||
|
|
||
| def _handle( | ||
| self, | ||
| integration: RpcIntegration, | ||
| event: Mapping[str, Any], | ||
| **kwargs, | ||
| ) -> None: | ||
| # Get organization from kwargs (populated by GitHubWebhook base class) | ||
| organization = kwargs.get("organization") | ||
| if organization is None: | ||
| logger.warning("github.webhook.check_run.missing-organization") | ||
| return | ||
|
|
||
| # XXX: Add support for registering functions to call | ||
| from sentry.seer.error_prediction.webhooks import forward_github_event_for_error_prediction | ||
|
|
||
| forward_github_event_for_error_prediction(organization=organization, event=event) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: CheckRunEventWebhook._handle never called without repository keyThe |
||
|
|
||
|
|
||
| @all_silo_endpoint | ||
| class GitHubIntegrationsWebhookEndpoint(Endpoint): | ||
| """ | ||
|
|
@@ -804,6 +835,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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # Error prediction module for Seer integration | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we should call it |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| 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] | ||
| # 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], | ||
| ) -> bool: | ||
| """ | ||
| 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 | ||
| Returns: | ||
| True if the event was forwarded successfully, False otherwise | ||
| """ | ||
| check_run = event.get("check_run") | ||
| action = event.get("action") | ||
|
|
||
| extra = { | ||
| "organization_id": organization.id, | ||
| "action": action, | ||
| "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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we have a function for this somewhere... and I'm not sure this check is complete. But I'd have to double check too 😅 |
||
| logger.debug("seer.error_prediction.check_run.feature_disabled", extra=extra) | ||
| return False | ||
|
|
||
| # Only handle relevant actions | ||
| if action not in HANDLED_ACTIONS: | ||
| logger.debug("seer.error_prediction.check_run.skipped_action", extra=extra) | ||
| 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 = { | ||
| "action": action, | ||
| "check_run": { | ||
| "external_id": check_run.get("external_id"), | ||
| "html_url": check_run.get("html_url"), | ||
| }, | ||
| } | ||
| outcome = "failure" | ||
| try: | ||
| post_to_seer(path=SEER_ERROR_PREDICTION_PATH, payload=payload) | ||
| 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={"outcome": outcome}) | ||
|
|
||
| return outcome == "success" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are there docs that list the event format if we want to check the full payload later?