Skip to content

Commit 3432173

Browse files
authored
fix(autofix): Dual-delete Seer preferences for disabled repositories (#112503)
Remove SeerProjectRepository rows and call `/v1/project-preference/bulk-remove-repositories` Seer endpoint for disabled repositories. This covers more of Seer's deletion task `cleanup_stale_seer_preferences_task`, as part of the migration of Seer settings from Seer to Sentry ([spec](https://www.notion.so/sentry/Tech-Spec-Migrate-Seer-Settings-to-Sentry-Database-3208b10e4b5d80f58ea0d7b77a301e2a)).
1 parent 94511a0 commit 3432173

File tree

3 files changed

+267
-41
lines changed

3 files changed

+267
-41
lines changed

src/sentry/integrations/services/repository/impl.py

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,60 @@ def update_repositories(self, *, organization_id: int, updates: list[RpcReposito
128128

129129
Repository.objects.bulk_update(repositories, fields=list(fields_to_update))
130130

131+
def _cleanup_seer_project_repositories(
132+
self,
133+
organization_id: int,
134+
repo_tuples: list[tuple[int, str | None, str | None]],
135+
) -> None:
136+
"""Delete SeerProjectRepository rows and schedule Seer API cleanup.
137+
138+
Must be called inside a transaction.atomic() block. The SeerProjectRepository
139+
delete participates in the transaction, while the Seer API task is dispatched
140+
via on_commit so it only fires if the transaction succeeds.
141+
142+
repo_tuples: list of (repo_id, external_id, provider) from Repository.values_list().
143+
"""
144+
try:
145+
organization = Organization.objects.get_from_cache(id=organization_id)
146+
if features.has("organizations:seer-project-settings-dual-write", organization):
147+
SeerProjectRepository.objects.filter(
148+
repository_id__in=[repo_id for repo_id, _, _ in repo_tuples]
149+
).delete()
150+
except Organization.DoesNotExist:
151+
pass
152+
153+
repos_to_clean = [
154+
{"repo_external_id": external_id, "repo_provider": repo_provider}
155+
for _, external_id, repo_provider in repo_tuples
156+
if external_id and repo_provider
157+
]
158+
if repos_to_clean:
159+
transaction.on_commit(
160+
lambda: bulk_cleanup_seer_repository_preferences.apply_async(
161+
kwargs={
162+
"organization_id": organization_id,
163+
"repos": repos_to_clean,
164+
}
165+
),
166+
using=router.db_for_write(Repository),
167+
)
168+
131169
def disable_repositories_for_integration(
132170
self, *, organization_id: int, integration_id: int, provider: str
133171
) -> None:
134172
with transaction.atomic(router.db_for_write(Repository)):
135-
Repository.objects.filter(
136-
organization_id=organization_id,
137-
integration_id=integration_id,
138-
provider=provider,
139-
).update(status=ObjectStatus.DISABLED)
173+
repos = list(
174+
Repository.objects.filter(
175+
organization_id=organization_id,
176+
integration_id=integration_id,
177+
provider=provider,
178+
).values_list("id", "external_id", "provider")
179+
)
180+
repo_ids = [repo_id for repo_id, _, _ in repos]
181+
182+
if repo_ids:
183+
Repository.objects.filter(id__in=repo_ids).update(status=ObjectStatus.DISABLED)
184+
self._cleanup_seer_project_repositories(organization_id, repos)
140185

141186
def disable_repositories_by_external_ids(
142187
self,
@@ -147,13 +192,20 @@ def disable_repositories_by_external_ids(
147192
external_ids: list[str],
148193
) -> None:
149194
with transaction.atomic(router.db_for_write(Repository)):
150-
Repository.objects.filter(
151-
organization_id=organization_id,
152-
integration_id=integration_id,
153-
provider=provider,
154-
external_id__in=external_ids,
155-
status=ObjectStatus.ACTIVE,
156-
).update(status=ObjectStatus.DISABLED)
195+
repos = list(
196+
Repository.objects.filter(
197+
organization_id=organization_id,
198+
integration_id=integration_id,
199+
provider=provider,
200+
external_id__in=external_ids,
201+
status=ObjectStatus.ACTIVE,
202+
).values_list("id", "external_id", "provider")
203+
)
204+
repo_ids = [repo_id for repo_id, _, _ in repos]
205+
206+
if repo_ids:
207+
Repository.objects.filter(id__in=repo_ids).update(status=ObjectStatus.DISABLED)
208+
self._cleanup_seer_project_repositories(organization_id, repos)
157209

158210
def disassociate_organization_integration(
159211
self,
@@ -169,21 +221,10 @@ def disassociate_organization_integration(
169221
).values_list("id", "external_id", "provider")
170222
)
171223
repo_ids = [repo_id for repo_id, _, _ in repos]
172-
173224
if repo_ids:
174225
# Disassociate repos from the organization integration being deleted
175226
Repository.objects.filter(id__in=repo_ids).update(integration_id=None)
176-
177-
# Delete Seer project preferences for the affected repos.
178-
# Organization may already be deleted if org deletion and integration
179-
# uninstall overlap; skip SeerProjectRepository cleanup in that case
180-
# since cascades will handle it.
181-
try:
182-
organization = Organization.objects.get_from_cache(id=organization_id)
183-
if features.has("organizations:seer-project-settings-dual-write", organization):
184-
SeerProjectRepository.objects.filter(repository_id__in=repo_ids).delete()
185-
except Organization.DoesNotExist:
186-
pass
227+
self._cleanup_seer_project_repositories(organization_id, repos)
187228

188229
# Delete Code Owners with a Code Mapping using the OrganizationIntegration
189230
ProjectCodeOwners.objects.filter(
@@ -196,17 +237,3 @@ def disassociate_organization_integration(
196237
RepositoryProjectPathConfig.objects.filter(
197238
organization_integration_id=organization_integration_id
198239
).delete()
199-
200-
# Delete Seer project preferences for the affected repos via Seer API
201-
repos_to_clean = [
202-
{"repo_external_id": external_id, "repo_provider": provider}
203-
for _, external_id, provider in repos
204-
if external_id and provider
205-
]
206-
if repos_to_clean:
207-
bulk_cleanup_seer_repository_preferences.apply_async(
208-
kwargs={
209-
"organization_id": organization_id,
210-
"repos": repos_to_clean,
211-
}
212-
)

tests/sentry/integrations/github/test_webhook.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,8 @@ def test_end_to_end_repos_added(self) -> None:
455455
assert repos[0].provider == "integrations:github"
456456
assert repos[1].name == "getsentry/snuba"
457457

458-
def test_end_to_end_repos_removed(self) -> None:
458+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
459+
def test_end_to_end_repos_removed(self, mock_seer_cleanup: MagicMock) -> None:
459460
"""Full end-to-end: webhook URL → handler → task → Repository disabled."""
460461
future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5)
461462
integration = self.create_integration(

tests/sentry/integrations/services/repository/test_impl.py

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
15
from sentry.constants import ObjectStatus
6+
from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
27
from sentry.integrations.services.repository.service import repository_service
38
from sentry.models.repository import Repository
9+
from sentry.seer.models.project_repository import SeerProjectRepository
410
from sentry.testutils.cases import TestCase
11+
from sentry.testutils.helpers.features import with_feature
512
from sentry.testutils.silo import cell_silo_test
613

714

@@ -15,7 +22,8 @@ def setUp(self) -> None:
1522
)
1623
self.provider = "integrations:github"
1724

18-
def test_disables_matching_active_repos(self) -> None:
25+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
26+
def test_disables_matching_active_repos(self, mock_seer_cleanup: MagicMock) -> None:
1927
repo1 = Repository.objects.create(
2028
organization_id=self.organization.id,
2129
name="getsentry/sentry",
@@ -111,7 +119,8 @@ def test_does_not_affect_repos_from_other_orgs(self) -> None:
111119
repo.refresh_from_db()
112120
assert repo.status == ObjectStatus.ACTIVE
113121

114-
def test_only_disables_specified_external_ids(self) -> None:
122+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
123+
def test_only_disables_specified_external_ids(self, mock_seer_cleanup: MagicMock) -> None:
115124
repo_to_disable = Repository.objects.create(
116125
organization_id=self.organization.id,
117126
name="getsentry/sentry",
@@ -160,3 +169,192 @@ def test_empty_external_ids_is_noop(self) -> None:
160169

161170
repo.refresh_from_db()
162171
assert repo.status == ObjectStatus.ACTIVE
172+
173+
@with_feature("organizations:seer-project-settings-dual-write")
174+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
175+
def test_cleans_up_seer_preferences(self, mock_cleanup: MagicMock) -> None:
176+
project = self.create_project(organization=self.organization)
177+
repo = Repository.objects.create(
178+
organization_id=self.organization.id,
179+
name="getsentry/sentry",
180+
external_id="100",
181+
provider=self.provider,
182+
integration_id=self.integration.id,
183+
status=ObjectStatus.ACTIVE,
184+
)
185+
SeerProjectRepository.objects.create(project=project, repository_id=repo.id)
186+
187+
repository_service.disable_repositories_by_external_ids(
188+
organization_id=self.organization.id,
189+
integration_id=self.integration.id,
190+
provider=self.provider,
191+
external_ids=["100"],
192+
)
193+
194+
repo.refresh_from_db()
195+
assert repo.status == ObjectStatus.DISABLED
196+
197+
assert not SeerProjectRepository.objects.filter(repository_id=repo.id).exists()
198+
mock_cleanup.apply_async.assert_called_once_with(
199+
kwargs={
200+
"organization_id": self.organization.id,
201+
"repos": [{"repo_external_id": "100", "repo_provider": self.provider}],
202+
}
203+
)
204+
205+
206+
@cell_silo_test
207+
class DisableRepositoriesForIntegrationTest(TestCase):
208+
def setUp(self) -> None:
209+
self.integration = self.create_integration(
210+
organization=self.organization,
211+
external_id="1",
212+
provider="github",
213+
)
214+
self.provider = "integrations:github"
215+
216+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
217+
def test_disables_matching_active_repos(self, mock_seer_cleanup: MagicMock) -> None:
218+
repo = Repository.objects.create(
219+
organization_id=self.organization.id,
220+
name="getsentry/sentry",
221+
external_id="100",
222+
provider=self.provider,
223+
integration_id=self.integration.id,
224+
status=ObjectStatus.ACTIVE,
225+
)
226+
227+
repository_service.disable_repositories_for_integration(
228+
organization_id=self.organization.id,
229+
integration_id=self.integration.id,
230+
provider=self.provider,
231+
)
232+
233+
repo.refresh_from_db()
234+
assert repo.status == ObjectStatus.DISABLED
235+
236+
@with_feature("organizations:seer-project-settings-dual-write")
237+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
238+
def test_cleans_up_seer_preferences(self, mock_cleanup: MagicMock) -> None:
239+
project = self.create_project(organization=self.organization)
240+
repo = Repository.objects.create(
241+
organization_id=self.organization.id,
242+
name="getsentry/sentry",
243+
external_id="100",
244+
provider=self.provider,
245+
integration_id=self.integration.id,
246+
status=ObjectStatus.ACTIVE,
247+
)
248+
SeerProjectRepository.objects.create(project=project, repository_id=repo.id)
249+
250+
repository_service.disable_repositories_for_integration(
251+
organization_id=self.organization.id,
252+
integration_id=self.integration.id,
253+
provider=self.provider,
254+
)
255+
256+
repo.refresh_from_db()
257+
assert repo.status == ObjectStatus.DISABLED
258+
259+
assert not SeerProjectRepository.objects.filter(repository_id=repo.id).exists()
260+
mock_cleanup.apply_async.assert_called_once_with(
261+
kwargs={
262+
"organization_id": self.organization.id,
263+
"repos": [{"repo_external_id": "100", "repo_provider": self.provider}],
264+
}
265+
)
266+
267+
268+
@cell_silo_test
269+
class DisassociateOrganizationIntegrationTest(TestCase):
270+
def setUp(self) -> None:
271+
self.integration = self.create_integration(
272+
organization=self.organization,
273+
external_id="1",
274+
provider="github",
275+
)
276+
self.provider = "integrations:github"
277+
self.org_integration = self.integration.organizationintegration_set.first()
278+
279+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
280+
def test_disassociates_repos(self, mock_cleanup: MagicMock) -> None:
281+
repo = Repository.objects.create(
282+
organization_id=self.organization.id,
283+
name="getsentry/sentry",
284+
external_id="100",
285+
provider=self.provider,
286+
integration_id=self.integration.id,
287+
status=ObjectStatus.ACTIVE,
288+
)
289+
290+
repository_service.disassociate_organization_integration(
291+
organization_id=self.organization.id,
292+
organization_integration_id=self.org_integration.id,
293+
integration_id=self.integration.id,
294+
)
295+
296+
repo.refresh_from_db()
297+
assert repo.integration_id is None
298+
mock_cleanup.apply_async.assert_called_once()
299+
300+
@with_feature("organizations:seer-project-settings-dual-write")
301+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
302+
def test_cleans_up_seer_preferences(self, mock_cleanup: MagicMock) -> None:
303+
project = self.create_project(organization=self.organization)
304+
repo = Repository.objects.create(
305+
organization_id=self.organization.id,
306+
name="getsentry/sentry",
307+
external_id="100",
308+
provider=self.provider,
309+
integration_id=self.integration.id,
310+
status=ObjectStatus.ACTIVE,
311+
)
312+
SeerProjectRepository.objects.create(project=project, repository_id=repo.id)
313+
314+
repository_service.disassociate_organization_integration(
315+
organization_id=self.organization.id,
316+
organization_integration_id=self.org_integration.id,
317+
integration_id=self.integration.id,
318+
)
319+
320+
repo.refresh_from_db()
321+
assert repo.integration_id is None
322+
assert not SeerProjectRepository.objects.filter(repository_id=repo.id).exists()
323+
mock_cleanup.apply_async.assert_called_once_with(
324+
kwargs={
325+
"organization_id": self.organization.id,
326+
"repos": [{"repo_external_id": "100", "repo_provider": self.provider}],
327+
}
328+
)
329+
330+
@patch("sentry.integrations.services.repository.impl.bulk_cleanup_seer_repository_preferences")
331+
def test_transaction_rollback_does_not_dispatch_seer_cleanup(
332+
self, mock_cleanup: MagicMock
333+
) -> None:
334+
repo = Repository.objects.create(
335+
organization_id=self.organization.id,
336+
name="getsentry/sentry",
337+
external_id="100",
338+
provider=self.provider,
339+
integration_id=self.integration.id,
340+
status=ObjectStatus.ACTIVE,
341+
)
342+
343+
with patch.object(
344+
RepositoryProjectPathConfig.objects,
345+
"filter",
346+
side_effect=RuntimeError("simulated failure"),
347+
):
348+
with pytest.raises(RuntimeError):
349+
repository_service.disassociate_organization_integration(
350+
organization_id=self.organization.id,
351+
organization_integration_id=self.org_integration.id,
352+
integration_id=self.integration.id,
353+
)
354+
355+
# Transaction rolled back: repo should still have its integration
356+
repo.refresh_from_db()
357+
assert repo.integration_id == self.integration.id
358+
359+
# Task should not have been dispatched
360+
mock_cleanup.apply_async.assert_not_called()

0 commit comments

Comments
 (0)