Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions fixtures/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -3541,3 +3541,16 @@
"site_admin": false
}
}"""

# Simplified example of a check_run rerequested action event
Copy link
Contributor

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?

CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE = b"""{
"action": "rerequested",
"check_run": {
"external_id": "4663713",
"html_url": "https://github.com/test/repo/runs/4"
}
}"""

CHECK_RUN_COMPLETED_EVENT_EXAMPLE = b"""{
"action": "completed"
}"""
36 changes: 34 additions & 2 deletions src/sentry/integrations/github/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be CHECK_RUN?

Copy link
Member Author

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: CheckRunEventWebhook._handle never called without repository key

The CheckRunEventWebhook class inherits from GitHubWebhook but doesn't override the __call__ method. The parent class's __call__ method only invokes _handle when the event contains a "repository" key (line 116 of the base class). GitHub check_run webhook events for rerequested actions may not include a repository field in the payload, and the test fixtures confirm this. As a result, _handle will never be called for check_run events lacking a repository, and the forwarding to Seer won't happen. The InstallationEventWebhook class handles this correctly by overriding __call__ entirely.

Fix in Cursor Fix in Web



@all_silo_endpoint
class GitHubIntegrationsWebhookEndpoint(Endpoint):
"""
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/integrations/github/webhook_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from enum import StrEnum

GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT"
Expand All @@ -14,3 +16,4 @@ class GithubWebhookType(StrEnum):
PULL_REQUEST_REVIEW_COMMENT = "pull_request_review_comment"
PULL_REQUEST_REVIEW = "pull_request_review"
PUSH = "push"
CHECK_RUN = "check_run"
1 change: 1 addition & 0 deletions src/sentry/seer/error_prediction/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Error prediction module for Seer integration
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe we should call it code_review that is closer to the external name of the feature?

90 changes: 90 additions & 0 deletions src/sentry/seer/error_prediction/webhooks.py
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):
Copy link
Contributor

Choose a reason for hiding this comment

The 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"
50 changes: 50 additions & 0 deletions src/sentry/seer/signed_seer_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"):
Expand Down
137 changes: 137 additions & 0 deletions src/sentry/testutils/helpers/github.py
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,
)
Loading
Loading