Skip to content

Commit fb753cc

Browse files
authored
fix(seer-prefs): repo cleanup for org deleted repos (#95768)
When you delete a repo from the UI (the `PUT` endpoint) we'll delete it from seer preferences as well. Tested locally with sentry <-> seer
1 parent 2a92bd0 commit fb753cc

File tree

5 files changed

+324
-3
lines changed

5 files changed

+324
-3
lines changed

src/sentry/conf/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
15051505
"sentry.tasks.release_registry",
15061506
"sentry.tasks.repository",
15071507
"sentry.tasks.reprocessing2",
1508+
"sentry.tasks.seer",
15081509
"sentry.tasks.statistical_detectors",
15091510
"sentry.tasks.store",
15101511
"sentry.tasks.summaries.daily_summary",

src/sentry/integrations/api/endpoints/organization_repository_details.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from sentry.models.commit import Commit
2222
from sentry.models.repository import Repository
2323
from sentry.tasks.repository import repository_cascade_delete_on_hide
24+
from sentry.tasks.seer import cleanup_seer_repository_preferences
2425

2526

2627
class RepositorySerializer(serializers.Serializer):
@@ -97,6 +98,15 @@ def put(self, request: Request, organization, repo_id) -> Response:
9798
elif repo.status == ObjectStatus.HIDDEN and old_status != repo.status:
9899
repository_cascade_delete_on_hide.apply_async(kwargs={"repo_id": repo.id})
99100

101+
if repo.external_id and repo.provider:
102+
cleanup_seer_repository_preferences.apply_async(
103+
kwargs={
104+
"organization_id": repo.organization_id,
105+
"repo_external_id": repo.external_id,
106+
"repo_provider": repo.provider,
107+
}
108+
)
109+
100110
return Response(serialize(repo, request.user))
101111

102112
def delete(self, request: Request, organization, repo_id) -> Response:

src/sentry/tasks/seer.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
import orjson
6+
import requests
7+
from django.conf import settings
8+
9+
from sentry.seer.signed_seer_api import sign_with_seer_secret
10+
from sentry.silo.base import SiloMode
11+
from sentry.tasks.base import instrumented_task
12+
from sentry.taskworker.config import TaskworkerConfig
13+
from sentry.taskworker.namespaces import seer_tasks
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
@instrumented_task(
19+
name="sentry.tasks.seer.cleanup_seer_repository_preferences",
20+
queue="cleanup",
21+
max_retries=3,
22+
default_retry_delay=60,
23+
silo_mode=SiloMode.REGION,
24+
taskworker_config=TaskworkerConfig(
25+
namespace=seer_tasks,
26+
processing_deadline_duration=60 * 5,
27+
),
28+
)
29+
def cleanup_seer_repository_preferences(
30+
organization_id: int, repo_external_id: str, repo_provider: str
31+
) -> None:
32+
"""
33+
Clean up Seer preferences for a deleted repository.
34+
35+
This task removes a repository from Seer organization preferences when the repository
36+
is deleted from an organization's integration.
37+
"""
38+
# Call Seer API to remove repository from organization preferences
39+
path = "/v1/project-preference/remove-repository"
40+
body = orjson.dumps(
41+
{
42+
"organization_id": organization_id,
43+
"repo_provider": repo_provider,
44+
"repo_external_id": repo_external_id,
45+
}
46+
)
47+
48+
try:
49+
response = requests.post(
50+
f"{settings.SEER_AUTOFIX_URL}{path}",
51+
data=body,
52+
headers={
53+
"content-type": "application/json;charset=utf-8",
54+
**sign_with_seer_secret(body),
55+
},
56+
)
57+
response.raise_for_status()
58+
logger.info(
59+
"cleanup_seer_repository_preferences.success",
60+
extra={
61+
"organization_id": organization_id,
62+
"repo_external_id": repo_external_id,
63+
"repo_provider": repo_provider,
64+
},
65+
)
66+
except Exception as e:
67+
logger.exception(
68+
"cleanup_seer_repository_preferences.failed",
69+
extra={
70+
"organization_id": organization_id,
71+
"repo_external_id": repo_external_id,
72+
"repo_provider": repo_provider,
73+
"error": str(e),
74+
},
75+
)
76+
raise

tests/sentry/integrations/api/endpoints/test_organization_repository_details.py

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,16 @@ def test_put_cancel_deletion(self):
190190
organization_id=org.id, key=build_pending_deletion_key(repo)
191191
).exists()
192192

193-
def test_put_hide_repo(self):
193+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
194+
def test_put_hide_repo(self, mock_cleanup_task):
194195
self.login_as(user=self.user)
195196

196197
org = self.create_organization(owner=self.user, name="baz")
197198

