Skip to content

Commit f9145c6

Browse files
grichaclaude
andauthored
feat(integrations): Set ViewerContext on webhook handlers (#112284)
Add `webhook_viewer_context(org_id)` helper that wraps webhook per-org processing in `viewer_context_scope` with `actor_type=INTEGRATION`. Gated behind the `viewer-context.enabled` option so it rolls out with the rest of the ViewerContext infrastructure. Wired into 5 webhook handlers at the point where `organization_id` is known: - **GitHub** — inside per-repo loop in `GitHubWebhook.__call__` - **GitLab** — inside per-install loop in `GitlabWebhookEndpoint.post` - **Bitbucket** — wrapping handler call (org from URL param) - **Bitbucket Server** — wrapping handler call (org from URL param) - **Cursor** — wrapping `_process_webhook` (org from URL param) Remaining integrations (Slack, Jira Server, MS Teams, Vercel) resolve the organization deeper in the stack and need separate work — they're tracked but not in scope here. Part of the [ViewerContext RFC](https://www.notion.so/sentry/RFC-Unified-ViewerContext-via-ContextVar-32f8b10e4b5d81988625cb5787035e02). --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f2614c commit f9145c6

8 files changed

Lines changed: 118 additions & 20 deletions

File tree

src/sentry/integrations/bitbucket/webhook.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sentry.integrations.source_code_management.webhook import SCMWebhook
2525
from sentry.integrations.types import IntegrationProviderSlug
2626
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
27+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
2728
from sentry.models.commit import Commit
2829
from sentry.models.commitauthor import CommitAuthor
2930
from sentry.models.organization import Organization
@@ -258,11 +259,14 @@ def post(self, request: HttpRequest, organization_id: int) -> HttpResponse:
258259

259260
event_handler = handler()
260261

261-
with IntegrationWebhookEvent(
262-
interaction_type=event_handler.event_type,
263-
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
264-
provider_key=event_handler.provider,
265-
).capture():
262+
with (
263+
webhook_viewer_context(organization.id),
264+
IntegrationWebhookEvent(
265+
interaction_type=event_handler.event_type,
266+
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
267+
provider_key=event_handler.provider,
268+
).capture(),
269+
):
266270
event_handler(event, repo=repo, organization=organization)
267271

268272
return HttpResponse(status=204)

src/sentry/integrations/bitbucket_server/webhook.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from sentry.integrations.source_code_management.webhook import SCMWebhook
2222
from sentry.integrations.types import IntegrationProviderSlug
2323
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
24+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
2425
from sentry.models.commit import Commit
2526
from sentry.models.commitauthor import CommitAuthor
2627
from sentry.models.organization import Organization
@@ -210,6 +211,7 @@ def post(self, request: HttpRequest, organization_id, integration_id) -> HttpRes
210211

211212
event_handler = handler()
212213

213-
event_handler(event, organization=organization, integration_id=integration_id)
214+
with webhook_viewer_context(organization.id):
215+
event_handler(event, organization=organization, integration_id=integration_id)
214216

215217
return HttpResponse(status=204)

src/sentry/integrations/cursor/webhooks/handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from sentry.api.base import Endpoint, cell_silo_endpoint
2222
from sentry.integrations.cursor.integration import CursorAgentIntegration
2323
from sentry.integrations.services.integration import integration_service
24+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
2425
from sentry.seer.autofix.utils import (
2526
CodingAgentResult,
2627
CodingAgentStatus,
@@ -58,7 +59,8 @@ def post(self, request: Request, organization_id: int) -> Response:
5859
logger.warning("cursor_webhook.invalid_signature")
5960
raise PermissionDenied("Invalid signature")
6061

61-
self._process_webhook(payload)
62+
with webhook_viewer_context(organization_id):
63+
self._process_webhook(payload)
6264
logger.info("cursor_webhook.success", extra={"event_type": event_type})
6365
return self.respond(status=204)
6466

src/sentry/integrations/github/webhook.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
4545
from sentry.integrations.utils.scope import clear_tags_and_context
4646
from sentry.integrations.utils.sync import sync_group_assignee_inbound_by_external_actor
47+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
4748
from sentry.models.commit import Commit
4849
from sentry.models.commitauthor import CommitAuthor
4950
from sentry.models.commitfilechange import CommitFileChange, post_bulk_create
@@ -263,14 +264,15 @@ def __call__(self, event: Mapping[str, Any], **kwargs: Any) -> None:
263264

264265
for repo in repos.exclude(status=ObjectStatus.HIDDEN):
265266
self.update_repo_data(repo, event)
266-
self._handle(
267-
github_event=github_event,
268-
integration=integration,
269-
event=event,
270-
organization=orgs[repo.organization_id],
271-
repo=repo,
272-
github_delivery_id=github_delivery_id,
273-
)
267+
with webhook_viewer_context(repo.organization_id):
268+
self._handle(
269+
github_event=github_event,
270+
integration=integration,
271+
event=event,
272+
organization=orgs[repo.organization_id],
273+
repo=repo,
274+
github_delivery_id=github_delivery_id,
275+
)
274276

275277
def update_repo_data(self, repo: Repository, event: Mapping[str, Any]) -> None:
276278
"""

src/sentry/integrations/gitlab/webhooks.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
2929
from sentry.integrations.utils.scope import clear_tags_and_context
3030
from sentry.integrations.utils.sync import sync_group_assignee_inbound_by_external_actor
31+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
3132
from sentry.models.commit import Commit
3233
from sentry.models.commitauthor import CommitAuthor
3334
from sentry.models.pullrequest import PullRequest
@@ -516,11 +517,14 @@ def post(self, request: HttpRequest) -> HttpResponse:
516517
organization = org_context.organization
517518
event_handler = handler()
518519

519-
with IntegrationWebhookEvent(
520-
interaction_type=event_handler.event_type,
521-
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
522-
provider_key=event_handler.provider,
523-
).capture():
520+
with (
521+
webhook_viewer_context(install.organization_id),
522+
IntegrationWebhookEvent(
523+
interaction_type=event_handler.event_type,
524+
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
525+
provider_key=event_handler.provider,
526+
).capture(),
527+
):
524528
event_handler(event, integration=integration, organization=organization)
525529

526530
return HttpResponse(status=204)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
from collections.abc import Generator
5+
6+
from sentry import options
7+
from sentry.viewer_context import ActorType, ViewerContext, viewer_context_scope
8+
9+
10+
@contextlib.contextmanager
11+
def webhook_viewer_context(organization_id: int) -> Generator[None]:
12+
"""Set ViewerContext for a webhook handler processing an org-scoped event.
13+
14+
Gated by the ``viewer-context.enabled`` option so it rolls out alongside
15+
the rest of the ViewerContext infrastructure.
16+
"""
17+
if not options.get("viewer-context.enabled"):
18+
yield
19+
return
20+
21+
with viewer_context_scope(
22+
ViewerContext(
23+
organization_id=organization_id,
24+
actor_type=ActorType.INTEGRATION,
25+
)
26+
):
27+
yield

tests/sentry/integrations/github/test_webhook.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,40 @@ def test_simple(self, mock_record: MagicMock) -> None:
773773

774774
assert_success_metric(mock_record)
775775

776+
@responses.activate
777+
@override_options({"viewer-context.enabled": True})
778+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
779+
def test_viewer_context_set_during_handler(self, mock_record: MagicMock) -> None:
780+
"""ViewerContext is set with org_id and actor_type=INTEGRATION during webhook processing."""
781+
from sentry.viewer_context import ActorType, get_viewer_context
782+
783+
Repository.objects.create(
784+
organization_id=self.project.organization.id,
785+
external_id="35129377",
786+
provider="integrations:github",
787+
name="baxterthehacker/repo",
788+
)
789+
790+
captured_contexts: list = []
791+
792+
from sentry.integrations.github.webhook import PushEventWebhook
793+
794+
original_handle = PushEventWebhook._handle
795+
796+
def capturing_handle(self_handler, **kwargs):
797+
captured_contexts.append(get_viewer_context())
798+
return original_handle(self_handler, **kwargs)
799+
800+
with patch.object(PushEventWebhook, "_handle", capturing_handle):
801+
self._create_integration_and_send_push_event()
802+
803+
assert len(captured_contexts) == 1
804+
ctx = captured_contexts[0]
805+
assert ctx is not None
806+
assert ctx.organization_id == self.project.organization.id
807+
assert ctx.actor_type == ActorType.INTEGRATION
808+
assert ctx.user_id is None
809+
776810
@responses.activate
777811
@patch("sentry.integrations.github.webhook.metrics")
778812
def test_creates_missing_repo(self, mock_metrics: MagicMock) -> None:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context
2+
from sentry.testutils.cases import TestCase
3+
from sentry.testutils.helpers.options import override_options
4+
from sentry.viewer_context import ActorType, get_viewer_context
5+
6+
7+
class WebhookViewerContextTest(TestCase):
8+
@override_options({"viewer-context.enabled": True})
9+
def test_sets_viewer_context_when_enabled(self):
10+
with webhook_viewer_context(42):
11+
ctx = get_viewer_context()
12+
assert ctx is not None
13+
assert ctx.organization_id == 42
14+
assert ctx.actor_type == ActorType.INTEGRATION
15+
assert ctx.user_id is None
16+
assert ctx.token is None
17+
18+
assert get_viewer_context() is None
19+
20+
@override_options({"viewer-context.enabled": False})
21+
def test_noop_when_disabled(self):
22+
with webhook_viewer_context(42):
23+
assert get_viewer_context() is None

0 commit comments

Comments
 (0)