diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 0bf28bcf3fc8de..a2d8e88352a851 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -136,6 +136,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Project Management Integrations Feature Parity Flags diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 78aa2a539075db..17610a849ad399 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -47,7 +48,7 @@ def get_webhook_secret(self, organization): return secret def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 6b3bab8c6c463d..528e2bd6bd9466 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index b901fc89a839fd..766c1e03a8a4d7 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -52,7 +52,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["identifier"], diff --git a/src/sentry/integrations/github/tasks/__init__.py b/src/sentry/integrations/github/tasks/__init__.py index a635eebb4b9af1..cc31059167a9fb 100644 --- a/src/sentry/integrations/github/tasks/__init__.py +++ b/src/sentry/integrations/github/tasks/__init__.py @@ -2,10 +2,12 @@ from .codecov_account_unlink import codecov_account_unlink from .link_all_repos import link_all_repos from .pr_comment import github_comment_workflow +from .sync_repos_on_install_change import sync_repos_on_install_change __all__ = ( "codecov_account_link", "codecov_account_unlink", "github_comment_workflow", "link_all_repos", + "sync_repos_on_install_change", ) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index ade3e8ef83a7e0..046c0fe466236f 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from typing import Any from taskbroker_client.retry import Retry @@ -13,6 +14,7 @@ from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( RepoExistsError, + RepositoryInputConfig, get_integration_repository_provider, ) from sentry.shared_integrations.exceptions import ApiError @@ -23,9 +25,9 @@ logger = logging.getLogger(__name__) -def get_repo_config(repo, integration_id): +def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig: return { - "external_id": repo["id"], + "external_id": str(repo["id"]), "integration_id": integration_id, "identifier": repo["full_name"], } @@ -77,7 +79,7 @@ def link_all_repos( integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[dict[str, Any]] = [] + repo_configs: list[RepositoryInputConfig] = [] missing_repos = [] for repo in repositories: try: diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py new file mode 100644 index 00000000000000..c3ab3b70155163 --- /dev/null +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -0,0 +1,136 @@ +import logging +from typing import Literal + +from taskbroker_client.retry import Retry + +from sentry import features +from sentry.constants import ObjectStatus +from sentry.integrations.github.webhook_types import GitHubInstallationRepo +from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.services.repository.service import repository_service +from sentry.integrations.source_code_management.metrics import ( + SCMIntegrationInteractionEvent, + SCMIntegrationInteractionType, +) +from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.plugins.providers.integration_repository import ( + RepoExistsError, + RepositoryInputConfig, + get_integration_repository_provider, +) +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task, retry +from sentry.taskworker.namespaces import integrations_control_tasks + +from .link_all_repos import get_repo_config + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.integrations.github.tasks.sync_repos_on_install_change", + namespace=integrations_control_tasks, + retry=Retry(times=3, delay=120), + processing_deadline_duration=120, + silo_mode=SiloMode.CONTROL, +) +@retry(exclude=(RepoExistsError, KeyError)) +def sync_repos_on_install_change( + integration_id: int, + action: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], + repository_selection: Literal["all", "selected"], +) -> None: + """ + Handle GitHub installation_repositories webhook events. + + Creates Repository records for newly accessible repos and disables + records for repos that are no longer accessible, across all orgs + linked to the integration. + """ + result = integration_service.organization_contexts(integration_id=integration_id) + integration = result.integration + org_integrations = result.organization_integrations + + if integration is None or integration.status != ObjectStatus.ACTIVE: + logger.info( + "sync_repos_on_install_change.missing_or_inactive_integration", + extra={"integration_id": integration_id}, + ) + return + + if not org_integrations: + logger.info( + "sync_repos_on_install_change.no_org_integrations", + extra={"integration_id": integration_id}, + ) + return + + provider = f"integrations:{integration.provider}" + + for oi in org_integrations: + organization_id = oi.organization_id + rpc_org = organization_service.get(id=organization_id) + + if rpc_org is None: + logger.info( + "sync_repos_on_install_change.missing_organization", + extra={"organization_id": organization_id}, + ) + continue + + if not features.has("organizations:github-repo-auto-sync", rpc_org): + continue + + with SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, + integration_id=integration_id, + organization_id=organization_id, + provider_key=integration.provider, + ).capture(): + _sync_repos_for_org( + integration=integration, + rpc_org=rpc_org, + provider=provider, + repos_added=repos_added, + repos_removed=repos_removed, + ) + + +def _sync_repos_for_org( + *, + integration: RpcIntegration, + rpc_org: RpcOrganization, + provider: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], +) -> None: + if repos_added: + integration_repo_provider = get_integration_repository_provider(integration) + repo_configs: list[RepositoryInputConfig] = [] + for repo in repos_added: + try: + repo_configs.append(get_repo_config(repo, integration.id)) + except KeyError: + logger.exception("Failed to translate repository config") + continue + + if repo_configs: + try: + integration_repo_provider.create_repositories( + configs=repo_configs, organization=rpc_org + ) + except RepoExistsError: + pass + + if repos_removed: + external_ids = [str(repo["id"]) for repo in repos_removed] + repository_service.disable_repositories_by_external_ids( + organization_id=rpc_org.id, + integration_id=integration.id, + provider=provider, + external_ids=external_ids, + ) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index b5b86fea0c0a87..28e87abd1ef559 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -30,6 +30,7 @@ from sentry.integrations.github.webhook_types import ( GITHUB_WEBHOOK_TYPE_HEADER_KEY, GithubWebhookType, + InstallationRepositoriesEvent, ) from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import ( @@ -418,6 +419,57 @@ def _handle_organization_deletion( ) +class InstallationRepositoriesEventWebhook(GitHubWebhook): + """ + Handles installation_repositories events when repos are added to or + removed from the GitHub App installation. Runs in control silo. + + https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories + """ + + EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES + + def __call__( # type: ignore[override] + self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any + ) -> None: + external_id = get_github_external_id(event=event, host=host) + if external_id is None: + return + + result = integration_service.organization_contexts( + provider=self.provider, + external_id=external_id, + ) + integration = result.integration + + if integration is None: + logger.warning( + "github.installation_repositories.missing_integration", + extra={"external_id": str(external_id)}, + ) + return + + action = event["action"] + repos_added = event["repositories_added"] + repos_removed = event["repositories_removed"] + repository_selection = event["repository_selection"] + + if not repos_added and not repos_removed: + return + + from .tasks.sync_repos_on_install_change import sync_repos_on_install_change + + sync_repos_on_install_change.apply_async( + kwargs={ + "integration_id": integration.id, + "action": action, + "repos_added": repos_added, + "repos_removed": repos_removed, + "repository_selection": repository_selection, + } + ) + + class PushEventWebhook(GitHubWebhook): """https://developer.github.com/v3/activity/events/types/#pushevent""" @@ -958,6 +1010,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): _handlers: dict[GithubWebhookType, type[GitHubWebhook]] = { GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, + GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook, GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 0ad1061471a00c..eaad179b8ae10e 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Any, Literal, TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -22,7 +23,25 @@ class GithubWebhookType(StrEnum): # Event type strings (X-GitHub-Event header values) that the cell webhook endpoint processes. -# INSTALLATION is handled in control only. +# INSTALLATION and INSTALLATION_REPOSITORIES are handled in control only. +_CONTROL_ONLY_EVENTS = frozenset( + {GithubWebhookType.INSTALLATION, GithubWebhookType.INSTALLATION_REPOSITORIES} +) CELL_PROCESSED_GITHUB_EVENTS = frozenset( - t.value for t in GithubWebhookType if t != GithubWebhookType.INSTALLATION + t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS ) + + +class GitHubInstallationRepo(TypedDict): + id: int + full_name: str + private: bool + + +class InstallationRepositoriesEvent(TypedDict): + action: Literal["added", "removed"] + installation: dict[str, Any] + repositories_added: list[GitHubInstallationRepo] + repositories_removed: list[GitHubInstallationRepo] + repository_selection: Literal["all", "selected"] + sender: dict[str, Any] diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 5f256206ffd418..2835befdf3918a 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.github.repository import GitHubRepositoryProvider @@ -29,7 +30,7 @@ def _validate_repo(self, client, installation, repo): return repo_data def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: integration = integration_service.get_integration( integration_id=data["integration_id"], provider=self.repo_provider diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index 1b889c641c5c71..d2285d73b195f0 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 0adea7741301be..52d84dd91c13fa 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -102,7 +102,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Build repository configuration for database storage. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index 39238a71778c89..d1cf84cdcb4456 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -134,6 +134,23 @@ def disable_repositories_for_integration( provider=provider, ).update(status=ObjectStatus.DISABLED) + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + with transaction.atomic(router.db_for_write(Repository)): + Repository.objects.filter( + organization_id=organization_id, + integration_id=integration_id, + provider=provider, + external_id__in=external_ids, + status=ObjectStatus.ACTIVE, + ).update(status=ObjectStatus.DISABLED) + def disassociate_organization_integration( self, *, diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index a10d8c42852af3..51cb81c98ba835 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -85,6 +85,21 @@ def disable_repositories_for_integration( Code owners and code mappings will not be changed. """ + @cell_rpc_method(resolve=ByOrganizationId()) + @abstractmethod + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + """ + Disables specific repositories by external_id for a given integration. + Only active repositories are affected. Code mappings and commits are preserved. + """ + @cell_rpc_method(resolve=ByOrganizationId()) @abstractmethod def disassociate_organization_integration( diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 6cc035d5bcab32..a6612f5680922b 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -41,6 +41,7 @@ class SCMIntegrationInteractionType(StrEnum): # Tasks LINK_ALL_REPOS = "link_all_repos" + SYNC_REPOS_ON_INSTALL_CHANGE = "sync_repos_on_install_change" # GitHub only DERIVE_CODEMAPPINGS = "derive_codemappings" diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index 6d0f8ea33ea22a..a341f8c31833ba 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -448,6 +448,7 @@ class IntegrationWebhookEventType(StrEnum): # This represents a webhook event for an inbound sync operation, such as syncing external resources or data into Sentry. INBOUND_SYNC = "inbound_sync" INSTALLATION = "installation" + INSTALLATION_REPOSITORIES = "installation_repositories" ISSUE_COMMENT = "issue_comment" MERGE_REQUEST = "pull_request" MERGE_REQUEST_REVIEW = "pull_request_review" diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index f9a9b74007acfa..ac015771960172 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -47,7 +47,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["name"], diff --git a/src/sentry/middleware/integrations/parsers/github.py b/src/sentry/middleware/integrations/parsers/github.py index c6ec3f3c5bb191..f7c59195346749 100644 --- a/src/sentry/middleware/integrations/parsers/github.py +++ b/src/sentry/middleware/integrations/parsers/github.py @@ -77,7 +77,10 @@ def get_mailbox_identifier( def should_route_to_control_silo( self, parsed_event: Mapping[str, Any], request: HttpRequest ) -> bool: - return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) in ( + GithubWebhookType.INSTALLATION, + GithubWebhookType.INSTALLATION_REPOSITORIES, + ) @control_silo_function def get_integration_from_request(self) -> Integration | None: diff --git a/src/sentry/middleware/integrations/parsers/github_enterprise.py b/src/sentry/middleware/integrations/parsers/github_enterprise.py index 3f7cbdce60d00a..02edd104dfdb47 100644 --- a/src/sentry/middleware/integrations/parsers/github_enterprise.py +++ b/src/sentry/middleware/integrations/parsers/github_enterprise.py @@ -4,8 +4,11 @@ from collections.abc import Mapping from typing import Any +from django.http import HttpRequest + from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.github.webhook import get_github_external_id +from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType from sentry.integrations.github_enterprise.webhook import GitHubEnterpriseWebhookEndpoint, get_host from sentry.integrations.types import IntegrationProviderSlug from sentry.middleware.integrations.parsers.github import GithubRequestParser @@ -18,6 +21,13 @@ class GithubEnterpriseRequestParser(GithubRequestParser): webhook_identifier = WebhookProviderIdentifier.GITHUB_ENTERPRISE webhook_endpoint = GitHubEnterpriseWebhookEndpoint + def should_route_to_control_silo( + self, parsed_event: Mapping[str, Any], request: HttpRequest + ) -> bool: + # GHE only routes installation events to control silo. + # installation_repositories is not yet supported for GHE. + return request.META.get(GITHUB_WEBHOOK_TYPE_HEADER) == GithubWebhookType.INSTALLATION + def _get_external_id(self, event: Mapping[str, Any]) -> str | None: host = get_host(request=self.request) if not host: diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 9be762ce766856..e238eccd3124cc 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar, NotRequired, TypedDict from dateutil.parser import parse as parse_date from rest_framework import status @@ -27,6 +28,16 @@ from sentry.utils import metrics +class RepositoryInputConfig(TypedDict): + """Input config passed to create_repositories / build_repository_config. + Providers may include additional keys beyond these.""" + + external_id: str + integration_id: int + identifier: str + installation: NotRequired[str] + + class RepositoryConfig(TypedDict): name: str external_id: str @@ -107,7 +118,7 @@ def get_installation( def create_repository( self, - repo_config: dict[str, Any], + repo_config: Mapping[str, Any], organization: RpcOrganization, ): result = self.build_repository_config(organization=organization, data=repo_config) @@ -227,7 +238,7 @@ def _update_repositories( def create_repositories( self, - configs: list[dict[str, Any]], + configs: list[RepositoryInputConfig], organization: RpcOrganization, ): external_id_to_repo_config: dict[str, RepositoryConfig] = {} @@ -354,7 +365,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Builds final dict containing all necessary data to create the repository diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py new file mode 100644 index 00000000000000..9f63922c72d299 --- /dev/null +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -0,0 +1,205 @@ +from unittest.mock import MagicMock, patch + +from sentry.constants import ObjectStatus +from sentry.integrations.github.integration import GitHubIntegrationProvider +from sentry.integrations.github.tasks.sync_repos_on_install_change import ( + sync_repos_on_install_change, +) +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode +from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test + +FEATURE_FLAG = "organizations:github-repo-auto-sync" + + +@control_silo_test +@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") +class SyncReposOnInstallChangeTestCase(IntegrationTestCase): + provider = GitHubIntegrationProvider + base_url = "https://api.github.com" + key = "github" + + def _make_repos_added(self): + return [ + {"id": 1, "full_name": "getsentry/sentry", "private": False}, + {"id": 2, "full_name": "getsentry/snuba", "private": False}, + ] + + def _make_repos_removed(self): + return [ + {"id": 3, "full_name": "getsentry/old-repo", "private": False}, + ] + + def test_repos_added(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[0].integration_id == self.integration.id + assert repos[1].name == "getsentry/snuba" + + def test_repos_removed(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_mixed_add_and_remove(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + old_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + old_repo.refresh_from_db() + assert old_repo.status == ObjectStatus.DISABLED + + active_repos = Repository.objects.filter( + organization_id=self.organization.id, + status=ObjectStatus.ACTIVE, + ).order_by("name") + assert len(active_repos) == 2 + assert active_repos[0].name == "getsentry/sentry" + assert active_repos[1].name == "getsentry/snuba" + + def test_multi_org(self, _: MagicMock) -> None: + other_org = self.create_organization(owner=self.user) + self.create_organization_integration( + organization_id=other_org.id, + integration=self.integration, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos_org1 = Repository.objects.filter(organization_id=self.organization.id) + repos_org2 = Repository.objects.filter(organization_id=other_org.id) + + assert len(repos_org1) == 2 + assert len(repos_org2) == 2 + + def test_missing_integration(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=0, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_inactive_integration(self, _: MagicMock) -> None: + self.integration.update(status=ObjectStatus.DISABLED) + + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_feature_flag_off(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_empty_repos_is_noop(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=[], + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED diff --git a/tests/sentry/integrations/github/test_webhooks.py b/tests/sentry/integrations/github/test_webhook.py similarity index 82% rename from tests/sentry/integrations/github/test_webhooks.py rename to tests/sentry/integrations/github/test_webhook.py index bd3b637b473acd..843b76e2e4dd34 100644 --- a/tests/sentry/integrations/github/test_webhooks.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,11 @@ ) from sentry import options from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook import GitHubIntegrationsWebhookEndpoint +from sentry.integrations.github.webhook import ( + GitHubIntegrationsWebhookEndpoint, + InstallationRepositoriesEventWebhook, +) +from sentry.integrations.github.webhook_types import InstallationRepositoriesEvent from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import integration_service @@ -363,6 +368,297 @@ def test_installation_deleted_skips_codecov_unlink_when_app_ids_dont_match( mock_codecov_unlink.assert_not_called() +@control_silo_test +class InstallationRepositoriesEventWebhookTest(APITestCase): + def setUp(self) -> None: + self.url = "/extensions/github/webhook/" + self.secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.secret) + + def _make_event(self, action="added", repos_added=None, repos_removed=None): + return json.dumps( + { + "action": action, + "installation": {"id": 2}, + "repositories_added": repos_added or [], + "repositories_removed": repos_removed or [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + def _compute_signatures(self, body: str) -> tuple[str, str]: + sha1 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha1", body.encode(), self.secret + ) + sha256 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha256", body.encode(), self.secret + ) + return f"sha1={sha1}", f"sha256={sha256}" + + @patch("sentry.integrations.github.webhook.InstallationRepositoriesEventWebhook.__call__") + def test_webhook_dispatches_to_handler(self, mock_call: MagicMock) -> None: + """Verify the endpoint routes installation_repositories events to the correct handler.""" + body = self._make_event( + repos_added=[{"id": 1, "full_name": "getsentry/sentry", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + assert mock_call.called + + def test_end_to_end_repos_added(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository rows created.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + body = self._make_event( + repos_added=[ + {"id": 10, "full_name": "getsentry/sentry", "private": False}, + {"id": 20, "full_name": "getsentry/snuba", "private": False}, + ], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[1].name == "getsentry/snuba" + + def test_end_to_end_repos_removed(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository disabled.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="30", + provider="integrations:github", + integration_id=integration.id, + status=ObjectStatus.ACTIVE, + ) + + body = self._make_event( + action="removed", + repos_removed=[{"id": 30, "full_name": "getsentry/old-repo", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_added(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_added dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [ + {"id": 10, "full_name": "getsentry/sentry", "private": False} + ], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == integration.id + assert kwargs["action"] == "added" + assert len(kwargs["repos_added"]) == 1 + assert kwargs["repos_added"][0]["id"] == 10 + assert kwargs["repos_removed"] == [] + assert kwargs["repository_selection"] == "selected" + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_removed(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_removed dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "removed", + "repositories_added": [], + "repositories_removed": [ + {"id": 20, "full_name": "getsentry/old-repo", "private": False} + ], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["action"] == "removed" + assert len(kwargs["repos_removed"]) == 1 + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_no_repos(self, mock_apply_async: MagicMock) -> None: + """No repos added or removed — task should not be dispatched.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_malformed_event(self, mock_apply_async: MagicMock) -> None: + """Malformed event missing required keys — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + malformed_event = cast( + InstallationRepositoriesEvent, + {"repositories_added": [{"id": 1}], "repositories_removed": []}, + ) + handler(event=malformed_event) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_integration_not_found(self, mock_apply_async: MagicMock) -> None: + """Integration doesn't exist in Sentry — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 99999}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_propagates_host_for_ghe(self, mock_apply_async: MagicMock) -> None: + """GitHub Enterprise uses host prefix for external_id.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="github.mycompany.com:2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + }, + host="github.mycompany.com", + ) + + mock_apply_async.assert_called_once() + + class PushEventWebhookTest(APITestCase): def setUp(self) -> None: self.url = "/extensions/github/webhook/" diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py new file mode 100644 index 00000000000000..a92df36cc47066 --- /dev/null +++ b/tests/sentry/integrations/services/repository/test_impl.py @@ -0,0 +1,162 @@ +from sentry.constants import ObjectStatus +from sentry.integrations.services.repository.service import repository_service +from sentry.models.repository import Repository +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import cell_silo_test + + +@cell_silo_test +class DisableRepositoriesByExternalIdsTest(TestCase): + def setUp(self) -> None: + self.integration = self.create_integration( + organization=self.organization, + external_id="1", + provider="github", + ) + self.provider = "integrations:github" + + def test_disables_matching_active_repos(self) -> None: + repo1 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo2 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100", "200"], + ) + + repo1.refresh_from_db() + repo2.refresh_from_db() + assert repo1.status == ObjectStatus.DISABLED + assert repo2.status == ObjectStatus.DISABLED + + def test_does_not_disable_already_disabled_repos(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_does_not_affect_repos_from_other_integrations(self) -> None: + other_integration = self.create_integration( + organization=self.organization, + external_id="2", + provider="github", + ) + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=other_integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_does_not_affect_repos_from_other_orgs(self) -> None: + other_org = self.create_organization() + repo = Repository.objects.create( + organization_id=other_org.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_only_disables_specified_external_ids(self) -> None: + repo_to_disable = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo_to_keep = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo_to_disable.refresh_from_db() + repo_to_keep.refresh_from_db() + assert repo_to_disable.status == ObjectStatus.DISABLED + assert repo_to_keep.status == ObjectStatus.ACTIVE + + def test_empty_external_ids_is_noop(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=[], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index a86de294b8f96e..9a1e1836d04076 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -139,6 +139,52 @@ def test_get_integration_from_request(self) -> None: result = parser.get_integration_from_request() assert result == integration + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_repositories_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={ + "installation": {"id": "1"}, + "repositories_added": [], + "repositories_removed": [], + }, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION_REPOSITORIES.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_push_does_not_route_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.PUSH.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert not parser.should_route_to_control_silo(parsed_event={}, request=request) + @override_settings(SILO_MODE=SiloMode.CONTROL) @override_cells(cell_config) def test_webhook_outbox_creation(self) -> None: