Skip to content
Merged
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
4855a01
add new connected repos endpoint
srest2021 May 8, 2026
9d3ad42
:hammer_and_wrench: Sync API Urls to TypeScript
getsantry[bot] May 8, 2026
023e2d6
move helpers to uitls and add tests
srest2021 May 8, 2026
0c3e1bb
fix bugs and add endpoint tests
srest2021 May 8, 2026
72243e7
cleaning up
srest2021 May 8, 2026
217e3bf
resolve merge conflicts
srest2021 May 19, 2026
9ffb9e8
:hammer_and_wrench: Sync API Urls to TypeScript
getsantry[bot] May 19, 2026
65cb9ce
fix tests, link projectrepository, remove unnecessary queries
srest2021 May 19, 2026
580e22a
fix comments
srest2021 May 19, 2026
55a41ed
fix tests
srest2021 May 19, 2026
5196d7a
fix mypy
srest2021 May 19, 2026
9a769e4
rename file
srest2021 May 20, 2026
251463a
bug fixes and cleaning up
srest2021 May 20, 2026
63ad438
reorder endpoints
srest2021 May 20, 2026
a680213
fix missing arg
srest2021 May 20, 2026
f0b3791
feat(seer): Add CRUD helpers for Seer project repos
srest2021 May 20, 2026
69e411d
fix validation
srest2021 May 20, 2026
b778898
more test coverage
srest2021 May 20, 2026
e0e8350
remove validation fn from this pr
srest2021 May 20, 2026
4bc027b
fix get_or_create
srest2021 May 20, 2026
6d7bb23
remove project lock for replace-all
srest2021 May 20, 2026
d1f2493
resolve merge conflicts
srest2021 May 20, 2026
279d7b8
require repo id when creating
srest2021 May 20, 2026
20ea2df
Merge branch 'srest2021/CW-1286-utils' into srest2021/CW-1286
srest2021 May 20, 2026
47572b0
resolve merge conflicts, split to details-only endpoint
srest2021 May 20, 2026
177eaf4
remove update fn to inline it in the endpoint
srest2021 May 20, 2026
9784314
Merge branch 'srest2021/CW-1286-utils' into srest2021/CW-1286
srest2021 May 20, 2026
5462ec8
inline helper and move tests
srest2021 May 20, 2026
c8e4a29
bulk repos endpoint
srest2021 May 20, 2026
1d89266
ref base queryset
srest2021 May 20, 2026
b386f31
Merge branch 'srest2021/CW-1286' into srest2021/CW-1286-bulk
srest2021 May 20, 2026
6dfb8dd
ref base queryset
srest2021 May 20, 2026
9357f43
Merge branch 'srest2021/CW-1286' into srest2021/CW-1286-bulk
srest2021 May 20, 2026
7366ff8
use base queryset
srest2021 May 20, 2026
686eeec
Merge branch 'master' into srest2021/CW-1286
srest2021 May 20, 2026
2e93fc6
ensure at least one update field provided
srest2021 May 20, 2026
4e49993
add branch override id to response
srest2021 May 20, 2026
662db71
add org id to project repo response
srest2021 May 20, 2026
4d73e36
only save projectrepo if something changed
srest2021 May 20, 2026
10ddef3
Merge branch 'srest2021/CW-1286' into srest2021/CW-1286-bulk
srest2021 May 20, 2026
ab0c1dd
more test coverage
srest2021 May 20, 2026
06766c7
Accidentally added test script
srest2021 May 20, 2026
3f9354a
simplify unsupported provider tests
srest2021 May 20, 2026
6b1b6cf
resolve merge conflicts
srest2021 May 20, 2026
5785d94
switch to project-scoped endpoint
srest2021 May 21, 2026
e7186a6
:hammer_and_wrench: Sync API Urls to TypeScript
getsantry[bot] May 21, 2026
0cb1eaf
rename endpoint
srest2021 May 21, 2026
67dd8a8
switch to project endpoint and resolve conflicts
srest2021 May 21, 2026
fc81ec8
:hammer_and_wrench: Sync API Urls to TypeScript
getsantry[bot] May 21, 2026
6c08ae9
add cross-project test coverage
srest2021 May 21, 2026
5d6794c
more test coverage
srest2021 May 21, 2026
749c371
Merge remote-tracking branch 'origin/srest2021/CW-1286-bulk' into sre…
srest2021 May 21, 2026
635a636
resolve merge conflicts
srest2021 May 21, 2026
a02f7f6
clean up tests
srest2021 May 21, 2026
4c0ddc9
remove in/not_in support from name
srest2021 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
from sentry.seer.endpoints.project_seer_repos import (
ProjectSeerRepoEndpoint,
ProjectSeerReposEndpoint,
)
from sentry.seer.endpoints.project_seer_settings import (
OrganizationSeerProjectSettingsEndpoint,
Expand Down Expand Up @@ -3383,6 +3384,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ProjectSeerPreferencesEndpoint.as_view(),
name="sentry-api-0-project-seer-preferences",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/seer/repos/$",
ProjectSeerReposEndpoint.as_view(),
name="sentry-api-0-project-seer-repos",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/seer/repos/(?P<repo_id>\d+)/$",
ProjectSeerRepoEndpoint.as_view(),
Expand Down
177 changes: 176 additions & 1 deletion src/sentry/seer/endpoints/project_seer_repos.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from __future__ import annotations

from collections.abc import Sequence
from functools import partial
from typing import TypedDict

from django.db import router, transaction
from django.db.models import Value
from django.db.models.functions import Replace
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -11,13 +15,34 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter
from sentry.api.event_search import parse_search_query as base_parse_search_query
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
from sentry.constants import ObjectStatus
from sentry.exceptions import InvalidSearchQuery
from sentry.models.project import Project
from sentry.seer.autofix.utils import replace_all_branch_overrides
from sentry.models.repository import Repository
from sentry.seer.autofix.utils import (
add_seer_project_repos,
replace_all_branch_overrides,
replace_all_seer_project_repos,
)
from sentry.seer.models.project_repository import SeerProjectRepository
from sentry.seer.seer_setup import get_supported_scm_providers

SORT_FIELDS_MAPPING: dict[str, str] = {
"name": "project_repository__repository__name",
"-name": "-project_repository__repository__name",
"provider": "provider_normalized",
"-provider": "-provider_normalized",
Comment thread
srest2021 marked this conversation as resolved.
}

search_config = SearchConfig.create_from(
SearchConfig(), allowed_keys={"name", "provider"}, allow_boolean=False, free_text_key="name"
)
parse_search_query = partial(base_parse_search_query, config=search_config)


class BranchOverrideResponse(TypedDict):
id: str
Expand Down Expand Up @@ -79,6 +104,59 @@ def _valid_project_repos_queryset(project: Project):
).select_related("project_repository", "project_repository__repository")


