Skip to content

Commit 5aa7a40

Browse files
feat(code-review): Handle issue comment command (#105527)
Handle issue comment including the posting of eyes emoji from within sentry. This is behind an option for `github.webhook.issue-comment` which decides whether we route flow through this path or the path that forwards to overwatch. Note that though in theory overwatch at some point also supported review (including posting 👀 ) for `pull_request_review` and `pull_request_review_comment`, I tried out these flows and they don't seem to be working right now. I think it's fine to maintain parity and just port over the issue comment which can trigger a review with the on command phrase. Closes [CW-10](https://linear.app/getsentry/issue/CW-10/port-emoji-reactions-to-sentry) --------- Co-authored-by: Armen Zambrano G. <[email protected]>
1 parent cb7745d commit 5aa7a40

File tree

8 files changed

+409
-77
lines changed

8 files changed

+409
-77
lines changed

src/sentry/integrations/github/client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
from collections.abc import Mapping, Sequence
55
from datetime import datetime, timedelta
6+
from enum import StrEnum
67
from typing import Any, TypedDict
78

89
import orjson
@@ -59,6 +60,21 @@ def __repr__(self) -> str:
5960
return f"GithubRateLimitInfo(limit={self.limit},rem={self.remaining},reset={self.reset})"
6061

6162

63+
class GitHubReaction(StrEnum):
64+
"""
65+
https://docs.github.com/en/rest/reactions/reactions#about-reactions
66+
"""
67+
68+
PLUS_ONE = "+1"
69+
MINUS_ONE = "-1"
70+
LAUGH = "laugh"
71+
CONFUSED = "confused"
72+
HEART = "heart"
73+
HOORAY = "hooray"
74+
ROCKET = "rocket"
75+
EYES = "eyes"
76+
77+
6278
class GithubSetupApiClient(IntegrationProxyClient):
6379
"""
6480
API Client that doesn't require an installation.
@@ -585,6 +601,18 @@ def get_comment_reactions(self, repo: str, comment_id: str) -> Any:
585601
reactions.pop("url", None)
586602
return reactions
587603

604+
def create_comment_reaction(self, repo: str, comment_id: str, reaction: GitHubReaction) -> Any:
605+
"""
606+
https://docs.github.com/en/rest/reactions/reactions#create-reaction-for-an-issue-comment
607+
608+
Args:
609+
repo: Repository name in "owner/repo" format
610+
comment_id: The ID of the comment
611+
reaction: The reaction type
612+
"""
613+
endpoint = f"/repos/{repo}/issues/comments/{comment_id}/reactions"
614+
return self.post(endpoint, data={"content": reaction.value})
615+
588616
def get_user(self, gh_username: str) -> Any:
589617
"""
590618
https://docs.github.com/en/rest/users/users#get-a-user

src/sentry/integrations/github/webhook.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ def _handle(
177177
processor(
178178
github_event=github_event,
179179
event=event,
180+
integration=integration,
180181
organization=organization,
181182
repo=repo,
182183
**kwargs,
@@ -1016,8 +1017,7 @@ class IssueCommentEventWebhook(GitHubWebhook):
10161017
"""
10171018

10181019
EVENT_TYPE = IntegrationWebhookEventType.ISSUE_COMMENT
1019-
# XXX: Once we port the Overwatch feature, we can add the processor here.
1020-
WEBHOOK_EVENT_PROCESSORS = ()
1020+
WEBHOOK_EVENT_PROCESSORS = (code_review_handle_webhook_event,)
10211021

10221022

10231023
@all_silo_endpoint

src/sentry/seer/code_review/utils.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from django.conf import settings
77
from urllib3.exceptions import HTTPError
88

9-
from sentry.integrations.github.webhook_types import GithubWebhookType
109
from sentry.models.repository import Repository
1110
from sentry.net.http import connection_from_url
1211
from sentry.seer.signed_seer_api import make_signed_seer_api_request
@@ -18,11 +17,10 @@ class ClientError(Exception):
1817
pass
1918

2019

20+
# These values need to match the value defined in the Seer API.
2121
class SeerEndpoint(StrEnum):
22-
# XXX: We will need to either add a new one or re-use the overwatch-request endpoint.
2322
# https://github.com/getsentry/seer/blob/main/src/seer/routes/automation_request.py#L57
24-
SENTRY_REQUEST = "/v1/automation/sentry-request"
25-
# This needs to match the value defined in the Seer API:
23+
OVERWATCH_REQUEST = "/v1/automation/overwatch-request"
2624
# https://github.com/getsentry/seer/blob/main/src/seer/routes/codegen.py
2725
PR_REVIEW_RERUN = "/v1/automation/codegen/pr-review/rerun"
2826

@@ -58,8 +56,7 @@ def make_seer_request(path: str, payload: Mapping[str, Any]) -> bytes:
5856

5957

6058
# XXX: Do a thorough review of this function and make sure it's correct.
61-
def _transform_webhook_to_codegen_request(
62-
event_type: GithubWebhookType,
59+
def transform_webhook_to_codegen_request(
6360
event_payload: Mapping[str, Any],
6461
organization_id: int,
6562
repo: Repository,
@@ -68,7 +65,6 @@ def _transform_webhook_to_codegen_request(
6865
Transform a GitHub webhook payload into CodecovTaskRequest format for Seer.
6966
7067
Args:
71-
event_type: The type of GitHub webhook event
7268
event_payload: The full webhook event payload from GitHub
7369
organization_id: The Sentry organization ID
7470

src/sentry/seer/code_review/webhooks/handlers.py

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,26 @@
22

33
import logging
44
from collections.abc import Mapping
5-
from datetime import datetime, timezone
65
from typing import TYPE_CHECKING, Any
76

87
from sentry.integrations.github.webhook_types import GithubWebhookType
98
from sentry.models.organization import Organization
109
from sentry.models.repository import Repository
11-
from sentry.utils import metrics
1210

1311
if TYPE_CHECKING:
1412
from sentry.integrations.github.webhook import WebhookProcessor
1513

16-
from ..utils import _transform_webhook_to_codegen_request
1714
from .check_run import handle_check_run_event
15+
from .issue_comment import handle_issue_comment_event
1816

1917
logger = logging.getLogger(__name__)
2018

2119
METRICS_PREFIX = "seer.code_review.webhook"
2220

2321

24-
def handle_other_webhook_event(
25-
*,
26-
github_event: GithubWebhookType,
27-
event: Mapping[str, Any],
28-
organization: Organization,
29-
repo: Repository,
30-
**kwargs: Any,
31-
) -> None:
32-
"""
33-
Each webhook event type may implement its own handler.
34-
This is a generic handler for non-PR-related events (e.g., issue_comment on regular issues).
35-
"""
36-
from .task import process_github_webhook_event
37-
38-
transformed_event = _transform_webhook_to_codegen_request(
39-
github_event, dict(event), organization.id, repo
40-
)
41-
42-
if transformed_event is None:
43-
metrics.incr(
44-
f"{METRICS_PREFIX}.{github_event.value}.skipped",
45-
tags={"reason": "failed_to_transform", "github_event": github_event.value},
46-
)
47-
return
48-
49-
process_github_webhook_event.delay(
50-
github_event=github_event,
51-
event_payload=transformed_event,
52-
enqueued_at_str=datetime.now(timezone.utc).isoformat(),
53-
)
54-
metrics.incr(
55-
f"{METRICS_PREFIX}.{github_event.value}.enqueued",
56-
tags={"status": "success", "github_event": github_event.value},
57-
)
58-
59-
60-
EVENT_TYPE_TO_handler: dict[GithubWebhookType, WebhookProcessor] = {
22+
EVENT_TYPE_TO_HANDLER: dict[GithubWebhookType, WebhookProcessor] = {
6123
GithubWebhookType.CHECK_RUN: handle_check_run_event,
62-
GithubWebhookType.ISSUE_COMMENT: handle_other_webhook_event,
63-
GithubWebhookType.PULL_REQUEST: handle_other_webhook_event,
64-
GithubWebhookType.PULL_REQUEST_REVIEW: handle_other_webhook_event,
65-
GithubWebhookType.PULL_REQUEST_REVIEW_COMMENT: handle_other_webhook_event,
24+
GithubWebhookType.ISSUE_COMMENT: handle_issue_comment_event,
6625
}
6726

6827

@@ -84,7 +43,7 @@ def handle_webhook_event(
8443
repo: The repository that the webhook event is for
8544
**kwargs: Additional keyword arguments including integration
8645
"""
87-
handler = EVENT_TYPE_TO_handler.get(github_event)
46+
handler = EVENT_TYPE_TO_HANDLER.get(github_event)
8847
if handler is None:
8948
logger.warning(
9049
"github.webhook.handler.not_found",
@@ -99,8 +58,3 @@ def handle_webhook_event(
9958
repo=repo,
10059
**kwargs,
10160
)
102-
103-
104-
# Type checks to ensure the functions match WebhookProcessor protocol
105-
_type_checked_handle_other_webhook_event: WebhookProcessor = handle_other_webhook_event
106-
_type_checked_handle_check_run_event: WebhookProcessor = handle_check_run_event
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Handler for GitHub issue_comment webhook events.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import enum
8+
import logging
9+
from collections.abc import Mapping
10+
from typing import Any
11+
12+
from sentry import options
13+
from sentry.integrations.github.client import GitHubReaction
14+
from sentry.integrations.github.webhook_types import GithubWebhookType
15+
from sentry.integrations.services.integration import RpcIntegration
16+
from sentry.models.organization import Organization
17+
from sentry.models.repository import Repository
18+
from sentry.utils import metrics
19+
20+
from ..permissions import has_code_review_enabled
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class ErrorStatus(enum.StrEnum):
26+
MISSING_INTEGRATION = "missing_integration"
27+
REACTION_FAILED = "reaction_failed"
28+
29+
30+
class Log(enum.StrEnum):
31+
MISSING_INTEGRATION = "github.webhook.issue_comment.missing-integration"
32+
REACTION_FAILED = "github.webhook.issue_comment.reaction-failed"
33+
34+
35+
class Metrics(enum.StrEnum):
36+
ERROR = "seer.code_review.webhook.issue_comment.error"
37+
OUTCOME = "seer.code_review.webhook.issue_comment.outcome"
38+
39+
40+
SENTRY_REVIEW_COMMAND = "@sentry review"
41+
42+
43+
def is_pr_review_command(comment_body: str | None) -> bool:
44+
if comment_body is None:
45+
return False
46+
return SENTRY_REVIEW_COMMAND in comment_body.lower()
47+
48+
49+
def _add_eyes_reaction_to_comment(
50+
integration: RpcIntegration | None,
51+
organization: Organization,
52+
repo: Repository,
53+
comment_id: str,
54+
) -> None:
55+
extra = {"organization_id": organization.id, "repo": repo.name, "comment_id": comment_id}
56+
57+
if integration is None:
58+
metrics.incr(
59+
Metrics.ERROR.value,
60+
tags={"error_status": ErrorStatus.MISSING_INTEGRATION.value},
61+
)
62+
logger.warning(
63+
Log.MISSING_INTEGRATION.value,
64+
extra=extra,
65+
)
66+
return
67+
68+
try:
69+
client = integration.get_installation(organization_id=organization.id).get_client()
70+
client.create_comment_reaction(repo.name, comment_id, GitHubReaction.EYES)
71+
metrics.incr(
72+
Metrics.OUTCOME.value,
73+
tags={"status": "reaction_added"},
74+
)
75+
except Exception:
76+
metrics.incr(
77+
Metrics.ERROR.value,
78+
tags={"error_status": ErrorStatus.REACTION_FAILED.value},
79+
)
80+
logger.exception(
81+
Log.REACTION_FAILED.value,
82+
extra=extra,
83+
)
84+
85+
86+
def handle_issue_comment_event(
87+
*,
88+
github_event: GithubWebhookType,
89+
event: Mapping[str, Any],
90+
organization: Organization,
91+
repo: Repository,
92+
integration: RpcIntegration | None = None,
93+
**kwargs: Any,
94+
) -> None:
95+
"""
96+
Handle issue_comment webhook events for PR review commands.
97+
"""
98+
comment = event.get("comment", {})
99+
comment_id = comment.get("id")
100+
comment_body = comment.get("body")
101+
102+
if not has_code_review_enabled(organization):
103+
return
104+
105+
if not is_pr_review_command(comment_body or ""):
106+
return
107+
108+
if not options.get("github.webhook.issue-comment"):
109+
if comment_id:
110+
_add_eyes_reaction_to_comment(integration, organization, repo, str(comment_id))
111+
112+
from .task import schedule_task
113+
114+
schedule_task(
115+
github_event=github_event,
116+
event=event,
117+
organization=organization,
118+
repo=repo,
119+
)

src/sentry/seer/code_review/webhooks/task.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from urllib3.exceptions import HTTPError
99

1010
from sentry.integrations.github.webhook_types import GithubWebhookType
11+
from sentry.models.organization import Organization
12+
from sentry.models.repository import Repository
13+
from sentry.seer.code_review.utils import transform_webhook_to_codegen_request
1114
from sentry.silo.base import SiloMode
1215
from sentry.tasks.base import instrumented_task
1316
from sentry.taskworker.namespaces import seer_code_review_tasks
@@ -25,6 +28,7 @@
2528
MAX_RETRIES = 3
2629
DELAY_BETWEEN_RETRIES = 60 # 1 minute
2730
RETRYABLE_ERRORS = (HTTPError,)
31+
METRICS_PREFIX = "seer.code_review.task"
2832

2933

3034
def _call_seer_request(
@@ -35,7 +39,38 @@ def _call_seer_request(
3539
"""
3640
assert github_event != GithubWebhookType.CHECK_RUN
3741
# XXX: Add checking options to prevent sending events to Seer by mistake.
38-
make_seer_request(path=SeerEndpoint.SENTRY_REQUEST.value, payload=event_payload)
42+
make_seer_request(path=SeerEndpoint.OVERWATCH_REQUEST.value, payload=event_payload)
43+
44+
45+
def schedule_task(
46+
github_event: GithubWebhookType,
47+
event: Mapping[str, Any],
48+
organization: Organization,
49+
repo: Repository,
50+
) -> None:
51+
"""Transform and forward a webhook event to Seer for processing."""
52+
from .task import process_github_webhook_event
53+
54+
transformed_event = transform_webhook_to_codegen_request(
55+
event_payload=dict(event), organization_id=organization.id, repo=repo
56+
)
57+
58+
if transformed_event is None:
59+
metrics.incr(
60+
f"{METRICS_PREFIX}.{github_event.value}.skipped",
61+
tags={"reason": "failed_to_transform", "github_event": github_event.value},
62+
)
63+
return
64+
65+
process_github_webhook_event.delay(
66+
github_event=github_event,
67+
event_payload=transformed_event,
68+
enqueued_at_str=datetime.now(timezone.utc).isoformat(),
69+
)
70+
metrics.incr(
71+
f"{METRICS_PREFIX}.{github_event.value}.enqueued",
72+
tags={"status": "success", "github_event": github_event.value},
73+
)
3974

4075

4176
EVENT_TYPE_TO_PROCESSOR = {GithubWebhookType.CHECK_RUN: process_check_run_task_event}

0 commit comments

Comments
 (0)