55from typing import TypedDict
66
77from django .db import router , transaction
8- from django .db .models import Value
8+ from django .db .models import QuerySet , Value
99from django .db .models .functions import Replace
1010from rest_framework import serializers
1111from rest_framework .request import Request
2121from sentry .api .serializers .rest_framework import CamelSnakeSerializer
2222from sentry .constants import ObjectStatus
2323from sentry .exceptions import InvalidSearchQuery
24+ from sentry .models .organization import Organization
2425from sentry .models .project import Project
26+ from sentry .models .projectrepository import ProjectRepository
2527from sentry .models .repository import Repository
2628from sentry .seer .autofix .utils import (
2729 add_seer_project_repos ,
30+ get_seer_project_repo ,
31+ get_token_based_billing_project_repos ,
32+ is_seer_token_based_billing_onboarding_enabled ,
2833 replace_all_seer_project_repos ,
34+ update_seer_project_repo ,
2935)
30- from sentry .seer .models .project_repository import (
31- SeerProjectRepository ,
32- SeerProjectRepositoryBranchOverride ,
33- )
36+ from sentry .seer .models .project_repository import SeerProjectRepository
3437from sentry .seer .seer_setup import get_supported_scm_providers
3538
3639SORT_FIELDS_MAPPING : dict [str , str ] = {
37- "name" : "project_repository__repository__name " ,
38- "-name" : "-project_repository__repository__name " ,
40+ "name" : "repository__name " ,
41+ "-name" : "-repository__name " ,
3942 "provider" : "provider_normalized" ,
4043 "-provider" : "-provider_normalized" ,
4144}
@@ -67,8 +70,10 @@ class ProjectRepoResponse(TypedDict):
6770 instructions : str | None
6871
6972
70- def _serialize_project_repo (project_repo : SeerProjectRepository ) -> ProjectRepoResponse :
71- repo = project_repo .project_repository .repository
73+ def _serialize_project_repo (
74+ project_repo : ProjectRepository , seer_project_repo : SeerProjectRepository | None
75+ ) -> ProjectRepoResponse :
76+ repo = project_repo .repository
7277 name_parts = repo .name .split ("/" , 1 )
7378 owner = name_parts [0 ] if len (name_parts ) > 1 else ""
7479 name = name_parts [1 ] if len (name_parts ) > 1 else repo .name
@@ -82,21 +87,26 @@ def _serialize_project_repo(project_repo: SeerProjectRepository) -> ProjectRepoR
8287 name = name ,
8388 externalId = repo .external_id or "" ,
8489 integrationId = str (repo .integration_id ) if repo .integration_id is not None else None ,
85- branchName = project_repo .branch_name ,
86- branchOverrides = [
87- BranchOverrideResponse (
88- id = str (bo .id ),
89- tagName = bo .tag_name ,
90- tagValue = bo .tag_value ,
91- branchName = bo .branch_name ,
92- )
93- for bo in project_repo .branch_overrides .all ()
94- ],
95- instructions = project_repo .instructions ,
90+ branchName = seer_project_repo .branch_name if seer_project_repo else None ,
91+ branchOverrides = (
92+ [
93+ BranchOverrideResponse (
94+ id = str (bo .id ),
95+ tagName = bo .tag_name ,
96+ tagValue = bo .tag_value ,
97+ branchName = bo .branch_name ,
98+ )
99+ for bo in seer_project_repo .branch_overrides .all ()
100+ ]
101+ if seer_project_repo
102+ else []
103+ ),
104+ instructions = seer_project_repo .instructions if seer_project_repo else None ,
96105 )
97106
98107
99- def _valid_project_repos_queryset (project : Project ):
108+ def _get_seat_based_billing_seer_project_repos (project : Project ) -> QuerySet [SeerProjectRepository ]:
109+ """SeerProjectRepository Seer can use under seat-based billing."""
100110 return SeerProjectRepository .objects .filter (
101111 project_repository__project = project ,
102112 project_repository__repository__status = ObjectStatus .ACTIVE ,
@@ -106,17 +116,49 @@ def _valid_project_repos_queryset(project: Project):
106116 ).select_related ("project_repository" , "project_repository__repository" )
107117
108118
119+ def _get_seat_based_billing_project_repos (project : Project ) -> QuerySet [ProjectRepository ]:
120+ """ProjectRepository Seer can use under seat-based billing."""
121+ return (
122+ ProjectRepository .objects .filter (
123+ project = project ,
124+ seerprojectrepository__isnull = False ,
125+ repository__status = ObjectStatus .ACTIVE ,
126+ repository__provider__in = get_supported_scm_providers (project .organization ),
127+ )
128+ .select_related ("repository" , "seerprojectrepository" )
129+ .prefetch_related ("seerprojectrepository__branch_overrides" )
130+ )
131+
132+
133+ def _get_seat_based_billing_repos (organization : Organization ) -> QuerySet [Repository ]:
134+ """Repos Seer can use under seat-based billing."""
135+ return Repository .objects .filter (
136+ organization_id = organization .id ,
137+ status = ObjectStatus .ACTIVE ,
138+ provider__in = get_supported_scm_providers (organization ),
139+ )
140+
141+
142+ def _get_token_based_billing_repos (organization : Organization ) -> QuerySet [Repository ]:
143+ """Repos Seer can use under token-based billing. Same checks as get_token_based_billing_project_repos."""
144+ return Repository .objects .filter (
145+ organization_id = organization .id ,
146+ status = ObjectStatus .ACTIVE ,
147+ provider__in = get_supported_scm_providers (organization ),
148+ integration_id__isnull = False ,
149+ external_id__isnull = False ,
150+ ).exclude (external_id = "" )
151+
152+
109153def _validate_repo_ids (project : Project , repo_ids : list [int ]) -> None :
110154 """Raise ValueError if any repo IDs are invalid, inactive, or have providers not supported by Seer."""
111155 organization = project .organization
112- valid_ids = set (
113- Repository .objects .filter (
114- id__in = repo_ids ,
115- organization_id = organization .id ,
116- status = ObjectStatus .ACTIVE ,
117- provider__in = get_supported_scm_providers (organization ),
118- ).values_list ("id" , flat = True )
156+ repos = (
157+ _get_token_based_billing_repos (organization )
158+ if is_seer_token_based_billing_onboarding_enabled (organization )
159+ else _get_seat_based_billing_repos (organization )
119160 )
161+ valid_ids = set (repos .filter (id__in = repo_ids ).values_list ("id" , flat = True ))
120162 invalid_ids = set (repo_ids ) - valid_ids
121163 if invalid_ids :
122164 raise ValueError (sorted (invalid_ids ))
@@ -135,9 +177,9 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]):
135177 if op not in ("=" , "!=" ):
136178 raise InvalidSearchQuery (f"name does not support the { op } operator." )
137179 if op == "=" :
138- queryset = queryset .filter (project_repository__repository__name__icontains = value )
180+ queryset = queryset .filter (repository__name__icontains = value )
139181 elif op == "!=" :
140- queryset = queryset .exclude (project_repository__repository__name__icontains = value )
182+ queryset = queryset .exclude (repository__name__icontains = value )
141183
142184 elif key == "provider" :
143185 if op not in ("=" , "!=" , "IN" , "NOT IN" ):
@@ -219,16 +261,16 @@ class ProjectSeerRepoEndpoint(ProjectEndpoint):
219261 }
220262
221263 def get (self , request : Request , project : Project , repo_id : int ) -> Response :
222- project_repo = (
223- _valid_project_repos_queryset (project )
224- .prefetch_related ("branch_overrides" )
225- .filter (project_repository__repository_id = repo_id )
226- .first ()
264+ project_repos_qs = (
265+ get_token_based_billing_project_repos (project .organization , [project .id ])
266+ if is_seer_token_based_billing_onboarding_enabled (project .organization )
267+ else _get_seat_based_billing_project_repos (project )
227268 )
269+ project_repo = project_repos_qs .filter (repository_id = repo_id ).first ()
228270 if project_repo is None :
229271 return Response (status = 404 )
230272
231- return Response (_serialize_project_repo (project_repo ))
273+ return Response (_serialize_project_repo (project_repo , get_seer_project_repo ( project_repo ) ))
232274
233275 def put (self , request : Request , project : Project , repo_id : int ) -> Response :
234276 serializer = SeerProjectRepoUpdateSerializer (data = request .data )
@@ -237,49 +279,46 @@ def put(self, request: Request, project: Project, repo_id: int) -> Response:
237279
238280 data = serializer .validated_data
239281
282+ seer_project_repo : SeerProjectRepository | None
240283 with transaction .atomic (router .db_for_write (SeerProjectRepository )):
241- project_repo = (
242- _valid_project_repos_queryset (project )
243- .select_for_update ()
244- .filter (project_repository__repository_id = repo_id )
245- .first ()
246- )
247- if project_repo is None :
248- return Response (status = 404 )
249-
250- if "branch_name" in data :
251- project_repo .branch_name = data ["branch_name" ]
252- if "instructions" in data :
253- project_repo .instructions = data ["instructions" ]
254- if "branch_name" in data or "instructions" in data :
255- project_repo .save ()
256-
257- if "branch_overrides" in data :
258- SeerProjectRepositoryBranchOverride .objects .filter (
259- seer_project_repository = project_repo
260- ).delete ()
261- if data ["branch_overrides" ]:
262- SeerProjectRepositoryBranchOverride .objects .bulk_create (
263- [
264- SeerProjectRepositoryBranchOverride (
265- seer_project_repository = project_repo ,
266- tag_name = override ["tag_name" ],
267- tag_value = override ["tag_value" ],
268- branch_name = override ["branch_name" ],
269- )
270- for override in data ["branch_overrides" ]
271- ],
272- update_conflicts = True ,
273- update_fields = ["branch_name" , "date_updated" ],
274- unique_fields = ["seer_project_repository" , "tag_name" , "tag_value" ],
284+ if is_seer_token_based_billing_onboarding_enabled (project .organization ):
285+ project_repo = (
286+ get_token_based_billing_project_repos (project .organization , [project .id ])
287+ .filter (repository_id = repo_id )
288+ .first ()
289+ )
290+ if project_repo is None :
291+ return Response (status = 404 )
292+
293+ seer_project_repo , _ = (
294+ SeerProjectRepository .objects .select_for_update ().get_or_create (
295+ project_repository = project_repo
275296 )
297+ )
298+ else :
299+ seer_project_repo = (
300+ _get_seat_based_billing_seer_project_repos (project )
301+ .select_for_update ()
302+ .filter (project_repository__repository_id = repo_id )
303+ .first ()
304+ )
305+ if seer_project_repo is None :
306+ return Response (status = 404 )
307+ project_repo = seer_project_repo .project_repository
308+
309+ update_seer_project_repo (seer_project_repo , data )
276310
277- return Response (_serialize_project_repo (project_repo ))
311+ return Response (_serialize_project_repo (project_repo , seer_project_repo ))
278312
279313 def delete (self , request : Request , project : Project , repo_id : int ) -> Response :
314+ if is_seer_token_based_billing_onboarding_enabled (project .organization ):
315+ # Under token-based billing Seer uses every linked project repo,
316+ # so there's nothing to remove.
317+ return Response ({"detail" : "Repositories can't be removed from Seer." }, status = 400 )
318+
280319 with transaction .atomic (router .db_for_write (SeerProjectRepository )):
281320 deleted_count , _ = (
282- _valid_project_repos_queryset (project )
321+ _get_seat_based_billing_seer_project_repos (project )
283322 .filter (project_repository__repository_id = repo_id )
284323 .delete ()
285324 )
@@ -301,13 +340,11 @@ class ProjectSeerReposEndpoint(ProjectEndpoint):
301340
302341 def get (self , request : Request , project : Project ) -> Response :
303342 queryset = (
304- _valid_project_repos_queryset (project )
305- .prefetch_related ("branch_overrides" )
306- .annotate (
307- provider_normalized = Replace (
308- "project_repository__repository__provider" , Value ("integrations:" ), Value ("" )
309- )
310- )
343+ get_token_based_billing_project_repos (project .organization , [project .id ])
344+ if is_seer_token_based_billing_onboarding_enabled (project .organization )
345+ else _get_seat_based_billing_project_repos (project )
346+ ).annotate (
347+ provider_normalized = Replace ("repository__provider" , Value ("integrations:" ), Value ("" ))
311348 )
312349
313350 search_query = request .GET .get ("query" , "" )
@@ -327,7 +364,9 @@ def get(self, request: Request, project: Project) -> Response:
327364 request = request ,
328365 queryset = queryset ,
329366 order_by = order_by ,
330- on_results = lambda results : [_serialize_project_repo (r ) for r in results ],
367+ on_results = lambda results : [
368+ _serialize_project_repo (r , get_seer_project_repo (r )) for r in results
369+ ],
331370 paginator_cls = OffsetPaginator ,
332371 )
333372
0 commit comments