198199
repo = Repository.objects.create(
199200
name="uuid-name",
200201
external_id="uuid-external-id",
202+
provider="github",
201203
organization_id=org.id,
202204
status=ObjectStatus.ACTIVE,
203205
)
@@ -210,12 +212,22 @@ def test_put_hide_repo(self):
210212
repo = Repository.objects.get(id=repo.id)
211213
assert repo.status == ObjectStatus.HIDDEN
212214

213-
def test_put_hide_repo_with_commits(self):
215+
# Verify the cleanup task was called
216+
mock_cleanup_task.assert_called_once_with(
217+
kwargs={
218+
"organization_id": org.id,
219+
"repo_external_id": "uuid-external-id",
220+
"repo_provider": "github",
221+
}
222+
)
223+
224+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
225+
def test_put_hide_repo_with_commits(self, mock_cleanup_task):
214226
self.login_as(user=self.user)
215227

216228
org = self.create_organization(owner=self.user, name="baz")
217229
repo = Repository.objects.create(
218-
name="example", organization_id=org.id, external_id="abc123"
230+
name="example", organization_id=org.id, external_id="abc123", provider="github"
219231
)
220232
Commit.objects.create(repository_id=repo.id, key="a" * 40, organization_id=org.id)
221233

@@ -229,6 +241,15 @@ def test_put_hide_repo_with_commits(self):
229241
assert repo.status == ObjectStatus.HIDDEN
230242
assert len(Commit.objects.filter(repository_id=repo.id)) == 0
231243

244+
# Verify the cleanup task was called
245+
mock_cleanup_task.assert_called_once_with(
246+
kwargs={
247+
"organization_id": org.id,
248+
"repo_external_id": "abc123",
249+
"repo_provider": "github",
250+
}
251+
)
252+
232253
def test_put_bad_integration_org(self):
233254
self.login_as(user=self.user)
234255

@@ -260,3 +281,109 @@ def test_put_bad_integration_id(self):
260281
assert response.status_code == 400
261282
assert response.data == {"integrationId": ["A valid integer is required."]}
262283
assert Repository.objects.get(id=repo.id).name == "example"
284+
285+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
286+
def test_put_hide_repo_triggers_cleanup(self, mock_cleanup_task):
287+
"""Test that hiding a repository triggers Seer cleanup task."""
288+
self.login_as(user=self.user)
289+
290+
org = self.create_organization(owner=self.user, name="baz")
291+
repo = Repository.objects.create(
292+
name="example-repo",
293+
external_id="github-123",
294+
provider="github",
295+
organization_id=org.id,
296+
status=ObjectStatus.ACTIVE,
297+
)
298+
299+
url = reverse("sentry-api-0-organization-repository-details", args=[org.slug, repo.id])
300+
response = self.client.put(url, data={"status": "hidden"})
301+
302+
assert response.status_code == 200
303+
304+
repo = Repository.objects.get(id=repo.id)
305+
assert repo.status == ObjectStatus.HIDDEN
306+
307+
# Verify the cleanup task was called with correct parameters
308+
mock_cleanup_task.assert_called_once_with(
309+
kwargs={
310+
"organization_id": org.id,
311+
"repo_external_id": "github-123",
312+
"repo_provider": "github",
313+
}
314+
)
315+
316+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
317+
def test_put_hide_repo_no_cleanup_when_null_fields(self, mock_cleanup_task):
318+
"""Test that hiding a repository with null external_id/provider does not trigger Seer cleanup."""
319+
self.login_as(user=self.user)
320+
321+
org = self.create_organization(owner=self.user, name="baz")
322+
repo = Repository.objects.create(
323+
name="example-repo",
324+
external_id=None, # No external_id
325+
provider=None, # No provider
326+
organization_id=org.id,
327+
status=ObjectStatus.ACTIVE,
328+
)
329+
330+
url = reverse("sentry-api-0-organization-repository-details", args=[org.slug, repo.id])
331+
response = self.client.put(url, data={"status": "hidden"})
332+
333+
assert response.status_code == 200
334+
335+
repo = Repository.objects.get(id=repo.id)
336+
assert repo.status == ObjectStatus.HIDDEN
337+
338+
# Verify the cleanup task was NOT called
339+
mock_cleanup_task.assert_not_called()
340+
341+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
342+
def test_put_hide_repo_no_cleanup_when_external_id_null(self, mock_cleanup_task):
343+
"""Test that hiding a repository with null external_id does not trigger Seer cleanup."""
344+
self.login_as(user=self.user)
345+
346+
org = self.create_organization(owner=self.user, name="baz")
347+
repo = Repository.objects.create(
348+
name="example-repo",
349+
external_id=None, # No external_id
350+
provider="github",
351+
organization_id=org.id,
352+
status=ObjectStatus.ACTIVE,
353+
)
354+
355+
url = reverse("sentry-api-0-organization-repository-details", args=[org.slug, repo.id])
356+
response = self.client.put(url, data={"status": "hidden"})
357+
358+
assert response.status_code == 200
359+
360+
repo = Repository.objects.get(id=repo.id)
361+
assert repo.status == ObjectStatus.HIDDEN
362+
363+
# Verify the cleanup task was NOT called
364+
mock_cleanup_task.assert_not_called()
365+
366+
@patch("sentry.tasks.seer.cleanup_seer_repository_preferences.apply_async")
367+
def test_put_hide_repo_no_cleanup_when_provider_null(self, mock_cleanup_task):
368+
"""Test that hiding a repository with null provider does not trigger Seer cleanup."""
369+
self.login_as(user=self.user)
370+
371+
org = self.create_organization(owner=self.user, name="baz")
372+
repo = Repository.objects.create(
373+
name="example-repo",
374+
external_id="github-123",
375+
provider=None, # No provider
376+
organization_id=org.id,
377+
status=ObjectStatus.ACTIVE,
378+
)
379+
380+
url = reverse("sentry-api-0-organization-repository-details", args=[org.slug, repo.id])
381+
response = self.client.put(url, data={"status": "hidden"})
382+
383+
assert response.status_code == 200
384+
385+
repo = Repository.objects.get(id=repo.id)
386+
assert repo.status == ObjectStatus.HIDDEN
387+
388+
# Verify the cleanup task was NOT called
389+
mock_cleanup_task.assert_not_called()