def _validate_repo_ids(project: Project, repo_ids: list[int]) -> None:
"""Raise ValueError if any repo IDs are invalid, inactive, or have providers not supported by Seer."""
organization = project.organization
valid_ids = set(
Repository.objects.filter(
id__in=repo_ids,
organization_id=organization.id,
status=ObjectStatus.ACTIVE,
provider__in=get_supported_scm_providers(organization),
).values_list("id", flat=True)
)
invalid_ids = set(repo_ids) - valid_ids
if invalid_ids:
raise ValueError(sorted(invalid_ids))


def _apply_search_filters(queryset, filters: Sequence[QueryToken]):
for f in filters:
if not isinstance(f, SearchFilter):
continue

key = f.key.name
op = f.operator
value = f.value.value

if key == "name":
if op not in ("=", "!=", "IN", "NOT IN"):
raise InvalidSearchQuery(f"name does not support the {op} operator.")
if op == "=":
queryset = queryset.filter(project_repository__repository__name__icontains=value)
Comment thread
srest2021 marked this conversation as resolved.
elif op == "!=":
queryset = queryset.exclude(project_repository__repository__name__icontains=value)
elif op == "IN":
queryset = queryset.filter(project_repository__repository__name__in=value)
elif op == "NOT IN":
queryset = queryset.exclude(project_repository__repository__name__in=value)
Comment thread
srest2021 marked this conversation as resolved.
Outdated

