Skip to content

Commit dae96c1

Browse files
authored
feat(seer-settings): Support ProjectRepository CRUD for token-based billing in Seer project repos API (#128705)
Relates to CW-2250 Under token-based billing, the Seer project repos API lists every linked repo Seer can use, with `SeerProjectRepository` as optional per-repo config. Under token-based billing, Seer only uses linked repos that are active, have a supported provider, and have an integration and external ID. Every endpoint applies these checks. - `/seer/repos/` - GET lists every usable linked repo, with or without a config row. - POST adds repos and their config. - PUT ("replace all") is unchanged except for the stricter validation. Its only caller is the projectAddRepoModal.tsx, which only offers projects with no Seer repos. For token-based billing, repos are automatically linked to projects, so it'd make more sense to call POST to bulk update config instead of PUT. - `/seer/repos/{repo_id}/` - GET returns the repo even without a config row, and 404s if Seer can't use it. - PUT creates the config row if needed. - DELETE returns 400. Deleting the project-repo link doesn't make sense for token-based billing. Use PUT to set the config row back to default. Responses now return the `ProjectRepository` ID (instead of `SeerProjectRepository` id) as `id` for both seat-based and token-based orgs. This is ok because [the frontend list keys by repo id](https://github.com/getsentry/sentry/blob/a06ba545e582348b7b8a236a8176c9312d257aff/static/app/components/seer/projectDetails/autofixRepositoriesList.tsx#L180), and the API takes project and repo ids for CRUD.
1 parent a2bb12d commit dae96c1

3 files changed

Lines changed: 366 additions & 94 deletions

File tree

‎src/sentry/seer/autofix/utils.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,11 @@ def get_token_based_billing_project_repos(
492492
)
493493

494494

495+
def get_seer_project_repo(project_repo: ProjectRepository) -> SeerProjectRepository | None:
496+
"""Avoid raising RelatedObjectDoesNotExist when ProjectRepository has no SeerProjectRepository."""
497+
return getattr(project_repo, "seerprojectrepository", None)
498+
499+
495500
def build_seer_repo_definition(
496501
repo: Repository, seer_project_repo: SeerProjectRepository | None
497502
) -> SeerRepoDefinition | None:
@@ -530,9 +535,8 @@ def _get_repo_definitions_by_project(
530535
repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list)
531536
if is_seer_token_based_billing_onboarding_enabled(organization):
532537
for project_repo in get_token_based_billing_project_repos(organization, project_ids):
533-
# getattr since accessing a missing one-to-one raises RelatedObjectDoesNotExist.
534538
repo_def = build_seer_repo_definition(
535-
project_repo.repository, getattr(project_repo, "seerprojectrepository", None)
539+
project_repo.repository, get_seer_project_repo(project_repo)
536540
)
537541
if repo_def is not None:
538542
repo_definitions_by_project[project_repo.project_id].append(repo_def)
@@ -744,6 +748,44 @@ class ProjectRepoCreateData(TypedDict):
744748
branch_overrides: NotRequired[list[BranchOverrideData]]
745749

746750

751+
class ProjectRepoUpdateData(TypedDict):
752+
branch_name: NotRequired[str | None]
753+
instructions: NotRequired[str | None]
754+
branch_overrides: NotRequired[list[BranchOverrideData]]
755+
756+
757+
def update_seer_project_repo(
758+
seer_project_repo: SeerProjectRepository, data: ProjectRepoUpdateData
759+
) -> None:
760+
"""Update a Seer project repo's config. Fields missing from data are left unchanged."""
761+
if "branch_name" in data:
762+
seer_project_repo.branch_name = data["branch_name"]
763+
if "instructions" in data:
764+
seer_project_repo.instructions = data["instructions"]
765+
if "branch_name" in data or "instructions" in data:
766+
seer_project_repo.save()
767+
768+
if "branch_overrides" in data:
769+
SeerProjectRepositoryBranchOverride.objects.filter(
770+
seer_project_repository=seer_project_repo
771+
).delete()
772+
if data["branch_overrides"]:
773+
SeerProjectRepositoryBranchOverride.objects.bulk_create(
774+
[
775+
SeerProjectRepositoryBranchOverride(
776+
seer_project_repository=seer_project_repo,
777+
tag_name=override["tag_name"],
778+
tag_value=override["tag_value"],
779+
branch_name=override["branch_name"],
780+
)
781+
for override in data["branch_overrides"]
782+
],
783+
update_conflicts=True,
784+
update_fields=["branch_name", "date_updated"],
785+
unique_fields=["seer_project_repository", "tag_name", "tag_value"],
786+
)
787+
788+
747789
def add_seer_project_repos(project: Project, repos_data: list[ProjectRepoCreateData]) -> list[int]:
748790
"""Upsert Seer project repos."""
749791
if not repos_data:

‎src/sentry/seer/endpoints/project_seer_repos.py‎

Lines changed: 119 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import TypedDict
66

77
from django.db import router, transaction
8-
from django.db.models import Value
8+
from django.db.models import QuerySet, Value
99
from django.db.models.functions import Replace
1010
from rest_framework import serializers
1111
from rest_framework.request import Request
@@ -21,21 +21,24 @@
2121
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
2222
from sentry.constants import ObjectStatus
2323
from sentry.exceptions import InvalidSearchQuery
24+
from sentry.models.organization import Organization
2425
from sentry.models.project import Project
26+
from sentry.models.projectrepository import ProjectRepository
2527
from sentry.models.repository import Repository
2628
from 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
3437
from sentry.seer.seer_setup import get_supported_scm_providers
3538

3639
SORT_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+
109153
def _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

Comments
 (0)