diff --git a/src/sentry/testutils/helpers/github.py b/src/sentry/testutils/helpers/github.py index 156eb47df5e224..f5b8427739bd96 100644 --- a/src/sentry/testutils/helpers/github.py +++ b/src/sentry/testutils/helpers/github.py @@ -4,10 +4,13 @@ from __future__ import annotations +from collections.abc import Collection, Generator, Mapping +from contextlib import contextmanager from datetime import datetime, timedelta from typing import Any from uuid import uuid4 +import orjson from django.http.response import HttpResponseBase from sentry import options @@ -131,3 +134,42 @@ def send_github_webhook_event( content_type="application/json", **headers, ) + + +class GitHubWebhookCodeReviewTestCase(GitHubWebhookTestCase): + CODE_REVIEW_FEATURES = {"organizations:gen-ai-features", "organizations:code-review-beta"} + + @contextmanager + def code_review_setup( + self, + features: Collection[str] | Mapping[str, Any] | None = None, + forward_to_overwatch: bool = False, + ) -> Generator[None]: + """Helper to set up code review test context.""" + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + features_to_enable = self.CODE_REVIEW_FEATURES if features is None else features + with ( + self.feature(features_to_enable), + self.options({"github.webhook.issue-comment": forward_to_overwatch}), + ): + yield + + def _send_webhook_event( + self, github_event: GithubWebhookType, event_data: bytes | str + ) -> HttpResponseBase: + """Helper to send a GitHub webhook event.""" + self.event_dict = ( + orjson.loads(event_data) if isinstance(event_data, (bytes, str)) else event_data + ) + repo_id = int(self.event_dict["repository"]["id"]) + + integration = self.create_github_integration() + self.create_repo( + project=self.project, + provider="integrations:github", + external_id=repo_id, + integration_id=integration.id, + ) + response = self.send_github_webhook_event(github_event, event_data) + assert response.status_code == 204 + return response diff --git a/tests/sentry/seer/code_review/test_webhooks.py b/tests/sentry/seer/code_review/test_webhooks.py index 3d1a01274a282a..3cead88d856d8d 100644 --- a/tests/sentry/seer/code_review/test_webhooks.py +++ b/tests/sentry/seer/code_review/test_webhooks.py @@ -1,28 +1,15 @@ -from collections.abc import Collection, Generator, Mapping -from contextlib import contextmanager from datetime import datetime, timedelta, timezone -from typing import Any from unittest.mock import MagicMock, patch -import orjson import pytest -from django.http.response import HttpResponseBase from sentry_protos.taskbroker.v1.taskbroker_pb2 import RetryState from urllib3 import BaseHTTPResponse from urllib3.exceptions import HTTPError -from fixtures.github import ( - CHECK_RUN_COMPLETED_EVENT_EXAMPLE, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, -) from sentry.integrations.github.client import GitHubReaction from sentry.integrations.github.webhook_types import GithubWebhookType -from sentry.integrations.models.integration import Integration -from sentry.models.organizationcontributors import OrganizationContributors from sentry.seer.code_review.utils import ClientError -from sentry.seer.code_review.webhooks.check_run import GitHubCheckRunAction from sentry.seer.code_review.webhooks.issue_comment import ( - SENTRY_REVIEW_COMMAND, _add_eyes_reaction_to_comment, is_pr_review_command, ) @@ -33,224 +20,6 @@ process_github_webhook_event, ) from sentry.testutils.cases import TestCase -from sentry.testutils.helpers.features import with_feature -from sentry.testutils.helpers.github import GitHubWebhookTestCase - -CODE_REVIEW_FEATURES = {"organizations:gen-ai-features", "organizations:code-review-beta"} - -DEFAULT_PR_AUTHOR_ID = "12345678" - - -class GitHubWebhookHelper(GitHubWebhookTestCase): - """Base class for GitHub webhook integration tests.""" - - github_integration: Integration | None = None - - @pytest.fixture(autouse=True) - def mock_billing_quota(self) -> Generator[None]: - """Mock billing quota check to return True for all tests.""" - with patch( - "sentry.seer.code_review.billing.quotas.backend.check_seer_quota", return_value=True - ): - yield - - def _enable_code_review(self) -> None: - """Enable all required options for code review to work.""" - self.organization.update_option("sentry:enable_pr_review_test_generation", True) - - # Setup billing data - self.github_integration = self.create_github_integration() - OrganizationContributors.objects.get_or_create( - organization_id=self.organization.id, - integration_id=self.github_integration.id, - external_identifier=DEFAULT_PR_AUTHOR_ID, - ) - - @contextmanager - def code_review_setup( - self, features: Collection[str] | Mapping[str, Any] = CODE_REVIEW_FEATURES - ) -> Generator[None]: - """Helper to set up code review test context.""" - self.organization.update_option("sentry:enable_pr_review_test_generation", True) - - # Setup billing data - self.github_integration = self.create_github_integration() - OrganizationContributors.objects.get_or_create( - organization_id=self.organization.id, - integration_id=self.github_integration.id, - external_identifier=DEFAULT_PR_AUTHOR_ID, - ) - - with ( - self.feature(features), - self.options({"github.webhook.issue-comment": False}), - ): - yield - - def _send_webhook_event( - self, github_event: GithubWebhookType, event_data: bytes | str - ) -> HttpResponseBase: - """Helper to send a GitHub webhook event.""" - self.event_dict = ( - orjson.loads(event_data) if isinstance(event_data, (bytes, str)) else event_data - ) - repo_id = int(self.event_dict["repository"]["id"]) - - integration = self.github_integration or self.create_github_integration() - self.create_repo( - project=self.project, - provider="integrations:github", - external_id=repo_id, - integration_id=integration.id, - ) - response = self.send_github_webhook_event(github_event, event_data) - assert response.status_code == 204 - return response - - -class CheckRunEventWebhookTest(GitHubWebhookHelper): - """Integration tests for GitHub check_run webhook events.""" - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_base_case(self, mock_task: MagicMock) -> None: - """Test that rerequested action enqueues task with correct parameters.""" - self._enable_code_review() - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, - ) - - mock_task.delay.assert_called_once() - call_kwargs = mock_task.delay.call_args[1] - assert call_kwargs["github_event"] == GithubWebhookType.CHECK_RUN - assert ( - call_kwargs["event_payload"]["original_run_id"] - == self.event_dict["check_run"]["external_id"] - ) - - assert call_kwargs["action"] == GitHubCheckRunAction.REREQUESTED.value - assert call_kwargs["html_url"] == self.event_dict["check_run"]["html_url"] - assert "enqueued_at_str" in call_kwargs - assert isinstance(call_kwargs["enqueued_at_str"], str) - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_skips_when_ai_features_disabled(self, mock_task: MagicMock) -> None: - """Test that the handler returns early when AI features are not enabled (even though the option is enabled).""" - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, - ) - mock_task.delay.assert_not_called() - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_fails_when_action_missing(self, mock_task: MagicMock) -> None: - """Test that missing action field is handled gracefully without KeyError.""" - self._enable_code_review() - event_without_action = orjson.loads(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) - del event_without_action["action"] - - with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - orjson.dumps(event_without_action), - ) - mock_task.delay.assert_not_called() - mock_logger.error.assert_called_once() - assert "github.webhook.check_run.missing-action" in str(mock_logger.error.call_args) - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_fails_when_external_id_missing(self, mock_task: MagicMock) -> None: - """Test that missing external_id is handled gracefully.""" - self._enable_code_review() - event_without_external_id = orjson.loads(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) - del event_without_external_id["check_run"]["external_id"] - - with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - orjson.dumps(event_without_external_id), - ) - mock_task.delay.assert_not_called() - mock_logger.exception.assert_called_once() - assert ( - "github.webhook.check_run.invalid-payload" in mock_logger.exception.call_args[0][0] - ) - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_fails_when_external_id_not_numeric(self, mock_task: MagicMock) -> None: - """Test that non-numeric external_id is handled gracefully.""" - self._enable_code_review() - event_with_invalid_external_id = orjson.loads(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) - event_with_invalid_external_id["check_run"]["external_id"] = "not-a-number" - - with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - orjson.dumps(event_with_invalid_external_id), - ) - mock_task.delay.assert_not_called() - mock_logger.exception.assert_called_once() - assert ( - "github.webhook.check_run.invalid-payload" in mock_logger.exception.call_args[0][0] - ) - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_enqueues_task_for_processing(self, mock_task: MagicMock) -> None: - """Test that webhook successfully enqueues task for async processing.""" - self._enable_code_review() - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, - ) - - mock_task.delay.assert_called_once() - call_kwargs = mock_task.delay.call_args[1] - assert call_kwargs["github_event"] == GithubWebhookType.CHECK_RUN - assert ( - call_kwargs["event_payload"]["original_run_id"] - == self.event_dict["check_run"]["external_id"] - ) - - def test_check_run_without_integration_returns_204(self) -> None: - """Test that check_run events without integration return 204.""" - response = self.send_github_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_COMPLETED_EVENT_EXAMPLE, - ) - assert response.status_code == 204 - - @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") - @with_feature({"organizations:gen-ai-features"}) - def test_check_run_runs_when_code_review_beta_flag_disabled_but_pr_review_test_generation_enabled( - self, mock_task: MagicMock - ) -> None: - """Test that task is enqueued when code-review-beta flag is off but pr_review_test_generation is enabled.""" - self._enable_code_review() - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, - ) - mock_task.delay.assert_called_once() - - @patch("sentry.seer.code_review.utils.make_seer_request") - @with_feature(CODE_REVIEW_FEATURES) - def test_check_run_skips_when_hide_ai_features_enabled( - self, mock_make_seer_request: MagicMock - ) -> None: - """Test that task is not enqueued when hide_ai_features option is True.""" - self._enable_code_review() - self.organization.update_option("sentry:hide_ai_features", True) - self._send_webhook_event( - GithubWebhookType.CHECK_RUN, - CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, - ) - mock_make_seer_request.assert_not_called() class ProcessGitHubWebhookEventTest(TestCase): @@ -762,177 +531,6 @@ def test_false_cases(self) -> None: assert not is_pr_review_command("") -class IssueCommentEventWebhookTest(GitHubWebhookHelper): - """Integration tests for GitHub issue_comment webhook events.""" - - @pytest.fixture(autouse=True) - def mock_github_api_calls(self) -> Generator[None]: - """ - Prevents real HTTP requests to GitHub API across all tests. - Uses autouse fixture to apply mocking automatically without @patch decorators on each test. - """ - mock_client_instance = MagicMock() - mock_client_instance.get_pull_request.return_value = {"head": {"sha": "abc123"}} - - with ( - patch( - "sentry.integrations.github.client.GitHubApiClient.create_comment_reaction" - ) as mock_reaction, - patch( - "sentry.seer.code_review.utils.GitHubApiClient", return_value=mock_client_instance - ) as mock_api_client, - ): - self.mock_reaction = mock_reaction - self.mock_api_client = mock_api_client - yield - - @pytest.fixture(autouse=True) - def mock_seer_request(self) -> Generator[None]: - """ - Prevents real HTTP requests to Seer API across all tests. - Uses autouse fixture to apply mocking automatically without @patch decorators on each test. - """ - with patch("sentry.seer.code_review.webhooks.task.make_seer_request") as mock_seer: - self.mock_seer = mock_seer - yield - - def _send_issue_comment_event(self, event_data: bytes | str) -> HttpResponseBase: - return self._send_webhook_event(GithubWebhookType.ISSUE_COMMENT, event_data) - - def _build_issue_comment_event( - self, comment_body: str, comment_id: int | None = 123456789 - ) -> bytes: - event = { - "action": "created", - "comment": { - "body": comment_body, - "id": comment_id, - }, - "issue": { - "number": 42, - "pull_request": {"url": "https://api.github.com/repos/owner/repo/pulls/42"}, - "user": { - "id": int(DEFAULT_PR_AUTHOR_ID), - "login": "pr-author", - }, - }, - "repository": { - "id": 12345, - "full_name": "owner/repo", - "html_url": "https://github.com/owner/repo", - }, - "sender": { - "id": 87654321, - "login": "commenter", - }, - } - return orjson.dumps(event) - - def test_skips_when_code_review_features_are_missing(self) -> None: - """Test that processing is skipped when code review features are missing.""" - with self.code_review_setup(features={}): # Missing on purpose - event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - self.mock_seer.assert_not_called() - - def test_skips_when_no_review_command(self) -> None: - """Test that processing is skipped when comment doesn't contain review command.""" - with self.code_review_setup(): - event = self._build_issue_comment_event("This is a regular comment without the command") - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - self.mock_seer.assert_not_called() - - def test_runs_when_code_review_beta_flag_disabled_but_pr_review_test_generation_enabled( - self, - ) -> None: - """Test that processing runs with gen-ai-features flag alone when org option is enabled.""" - with self.code_review_setup(features={"organizations:gen-ai-features"}): - event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - self.mock_seer.assert_called_once() - - def test_adds_reaction_and_forwards_when_valid(self) -> None: - """Test successful PR review command processing with reaction and Seer request.""" - with self.code_review_setup(): - event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - self.mock_reaction.assert_called_once_with( - "owner/repo", "123456789", GitHubReaction.EYES - ) - self.mock_seer.assert_called_once() - - call_args = self.mock_seer.call_args - assert call_args[1]["path"] == "/v1/automation/overwatch-request" - payload = call_args[1]["payload"] - assert payload["request_type"] == "pr-review" - assert payload["data"]["repo"]["base_commit_sha"] == "abc123" - - @patch("sentry.seer.code_review.webhooks.issue_comment._add_eyes_reaction_to_comment") - def test_skips_reaction_when_no_comment_id(self, mock_reaction: MagicMock) -> None: - """Test that reaction is skipped when comment has no ID, but processing continues.""" - with self.code_review_setup(): - event = self._build_issue_comment_event(SENTRY_REVIEW_COMMAND, comment_id=None) - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - mock_reaction.assert_not_called() - self.mock_seer.assert_called_once() - - @patch("sentry.seer.code_review.webhooks.issue_comment._add_eyes_reaction_to_comment") - def test_skips_processing_when_option_is_true(self, mock_reaction: MagicMock) -> None: - """Test that when github.webhook.issue-comment option is True (kill switch), no processing occurs.""" - self._enable_code_review() - with ( - self.feature(CODE_REVIEW_FEATURES), - self.options({"github.webhook.issue-comment": True}), - ): - event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - mock_reaction.assert_not_called() - self.mock_seer.assert_not_called() - - def test_validates_seer_request_contains_trigger_metadata(self) -> None: - """Test that Seer request includes trigger metadata from the comment.""" - with self.code_review_setup(): - event_dict = orjson.loads( - self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") - ) - event_dict["comment"]["user"] = {"login": "test-user"} - event = orjson.dumps(event_dict) - - with self.tasks(): - response = self._send_issue_comment_event(event) - assert response.status_code == 204 - - self.mock_seer.assert_called_once() - payload = self.mock_seer.call_args[1]["payload"] - assert payload["data"]["config"]["trigger_user"] == "test-user" - assert payload["data"]["config"]["trigger_comment_id"] == 123456789 - assert payload["data"]["config"]["trigger_comment_type"] == "issue_comment" - - class AddEyesReactionTest(TestCase): def setUp(self) -> None: super().setUp() diff --git a/tests/sentry/seer/code_review/webhooks/test_check_run.py b/tests/sentry/seer/code_review/webhooks/test_check_run.py new file mode 100644 index 00000000000000..fe953ceb965c3b --- /dev/null +++ b/tests/sentry/seer/code_review/webhooks/test_check_run.py @@ -0,0 +1,152 @@ +from unittest.mock import MagicMock, patch + +import orjson + +from fixtures.github import ( + CHECK_RUN_COMPLETED_EVENT_EXAMPLE, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, +) +from sentry.integrations.github.webhook_types import GithubWebhookType +from sentry.seer.code_review.webhooks.check_run import GitHubCheckRunAction +from sentry.testutils.helpers.github import GitHubWebhookCodeReviewTestCase + + +class CheckRunEventWebhookTest(GitHubWebhookCodeReviewTestCase): + """Integration tests for GitHub check_run webhook events.""" + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_base_case(self, mock_task: MagicMock) -> None: + """Test that rerequested action enqueues task with correct parameters.""" + with self.code_review_setup(): + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, + ) + + mock_task.delay.assert_called_once() + call_kwargs = mock_task.delay.call_args[1] + assert call_kwargs["github_event"] == GithubWebhookType.CHECK_RUN + assert ( + call_kwargs["event_payload"]["original_run_id"] + == self.event_dict["check_run"]["external_id"] + ) + + assert call_kwargs["action"] == GitHubCheckRunAction.REREQUESTED.value + assert call_kwargs["html_url"] == self.event_dict["check_run"]["html_url"] + assert "enqueued_at_str" in call_kwargs + assert isinstance(call_kwargs["enqueued_at_str"], str) + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_skips_when_ai_features_disabled(self, mock_task: MagicMock) -> None: + """Test that the handler returns early when AI features are not enabled (even though the option is enabled).""" + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, + ) + mock_task.delay.assert_not_called() + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_fails_when_action_missing(self, mock_task: MagicMock) -> None: + """Test that missing action field is handled gracefully without KeyError.""" + with self.code_review_setup(): + event_without_action = orjson.loads(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) + del event_without_action["action"] + + with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + orjson.dumps(event_without_action), + ) + mock_task.delay.assert_not_called() + mock_logger.error.assert_called_once() + assert "github.webhook.check_run.missing-action" in str(mock_logger.error.call_args) + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_fails_when_external_id_missing(self, mock_task: MagicMock) -> None: + """Test that missing external_id is handled gracefully.""" + with self.code_review_setup(): + event_without_external_id = orjson.loads(CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE) + del event_without_external_id["check_run"]["external_id"] + + with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + orjson.dumps(event_without_external_id), + ) + mock_task.delay.assert_not_called() + mock_logger.exception.assert_called_once() + assert ( + "github.webhook.check_run.invalid-payload" + in mock_logger.exception.call_args[0][0] + ) + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_fails_when_external_id_not_numeric(self, mock_task: MagicMock) -> None: + """Test that non-numeric external_id is handled gracefully.""" + with self.code_review_setup(): + event_with_invalid_external_id = orjson.loads( + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE + ) + event_with_invalid_external_id["check_run"]["external_id"] = "not-a-number" + + with patch("sentry.seer.code_review.webhooks.check_run.logger") as mock_logger: + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + orjson.dumps(event_with_invalid_external_id), + ) + mock_task.delay.assert_not_called() + mock_logger.exception.assert_called_once() + assert ( + "github.webhook.check_run.invalid-payload" + in mock_logger.exception.call_args[0][0] + ) + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_enqueues_task_for_processing(self, mock_task: MagicMock) -> None: + """Test that webhook successfully enqueues task for async processing.""" + with self.code_review_setup(): + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, + ) + + mock_task.delay.assert_called_once() + call_kwargs = mock_task.delay.call_args[1] + assert call_kwargs["github_event"] == GithubWebhookType.CHECK_RUN + assert ( + call_kwargs["event_payload"]["original_run_id"] + == self.event_dict["check_run"]["external_id"] + ) + + def test_check_run_without_integration_returns_204(self) -> None: + """Test that check_run events without integration return 204.""" + response = self.send_github_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_COMPLETED_EVENT_EXAMPLE, + ) + assert response.status_code == 204 + + @patch("sentry.seer.code_review.webhooks.task.process_github_webhook_event") + def test_check_run_runs_when_code_review_beta_flag_disabled_but_pr_review_test_generation_enabled( + self, mock_task: MagicMock + ) -> None: + """Test that task is enqueued when code-review-beta flag is off but pr_review_test_generation is enabled.""" + with self.code_review_setup(features={"organizations:gen-ai-features"}): + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, + ) + mock_task.delay.assert_called_once() + + @patch("sentry.seer.code_review.utils.make_seer_request") + def test_check_run_skips_when_hide_ai_features_enabled( + self, mock_make_seer_request: MagicMock + ) -> None: + """Test that task is not enqueued when hide_ai_features option is True.""" + with self.code_review_setup(): + self.organization.update_option("sentry:hide_ai_features", True) + self._send_webhook_event( + GithubWebhookType.CHECK_RUN, + CHECK_RUN_REREQUESTED_ACTION_EVENT_EXAMPLE, + ) + mock_make_seer_request.assert_not_called() diff --git a/tests/sentry/seer/code_review/webhooks/test_issue_comment.py b/tests/sentry/seer/code_review/webhooks/test_issue_comment.py new file mode 100644 index 00000000000000..f1c4f4db57c79d --- /dev/null +++ b/tests/sentry/seer/code_review/webhooks/test_issue_comment.py @@ -0,0 +1,170 @@ +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import orjson +import pytest +from django.http.response import HttpResponseBase + +from sentry.integrations.github.client import GitHubReaction +from sentry.integrations.github.webhook_types import GithubWebhookType +from sentry.seer.code_review.webhooks.issue_comment import SENTRY_REVIEW_COMMAND +from sentry.testutils.helpers.github import GitHubWebhookCodeReviewTestCase + + +class IssueCommentEventWebhookTest(GitHubWebhookCodeReviewTestCase): + """Integration tests for GitHub issue_comment webhook events.""" + + @pytest.fixture(autouse=True) + def mock_github_api_calls(self) -> Generator[None]: + """ + Prevents real HTTP requests to GitHub API across all tests. + Uses autouse fixture to apply mocking automatically without @patch decorators on each test. + """ + mock_client_instance = MagicMock() + mock_client_instance.get_pull_request.return_value = {"head": {"sha": "abc123"}} + + with ( + patch( + "sentry.integrations.github.client.GitHubApiClient.create_comment_reaction" + ) as mock_reaction, + patch( + "sentry.seer.code_review.utils.GitHubApiClient", return_value=mock_client_instance + ) as mock_api_client, + ): + self.mock_reaction = mock_reaction + self.mock_api_client = mock_api_client + yield + + @pytest.fixture(autouse=True) + def mock_seer_request(self) -> Generator[None]: + """ + Prevents real HTTP requests to Seer API across all tests. + Uses autouse fixture to apply mocking automatically without @patch decorators on each test. + """ + with patch("sentry.seer.code_review.webhooks.task.make_seer_request") as mock_seer: + self.mock_seer = mock_seer + yield + + def _send_issue_comment_event(self, event_data: bytes | str) -> HttpResponseBase: + return self._send_webhook_event(GithubWebhookType.ISSUE_COMMENT, event_data) + + def _build_issue_comment_event( + self, comment_body: str, comment_id: int | None = 123456789 + ) -> bytes: + event = { + "action": "created", + "comment": { + "body": comment_body, + "id": comment_id, + }, + "issue": { + "number": 42, + "pull_request": {"url": "https://api.github.com/repos/owner/repo/pulls/42"}, + "user": { + "id": 12345678, + "login": "pr-author", + }, + }, + "repository": { + "id": 12345, + "full_name": "owner/repo", + "html_url": "https://github.com/owner/repo", + }, + "sender": { + "id": 87654321, + "login": "commenter", + }, + } + return orjson.dumps(event) + + def test_skips_when_code_review_features_are_missing(self) -> None: + """Test that processing is skipped when code review features are missing.""" + # Missing features on purpose + with self.code_review_setup(features={}), self.tasks(): + event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") + + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + self.mock_seer.assert_not_called() + + def test_skips_when_no_review_command(self) -> None: + """Test that processing is skipped when comment doesn't contain review command.""" + with self.code_review_setup(), self.tasks(): + event = self._build_issue_comment_event("This is a regular comment without the command") + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + self.mock_seer.assert_not_called() + + def test_runs_when_code_review_beta_flag_disabled_but_pr_review_test_generation_enabled( + self, + ) -> None: + """Test that processing runs with gen-ai-features flag alone when org option is enabled.""" + with self.code_review_setup(features={"organizations:gen-ai-features"}), self.tasks(): + event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") + + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + self.mock_seer.assert_called_once() + + def test_adds_reaction_and_forwards_when_valid(self) -> None: + """Test successful PR review command processing with reaction and Seer request.""" + with self.code_review_setup(), self.tasks(): + event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") + + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + self.mock_reaction.assert_called_once_with( + "owner/repo", "123456789", GitHubReaction.EYES + ) + self.mock_seer.assert_called_once() + + call_args = self.mock_seer.call_args + assert call_args[1]["path"] == "/v1/automation/overwatch-request" + payload = call_args[1]["payload"] + assert payload["request_type"] == "pr-review" + assert payload["data"]["repo"]["base_commit_sha"] == "abc123" + + @patch("sentry.seer.code_review.webhooks.issue_comment._add_eyes_reaction_to_comment") + def test_skips_reaction_when_no_comment_id(self, mock_reaction: MagicMock) -> None: + """Test that reaction is skipped when comment has no ID, but processing continues.""" + with self.code_review_setup(), self.tasks(): + event = self._build_issue_comment_event(SENTRY_REVIEW_COMMAND, comment_id=None) + + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + mock_reaction.assert_not_called() + self.mock_seer.assert_called_once() + + @patch("sentry.seer.code_review.webhooks.issue_comment._add_eyes_reaction_to_comment") + def test_skips_processing_when_option_is_true(self, mock_reaction: MagicMock) -> None: + """Test that when github.webhook.issue-comment option is True (kill switch), no processing occurs.""" + with self.code_review_setup(forward_to_overwatch=True), self.tasks(): + event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + mock_reaction.assert_not_called() + self.mock_seer.assert_not_called() + + def test_validates_seer_request_contains_trigger_metadata(self) -> None: + """Test that Seer request includes trigger metadata from the comment.""" + with self.code_review_setup(), self.tasks(): + event_dict = orjson.loads( + self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") + ) + event_dict["comment"]["user"] = {"login": "test-user"} + event = orjson.dumps(event_dict) + + response = self._send_issue_comment_event(event) + assert response.status_code == 204 + + self.mock_seer.assert_called_once() + payload = self.mock_seer.call_args[1]["payload"] + assert payload["data"]["config"]["trigger_user"] == "test-user" + assert payload["data"]["config"]["trigger_comment_id"] == 123456789 + assert payload["data"]["config"]["trigger_comment_type"] == "issue_comment"