Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/sentry/audit_log/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions src/sentry/audit_log/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
10 changes: 5 additions & 5 deletions src/sentry/integrations/github/tasks/link_all_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
51 changes: 42 additions & 9 deletions src/sentry/integrations/github/tasks/sync_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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(
Expand All @@ -194,13 +209,31 @@ 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:
repo.status = ObjectStatus.ACTIVE
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
23 changes: 23 additions & 0 deletions src/sentry/integrations/source_code_management/repo_audit.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
14 changes: 11 additions & 3 deletions src/sentry/plugins/providers/integration_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 26 additions & 1 deletion tests/sentry/integrations/github/tasks/test_sync_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
Loading
Loading