Skip to content

Commit 81d0f5e

Browse files
committed
Reapply "feat(repos): Add audit logs when auto syncing repositories (#112056)"
This reverts commit 2765803.
1 parent 2765803 commit 81d0f5e

File tree

9 files changed

+207
-24
lines changed

9 files changed

+207
-24
lines changed

src/sentry/audit_log/events.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,38 @@ def render(self, audit_log_entry: AuditLogEntry) -> str:
384384
return "updated repository settings for {repository_count} repositories".format(
385385
repository_count=data.get("repository_count", 0),
386386
)
387+
388+
389+
def _render_repo_event(action: str, audit_log_entry: AuditLogEntry) -> str:
390+
data = audit_log_entry.data
391+
actor = audit_log_entry.actor_label or "unknown"
392+
repo_name = data.get("repo_name", "unknown")
393+
source = data.get("source", "")
394+
msg = f"{actor} {action} repository {repo_name}"
395+
if source:
396+
msg += f" (via {source})"
397+
return msg
398+
399+
400+
class RepoAddedAuditLogEvent(AuditLogEvent):
401+
def __init__(self) -> None:
402+
super().__init__(event_id=1161, name="REPO_ADDED", api_name="repo.added")
403+
404+
def render(self, audit_log_entry: AuditLogEntry) -> str:
405+
return _render_repo_event("added", audit_log_entry)
406+
407+
408+
class RepoDisabledAuditLogEvent(AuditLogEvent):
409+
def __init__(self) -> None:
410+
super().__init__(event_id=1162, name="REPO_DISABLED", api_name="repo.disabled")
411+
412+
def render(self, audit_log_entry: AuditLogEntry) -> str:
413+
return _render_repo_event("disabled", audit_log_entry)
414+
415+
416+
class RepoEnabledAuditLogEvent(AuditLogEvent):
417+
def __init__(self) -> None:
418+
super().__init__(event_id=1163, name="REPO_ENABLED", api_name="repo.enabled")
419+
420+
def render(self, audit_log_entry: AuditLogEntry) -> str:
421+
return _render_repo_event("enabled", audit_log_entry)

src/sentry/audit_log/register.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,6 @@
695695
template="updated autofix automation settings for {project_count} projects",
696696
)
697697
)
698+
default_manager.add(events.RepoAddedAuditLogEvent())
699+
default_manager.add(events.RepoDisabledAuditLogEvent())
700+
default_manager.add(events.RepoEnabledAuditLogEvent())