elif key == "provider":
if op not in ("=", "!=", "IN", "NOT IN"):
raise InvalidSearchQuery(f"provider does not support the {op} operator.")
normalize = lambda v: v.removeprefix("integrations:")
if op == "=":
queryset = queryset.filter(provider_normalized=normalize(value))
elif op == "!=":
queryset = queryset.exclude(provider_normalized=normalize(value))
elif op == "IN":
queryset = queryset.filter(provider_normalized__in=[normalize(v) for v in value])
elif op == "NOT IN":
queryset = queryset.exclude(provider_normalized__in=[normalize(v) for v in value])

return queryset


def _validate_branch_overrides(value):
if not value:
return value
Expand Down Expand Up @@ -113,6 +191,26 @@ def validate(self, data):
return data


class SeerProjectRepoCreateSerializer(CamelSnakeSerializer):
repository_id = serializers.IntegerField(required=True)
branch_name = serializers.CharField(required=False, allow_null=True, allow_blank=True)
instructions = serializers.CharField(required=False, allow_null=True, allow_blank=True)
branch_overrides = BranchOverrideSerializer(many=True, required=False, allow_null=False)

def validate_branch_overrides(self, value):
return _validate_branch_overrides(value)


class SeerProjectReposBulkSerializer(CamelSnakeSerializer):
repos = SeerProjectRepoCreateSerializer(many=True, required=True, allow_empty=True)

def validate_repos(self, value):
repo_ids = [r["repository_id"] for r in value]
if len(repo_ids) != len(set(repo_ids)):
raise serializers.ValidationError("Duplicate repository IDs are not allowed.")
return value


@cell_silo_endpoint
class ProjectSeerRepoEndpoint(ProjectEndpoint):
owner = ApiOwner.ML_AI
Expand Down Expand Up @@ -175,3 +273,80 @@ def delete(self, request: Request, project: Project, repo_id: int) -> Response:
return Response(status=404)

return Response(status=204)


@cell_silo_endpoint
class ProjectSeerReposEndpoint(ProjectEndpoint):
owner = ApiOwner.ML_AI
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
"PUT": ApiPublishStatus.EXPERIMENTAL,
}

def get(self, request: Request, project: Project) -> Response:
queryset = (
_valid_project_repos_queryset(project)
.prefetch_related("branch_overrides")
.annotate(
provider_normalized=Replace(
"project_repository__repository__provider", Value("integrations:"), Value("")
)
)
)

search_query = request.GET.get("query", "")
if search_query:
try:
filters = parse_search_query(search_query)
queryset = _apply_search_filters(queryset, filters)
except (InvalidSearchQuery, ValueError):
return Response({"detail": "Invalid search query"}, status=400)

sort_by = request.GET.get("sortBy", "name")
order_by = SORT_FIELDS_MAPPING.get(sort_by)
if order_by is None:
return Response({"detail": f"Invalid sortBy: {sort_by}"}, status=400)

return self.paginate(
request=request,
queryset=queryset,
order_by=order_by,
on_results=lambda results: [_serialize_project_repo(r) for r in results],
paginator_cls=OffsetPaginator,
)
Comment thread
srest2021 marked this conversation as resolved.

def post(self, request: Request, project: Project) -> Response:
serializer = SeerProjectReposBulkSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

repos_data = serializer.validated_data["repos"]
if not repos_data:
return Response({"detail": "repos must not be empty."}, status=400)

try:
_validate_repo_ids(project, [d["repository_id"] for d in repos_data])
except ValueError as e:
return Response({"detail": f"Invalid repository IDs: {e.args[0]}"}, status=400)

add_seer_project_repos(project, repos_data)
Comment thread
sentry[bot] marked this conversation as resolved.

return Response(status=204)

def put(self, request: Request, project: Project) -> Response:
serializer = SeerProjectReposBulkSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

repos_data = serializer.validated_data["repos"]

if repos_data:
try:
_validate_repo_ids(project, [d["repository_id"] for d in repos_data])
except ValueError as e:
return Response({"detail": f"Invalid repository IDs: {e.args[0]}"}, status=400)

replace_all_seer_project_repos(project, repos_data)
Comment thread
srest2021 marked this conversation as resolved.

return Response(status=204)
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ export type KnownSentryApiUrls =
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/preview/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/night-shift/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/preferences/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/$repoId/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/settings/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-coverage/'
Expand Down
Loading
Loading