tests/sentry/tasks/test_seer.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
import orjson
4+
import pytest
5+
import responses
6+
from django.conf import settings
7+
8+
from sentry.seer.signed_seer_api import sign_with_seer_secret
9+
from sentry.tasks.seer import cleanup_seer_repository_preferences
10+
from sentry.testutils.cases import TestCase
11+
12+
13+
class TestSeerRepositoryCleanup(TestCase):
14+
def setUp(self):
15+
self.organization = self.create_organization()
16+
self.project = self.create_project(organization=self.organization)
17+
self.repo_external_id = "12345"
18+
self.repo_provider = "github"
19+
20+
@responses.activate
21+
def test_cleanup_seer_repository_preferences_success(self):
22+
"""Test successful cleanup of Seer repository preferences."""
23+
# Mock the Seer API response
24+
responses.add(
25+
responses.POST,
26+
f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository",
27+
status=200,
28+
)
29+
30+
# Call the task
31+
cleanup_seer_repository_preferences(
32+
organization_id=self.organization.id,
33+
repo_external_id=self.repo_external_id,
34+
repo_provider=self.repo_provider,
35+
)
36+
37+
# Verify the request was made with correct data
38+
assert len(responses.calls) == 1
39+
request = responses.calls[0].request
40+
41+
expected_body = orjson.dumps(
42+
{
43+
"organization_id": self.organization.id,
44+
"repo_provider": self.repo_provider,
45+
"repo_external_id": self.repo_external_id,
46+
}
47+
)
48+
49+
assert request.body == expected_body
50+
assert request.headers["content-type"] == "application/json;charset=utf-8"
51+
52+
# Verify the request was signed
53+
expected_headers = sign_with_seer_secret(expected_body)
54+
for header_name, header_value in expected_headers.items():
55+
assert request.headers[header_name] == header_value
56+
57+
@responses.activate
58+
def test_cleanup_seer_repository_preferences_api_error(self):
59+
"""Test handling of Seer API errors."""
60+
# Mock the Seer API to return an error
61+
responses.add(
62+
responses.POST,
63+
f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository",
64+
status=500,
65+
)
66+
67+
# Call the task and expect it to raise an exception
68+
with pytest.raises(Exception):
69+
cleanup_seer_repository_preferences(
70+
organization_id=self.organization.id,
71+
repo_external_id=self.repo_external_id,
72+
repo_provider=self.repo_provider,
73+
)
74+
75+
@responses.activate
76+
def test_cleanup_seer_repository_preferences_organization_not_found(self):
77+
"""Test handling when organization doesn't exist."""
78+
# Mock the Seer API response for non-existent organization
79+
responses.add(
80+
responses.POST,
81+
f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository",
82+
status=200,
83+
)
84+
85+
# Use a non-existent organization ID
86+
nonexistent_organization_id = 99999
87+
88+
# Call the task - it should still make the API call even if org doesn't exist locally
89+
cleanup_seer_repository_preferences(
90+
organization_id=nonexistent_organization_id,
91+
repo_external_id=self.repo_external_id,
92+
repo_provider=self.repo_provider,
93+
)
94+
95+
# The API call should be made regardless of local organization existence
96+
assert len(responses.calls) == 1
97+
request = responses.calls[0].request
98+
99+
expected_body = orjson.dumps(
100+
{
101+
"organization_id": nonexistent_organization_id,
102+
"repo_provider": self.repo_provider,
103+
"repo_external_id": self.repo_external_id,
104+
}
105+
)
106+
107+
assert request.body == expected_body

0 commit comments

Comments
 (0)