diff --git a/src/sentry/audit_log/events.py b/src/sentry/audit_log/events.py index cbfd66a14e44da..27352906382b8f 100644 --- a/src/sentry/audit_log/events.py +++ b/src/sentry/audit_log/events.py @@ -384,3 +384,38 @@ def render(self, audit_log_entry: AuditLogEntry) -> str: return "updated repository settings for {repository_count} repositories".format( repository_count=data.get("repository_count", 0), ) + + +def _render_repo_event(action: str, audit_log_entry: AuditLogEntry) -> str: + data = audit_log_entry.data + actor = audit_log_entry.actor_label or "unknown" + repo_name = data.get("repo_name", "unknown") + source = data.get("source", "") + msg = f"{actor} {action} repository {repo_name}" + if source: + msg += f" (via {source})" + return msg + + +class RepoAddedAuditLogEvent(AuditLogEvent): + def __init__(self) -> None: + super().__init__(event_id=1170, name="REPO_ADDED", api_name="repo.added") + + def render(self, audit_log_entry: AuditLogEntry) -> str: + return _render_repo_event("added", audit_log_entry) + + +class RepoDisabledAuditLogEvent(AuditLogEvent): + def __init__(self) -> None: + super().__init__(event_id=1171, name="REPO_DISABLED", api_name="repo.disabled") + + def render(self, audit_log_entry: AuditLogEntry) -> str: + return _render_repo_event("disabled", audit_log_entry) + + +class RepoEnabledAuditLogEvent(AuditLogEvent): + def __init__(self) -> None: + super().__init__(event_id=1172, name="REPO_ENABLED", api_name="repo.enabled") + + def render(self, audit_log_entry: AuditLogEntry) -> str: + return _render_repo_event("enabled", audit_log_entry) diff --git a/src/sentry/audit_log/register.py b/src/sentry/audit_log/register.py index 0cbb57d9408502..b47bf850283fe0 100644 --- a/src/sentry/audit_log/register.py +++ b/src/sentry/audit_log/register.py @@ -695,3 +695,6 @@ template="updated autofix automation settings for {project_count} projects", ) ) +default_manager.add(events.RepoAddedAuditLogEvent()) +default_manager.add(events.RepoDisabledAuditLogEvent()) +default_manager.add(events.RepoEnabledAuditLogEvent()) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index 046c0fe466236f..2eae7324bac0f4 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -13,7 +13,6 @@ ) from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( - RepoExistsError, RepositoryInputConfig, get_integration_repository_provider, ) @@ -40,7 +39,7 @@ def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryI processing_deadline_duration=60, silo_mode=SiloMode.CONTROL, ) -@retry(exclude=(RepoExistsError, KeyError)) +@retry(exclude=(KeyError,)) def link_all_repos( integration_key: str, integration_id: int, @@ -88,14 +87,15 @@ def link_all_repos( missing_repos.append(repo) continue - try: + _created_repos, _reactivated_repos, existing_repos = ( integration_repo_provider.create_repositories( configs=repo_configs, organization=rpc_org ) - except RepoExistsError as e: + ) + if existing_repos: lifecycle.record_halt( str(LinkAllReposHaltReason.REPOSITORY_NOT_CREATED), - {"missing_repos": e.repos, "integration_id": integration_id}, + {"missing_repos": existing_repos, "integration_id": integration_id}, ) if missing_repos: diff --git a/src/sentry/integrations/github/tasks/sync_repos.py b/src/sentry/integrations/github/tasks/sync_repos.py index ebfd8a6960c1b2..06740e1c7a497d 100644 --- a/src/sentry/integrations/github/tasks/sync_repos.py +++ b/src/sentry/integrations/github/tasks/sync_repos.py @@ -21,11 +21,9 @@ SCMIntegrationInteractionEvent, SCMIntegrationInteractionType, ) +from sentry.integrations.source_code_management.repo_audit import log_repo_change from sentry.organizations.services.organization import organization_service -from sentry.plugins.providers.integration_repository import ( - RepoExistsError, - get_integration_repository_provider, -) +from sentry.plugins.providers.integration_repository import get_integration_repository_provider from sentry.shared_integrations.exceptions import ApiError from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task, retry @@ -171,6 +169,8 @@ def sync_repos_for_org(organization_integration_id: int) -> None: if dry_run: return + repo_by_external_id = {r.external_id: r for r in active_repos + disabled_repos} + if new_ids: integration_repo_provider = get_integration_repository_provider(integration) repo_configs = [ @@ -179,12 +179,27 @@ def sync_repos_for_org(organization_integration_id: int) -> None: if str(repo["id"]) in new_ids ] if repo_configs: - try: - integration_repo_provider.create_repositories( - configs=repo_configs, organization=rpc_org + created_repos, reactivated_repos, _ = integration_repo_provider.create_repositories( + configs=repo_configs, organization=rpc_org + ) + + for repo in created_repos: + log_repo_change( + event_name="REPO_ADDED", + organization_id=organization_id, + repo=repo, + source="automatic SCM syncing", + provider=integration.provider, + ) + + for repo in reactivated_repos: + log_repo_change( + event_name="REPO_ENABLED", + organization_id=organization_id, + repo=repo, + source="automatic SCM syncing", + provider=integration.provider, ) - except RepoExistsError: - pass if removed_ids: repository_service.disable_repositories_by_external_ids( @@ -194,6 +209,17 @@ def sync_repos_for_org(organization_integration_id: int) -> None: external_ids=list(removed_ids), ) + for eid in removed_ids: + removed_repo = repo_by_external_id.get(eid) + if removed_repo: + log_repo_change( + event_name="REPO_DISABLED", + organization_id=organization_id, + repo=removed_repo, + source="automatic SCM syncing", + provider=integration.provider, + ) + if restored_ids: for repo in disabled_repos: if repo.external_id in restored_ids: @@ -201,6 +227,13 @@ def sync_repos_for_org(organization_integration_id: int) -> None: repository_service.update_repository( organization_id=organization_id, update=repo ) + log_repo_change( + event_name="REPO_ENABLED", + organization_id=organization_id, + repo=repo, + source="automatic SCM syncing", + provider=integration.provider, + ) @instrumented_task( 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 index c3ab3b70155163..3b8b65e97d75e0 100644 --- a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -13,10 +13,10 @@ SCMIntegrationInteractionEvent, SCMIntegrationInteractionType, ) +from sentry.integrations.source_code_management.repo_audit import log_repo_change 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, ) @@ -36,7 +36,7 @@ processing_deadline_duration=120, silo_mode=SiloMode.CONTROL, ) -@retry(exclude=(RepoExistsError, KeyError)) +@retry(exclude=(KeyError,)) def sync_repos_on_install_change( integration_id: int, action: str, @@ -119,18 +119,59 @@ def _sync_repos_for_org( continue if repo_configs: - try: + created_repos, reactivated_repos, _missing_repos = ( integration_repo_provider.create_repositories( configs=repo_configs, organization=rpc_org ) - except RepoExistsError: - pass + ) + + for created_repo in created_repos: + log_repo_change( + event_name="REPO_ADDED", + organization_id=rpc_org.id, + repo=created_repo, + source="GitHub webhook", + provider=integration.provider, + ) + + for reactivated_repo in reactivated_repos: + log_repo_change( + event_name="REPO_ENABLED", + organization_id=rpc_org.id, + repo=reactivated_repo, + source="GitHub webhook", + provider=integration.provider, + ) if repos_removed: + # Look up repos before disabling to get their IDs and names external_ids = [str(repo["id"]) for repo in repos_removed] + existing_repos = repository_service.get_repositories( + organization_id=rpc_org.id, + integration_id=integration.id, + providers=[provider], + ) + repo_by_eid = { + r.external_id: r + for r in existing_repos + if r.external_id and r.status == ObjectStatus.ACTIVE + } + repository_service.disable_repositories_by_external_ids( organization_id=rpc_org.id, integration_id=integration.id, provider=provider, external_ids=external_ids, ) + + for repo in repos_removed: + eid = str(repo["id"]) + sentry_repo = repo_by_eid.get(eid) + if sentry_repo: + log_repo_change( + event_name="REPO_DISABLED", + organization_id=rpc_org.id, + repo=sentry_repo, + source="GitHub webhook", + provider=integration.provider, + ) diff --git a/src/sentry/integrations/source_code_management/repo_audit.py b/src/sentry/integrations/source_code_management/repo_audit.py new file mode 100644 index 00000000000000..618fbeacca7db6 --- /dev/null +++ b/src/sentry/integrations/source_code_management/repo_audit.py @@ -0,0 +1,23 @@ +""" +Audit log helpers for repository sync operations. +""" + +from sentry import audit_log +from sentry.integrations.services.repository.model import RpcRepository +from sentry.utils.audit import create_system_audit_entry + + +def log_repo_change( + *, event_name: str, organization_id: int, repo: RpcRepository, source: str, provider: str +) -> None: + create_system_audit_entry( + organization_id=organization_id, + target_object=repo.id, + event=audit_log.get_event_id(event_name), + data={ + "repo_name": repo.name, + "external_id": repo.external_id, + "source": source, + "provider": provider, + }, + ) diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index e238eccd3124cc..2834592c069e25 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -240,13 +240,21 @@ def create_repositories( self, configs: list[RepositoryInputConfig], organization: RpcOrganization, - ): + ) -> tuple[list[RpcRepository], list[RpcRepository], list[RepositoryConfig]]: + """ + Create or update repositories from configs. + Returns (created, reactivated, missing) — newly created repos, repos that + were reactivated or updated from a hidden/unlinked state, and repo configs + that could not be created because a repository with that configuration + already exists. + """ external_id_to_repo_config: dict[str, RepositoryConfig] = {} for config in configs: result = self.build_repository_config(organization=organization, data=config) external_id_to_repo_config[result["external_id"]] = result repos_to_update: list[RpcRepository] = [] + created_repos: list[RpcRepository] = [] hidden_repos = repository_service.get_repositories( organization_id=organization.id, @@ -272,6 +280,7 @@ def create_repositories( organization_id=organization.id, create=create_repository ) if new_repository is not None: + created_repos.append(new_repository) continue missing_repos.append(repo_config) @@ -299,8 +308,7 @@ def create_repositories( updates=repos_to_update, ) - if missing_repos: - raise RepoExistsError(repos=missing_repos) + return created_repos, repos_to_update, missing_repos def dispatch(self, request: Request, organization, **kwargs): try: diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos.py b/tests/sentry/integrations/github/tasks/test_sync_repos.py index c57e4da1189afa..f6f224b0c737be 100644 --- a/tests/sentry/integrations/github/tasks/test_sync_repos.py +++ b/tests/sentry/integrations/github/tasks/test_sync_repos.py @@ -4,14 +4,16 @@ import responses from taskbroker_client.retry import RetryTaskError +from sentry import audit_log from sentry.constants import ObjectStatus from sentry.integrations.github.integration import GitHubIntegrationProvider from sentry.integrations.github.tasks.sync_repos import sync_repos_for_org from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.models.auditlogentry import AuditLogEntry 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 +from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test @control_silo_test @@ -60,6 +62,13 @@ def test_creates_new_repos(self, _: MagicMock) -> None: assert repos[0].provider == "integrations:github" assert repos[1].name == "getsentry/snuba" + with assume_test_silo_mode_of(AuditLogEntry): + entries = AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_ADDED"), + ) + assert entries.count() == 2 + @responses.activate def test_disables_removed_repos(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): @@ -89,6 +98,16 @@ def test_disables_removed_repos(self, _: MagicMock) -> None: organization_id=self.organization.id, external_id="1" ).exists() + with assume_test_silo_mode_of(AuditLogEntry): + assert AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_DISABLED"), + ).exists() + assert AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_ADDED"), + ).exists() + @responses.activate def test_re_enables_restored_repos(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): @@ -113,6 +132,12 @@ def test_re_enables_restored_repos(self, _: MagicMock) -> None: repo.refresh_from_db() assert repo.status == ObjectStatus.ACTIVE + with assume_test_silo_mode_of(AuditLogEntry): + assert AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_ENABLED"), + ).exists() + @responses.activate def test_no_changes_needed(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): 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 index 9f63922c72d299..b70b55f55b6f99 100644 --- 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 @@ -1,14 +1,16 @@ from unittest.mock import MagicMock, patch +from sentry import audit_log 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.auditlogentry import AuditLogEntry 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 +from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test FEATURE_FLAG = "organizations:github-repo-auto-sync" @@ -50,6 +52,13 @@ def test_repos_added(self, _: MagicMock) -> None: assert repos[0].integration_id == self.integration.id assert repos[1].name == "getsentry/snuba" + with assume_test_silo_mode_of(AuditLogEntry): + entries = AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_ADDED"), + ) + assert entries.count() == 2 + def test_repos_removed(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( @@ -74,6 +83,12 @@ def test_repos_removed(self, _: MagicMock) -> None: repo.refresh_from_db() assert repo.status == ObjectStatus.DISABLED + with assume_test_silo_mode_of(AuditLogEntry): + assert AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("REPO_DISABLED"), + ).exists() + def test_mixed_add_and_remove(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): old_repo = Repository.objects.create(