src/sentry/integrations/github/tasks/link_all_repos.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
)
1414
from sentry.organizations.services.organization import organization_service
1515
from sentry.plugins.providers.integration_repository import (
16-
RepoExistsError,
1716
RepositoryInputConfig,
1817
get_integration_repository_provider,
1918
)
@@ -40,7 +39,7 @@ def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryI
4039
processing_deadline_duration=60,
4140
silo_mode=SiloMode.CONTROL,
4241
)
43-
@retry(exclude=(RepoExistsError, KeyError))
42+
@retry(exclude=(KeyError,))
4443
def link_all_repos(
4544
integration_key: str,
4645
integration_id: int,
@@ -88,14 +87,15 @@ def link_all_repos(
8887
missing_repos.append(repo)
8988
continue
9089

91-
try:
90+
_created_repos, _reactivated_repos, existing_repos = (
9291
integration_repo_provider.create_repositories(
9392
configs=repo_configs, organization=rpc_org
9493
)
95-
except RepoExistsError as e:
94+
)
95+
if existing_repos:
9696
lifecycle.record_halt(
9797
str(LinkAllReposHaltReason.REPOSITORY_NOT_CREATED),
98-
{"missing_repos": e.repos, "integration_id": integration_id},
98+
{"missing_repos": existing_repos, "integration_id": integration_id},
9999
)
100100

101101
if missing_repos:

src/sentry/integrations/github/tasks/sync_repos.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@
2121
SCMIntegrationInteractionEvent,
2222
SCMIntegrationInteractionType,
2323
)
24+
from sentry.integrations.source_code_management.repo_audit import log_repo_change
2425
from sentry.organizations.services.organization import organization_service
25-
from sentry.plugins.providers.integration_repository import (
26-
RepoExistsError,
27-
get_integration_repository_provider,
28-
)
26+
from sentry.plugins.providers.integration_repository import get_integration_repository_provider
2927
from sentry.shared_integrations.exceptions import ApiError
3028
from sentry.silo.base import SiloMode
3129
from sentry.tasks.base import instrumented_task, retry
@@ -171,6 +169,8 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
171169
if dry_run:
172170
return
173171

172+
repo_by_external_id = {r.external_id: r for r in active_repos + disabled_repos}
173+
174174
if new_ids:
175175
integration_repo_provider = get_integration_repository_provider(integration)
176176
repo_configs = [
@@ -179,12 +179,27 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
179179
if str(repo["id"]) in new_ids
180180
]
181181
if repo_configs:
182-
try:
183-
integration_repo_provider.create_repositories(
184-
configs=repo_configs, organization=rpc_org
182+
created_repos, reactivated_repos, _ = integration_repo_provider.create_repositories(
183+
configs=repo_configs, organization=rpc_org
184+
)
185+
186+
for repo in created_repos:
187+
log_repo_change(
188+
event_name="REPO_ADDED",
189+
organization_id=organization_id,
190+
repo=repo,
191+
source="repository sync",
192+
provider=integration.provider,
193+
)
194+
195+
for repo in reactivated_repos:
196+
log_repo_change(
197+
event_name="REPO_ENABLED",
198+
organization_id=organization_id,
199+
repo=repo,
200+
source="repository sync",
201+
provider=integration.provider,
185202
)
186-
except RepoExistsError:
187-
pass
188203

189204
if removed_ids:
190205
repository_service.disable_repositories_by_external_ids(
@@ -194,13 +209,31 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
194209
external_ids=list(removed_ids),
195210
)
196211

212+
for eid in removed_ids:
213+
removed_repo = repo_by_external_id.get(eid)
214+
if removed_repo:
215+
log_repo_change(
216+
event_name="REPO_DISABLED",
217+
organization_id=organization_id,
218+
repo=removed_repo,
219+
source="automatic SCM syncing",
220+
provider=integration.provider,
221+
)
222+
197223
if restored_ids:
198224
for repo in disabled_repos:
199225
if repo.external_id in restored_ids:
200226
repo.status = ObjectStatus.ACTIVE
201227
repository_service.update_repository(
202228
organization_id=organization_id, update=repo
203229
)
230+
log_repo_change(
231+
event_name="REPO_ENABLED",
232+
organization_id=organization_id,
233+
repo=repo,
234+
source="automatic SCM syncing",
235+
provider=integration.provider,
236+
)
204237

205238

206239
@instrumented_task(

src/sentry/integrations/github/tasks/sync_repos_on_install_change.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
SCMIntegrationInteractionEvent,
1414
SCMIntegrationInteractionType,
1515
)
16+
from sentry.integrations.source_code_management.repo_audit import log_repo_change
1617
from sentry.organizations.services.organization import organization_service
1718
from sentry.organizations.services.organization.model import RpcOrganization
1819
from sentry.plugins.providers.integration_repository import (
19-
RepoExistsError,
2020
RepositoryInputConfig,
2121
get_integration_repository_provider,
2222
)
@@ -36,7 +36,7 @@
3636
processing_deadline_duration=120,
3737
silo_mode=SiloMode.CONTROL,
3838
)
39-
@retry(exclude=(RepoExistsError, KeyError))
39+
@retry(exclude=(KeyError,))
4040
def sync_repos_on_install_change(
4141
integration_id: int,
4242
action: str,
@@ -119,18 +119,59 @@ def _sync_repos_for_org(
119119
continue
120120

121121
if repo_configs:
122-
try:
122+
created_repos, reactivated_repos, _missing_repos = (
123123
integration_repo_provider.create_repositories(
124124
configs=repo_configs, organization=rpc_org
125125
)
126-
except RepoExistsError:
127-
pass
126+
)
127+
128+
for created_repo in created_repos:
129+
log_repo_change(
130+
event_name="REPO_ADDED",
131+
organization_id=rpc_org.id,
132+
repo=created_repo,
133+
source="GitHub webhook",
134+
provider=integration.provider,
135+
)
136+
137+
for reactivated_repo in reactivated_repos:
138+
log_repo_change(
139+
event_name="REPO_ENABLED",
140+
organization_id=rpc_org.id,
141+
repo=reactivated_repo,
142+
source="GitHub webhook",
143+
provider=integration.provider,
144+
)
128145

129146
if repos_removed:
147+
# Look up repos before disabling to get their IDs and names
130148
external_ids = [str(repo["id"]) for repo in repos_removed]
149+
existing_repos = repository_service.get_repositories(
150+
organization_id=rpc_org.id,
151+
integration_id=integration.id,
152+
providers=[provider],
153+
)
154+
repo_by_eid = {
155+
r.external_id: r
156+
for r in existing_repos
157+
if r.external_id and r.status == ObjectStatus.ACTIVE
158+
}
159+
131160
repository_service.disable_repositories_by_external_ids(
132161
organization_id=rpc_org.id,
133162
integration_id=integration.id,
134163
provider=provider,
135164
external_ids=external_ids,
136165
)
166+
167+
for repo in repos_removed:
168+
eid = str(repo["id"])
169+
sentry_repo = repo_by_eid.get(eid)
170+
if sentry_repo:
171+
log_repo_change(
172+
event_name="REPO_DISABLED",
173+
organization_id=rpc_org.id,
174+
repo=sentry_repo,
175+
source="GitHub webhook",
176+
provider=integration.provider,
177+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Audit log helpers for repository sync operations.
3+
"""
4+
5+
from sentry import audit_log
6+
from sentry.integrations.services.repository.model import RpcRepository
7+
from sentry.utils.audit import create_system_audit_entry
8+
9+
10+
def log_repo_change(
11+
*, event_name: str, organization_id: int, repo: RpcRepository, source: str, provider: str
12+
) -> None:
13+
create_system_audit_entry(
14+
organization_id=organization_id,
15+
target_object=repo.id,
16+
event=audit_log.get_event_id(event_name),
17+
data={
18+
"repo_name": repo.name,
19+
"external_id": repo.external_id,
20+
"source": source,
21+
"provider": provider,
22+
},
23+
)

src/sentry/plugins/providers/integration_repository.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,21 @@ def create_repositories(
240240
self,
241241
configs: list[RepositoryInputConfig],
242242
organization: RpcOrganization,
243-
):
243+
) -> tuple[list[RpcRepository], list[RpcRepository], list[RepositoryConfig]]:
244+
"""
245+
Create or update repositories from configs.
246+
Returns (created, reactivated, missing) — newly created repos, repos that
247+
were reactivated or updated from a hidden/unlinked state, and repo configs
248+
that could not be created because a repository with that configuration
249+
already exists.
250+
"""
244251
external_id_to_repo_config: dict[str, RepositoryConfig] = {}
245252
for config in configs:
246253
result = self.build_repository_config(organization=organization, data=config)
247254
external_id_to_repo_config[result["external_id"]] = result
248255

249256
repos_to_update: list[RpcRepository] = []
257+
created_repos: list[RpcRepository] = []
250258

251259
hidden_repos = repository_service.get_repositories(
252260
organization_id=organization.id,
@@ -272,6 +280,7 @@ def create_repositories(
272280
organization_id=organization.id, create=create_repository
273281
)
274282
if new_repository is not None:
283+
created_repos.append(new_repository)
275284
continue
276285

277286
missing_repos.append(repo_config)
@@ -299,8 +308,7 @@ def create_repositories(
299308
updates=repos_to_update,
300309
)
301310

302-
if missing_repos:
303-
raise RepoExistsError(repos=missing_repos)
311+
return created_repos, repos_to_update, missing_repos
304312

305313
def dispatch(self, request: Request, organization, **kwargs):
306314
try:

tests/sentry/integrations/github/tasks/test_sync_repos.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
import responses
55
from taskbroker_client.retry import RetryTaskError
66

7+
from sentry import audit_log
78
from sentry.constants import ObjectStatus
89
from sentry.integrations.github.integration import GitHubIntegrationProvider
910
from sentry.integrations.github.tasks.sync_repos import sync_repos_for_org
1011
from sentry.integrations.models.organization_integration import OrganizationIntegration
12+
from sentry.models.auditlogentry import AuditLogEntry
1113
from sentry.models.repository import Repository
1214
from sentry.silo.base import SiloMode
1315
from sentry.testutils.cases import IntegrationTestCase
14-
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
16+
from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test
1517

1618

1719
@control_silo_test
@@ -60,6 +62,13 @@ def test_creates_new_repos(self, _: MagicMock) -> None:
6062
assert repos[0].provider == "integrations:github"
6163
assert repos[1].name == "getsentry/snuba"
6264

65+
with assume_test_silo_mode_of(AuditLogEntry):
66+
entries = AuditLogEntry.objects.filter(
67+
organization_id=self.organization.id,
68+
event=audit_log.get_event_id("REPO_ADDED"),
69+
)
70+
assert entries.count() == 2
71+
6372
@responses.activate
6473
def test_disables_removed_repos(self, _: MagicMock) -> None:
6574
with assume_test_silo_mode(SiloMode.CELL):
@@ -89,6 +98,16 @@ def test_disables_removed_repos(self, _: MagicMock) -> None:
8998
organization_id=self.organization.id, external_id="1"
9099
).exists()
91100

101+
with assume_test_silo_mode_of(AuditLogEntry):
102+
assert AuditLogEntry.objects.filter(
103+
organization_id=self.organization.id,
104+
event=audit_log.get_event_id("REPO_DISABLED"),
105+
).exists()
106+
assert AuditLogEntry.objects.filter(
107+
organization_id=self.organization.id,
108+
event=audit_log.get_event_id("REPO_ADDED"),
109+
).exists()
110+
92111
@responses.activate
93112
def test_re_enables_restored_repos(self, _: MagicMock) -> None:
94113
with assume_test_silo_mode(SiloMode.CELL):
@@ -113,6 +132,12 @@ def test_re_enables_restored_repos(self, _: MagicMock) -> None:
113132
repo.refresh_from_db()
114133
assert repo.status == ObjectStatus.ACTIVE
115134

135+
with assume_test_silo_mode_of(AuditLogEntry):
136+
assert AuditLogEntry.objects.filter(
137+
organization_id=self.organization.id,
138+
event=audit_log.get_event_id("REPO_ENABLED"),
139+
).exists()
140+
116141
@responses.activate
117142
def test_no_changes_needed(self, _: MagicMock) -> None:
118143
with assume_test_silo_mode(SiloMode.CELL):

0 commit comments

Comments
 (0)