Browse Source

feat(workflow): `release.stage` filter support (#27276)

Chris Fuller 3 years ago
parent
commit
931b97324b

+ 21 - 3
src/sentry/api/endpoints/organization_releases.py

@@ -19,6 +19,7 @@ from sentry.api.serializers.rest_framework import (
     ReleaseHeadCommitSerializerDeprecated,
     ReleaseWithVersionSerializer,
 )
+from sentry.exceptions import InvalidSearchQuery
 from sentry.models import (
     Activity,
     Project,
@@ -28,7 +29,7 @@ from sentry.models import (
     ReleaseStatus,
     SemverFilter,
 )
-from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
+from sentry.search.events.constants import RELEASE_STAGE_ALIAS, SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.search.events.filter import parse_semver
 from sentry.signals import release_created
 from sentry.snuba.sessions import (
@@ -90,6 +91,11 @@ def _filter_releases_by_query(queryset, organization, query):
                 SemverFilter("exact", [], search_filter.value.raw_value),
             )
 
+        if search_filter.key.name == RELEASE_STAGE_ALIAS:
+            queryset = queryset.filter_by_stage(
+                organization.id, search_filter.operator, search_filter.value.value
+            )
+
     return queryset
 
 
@@ -231,7 +237,13 @@ class OrganizationReleasesEndpoint(
         queryset = add_environment_to_queryset(queryset, filter_params)
 
         if query:
-            queryset = _filter_releases_by_query(queryset, organization, query)
+            try:
+                queryset = _filter_releases_by_query(queryset, organization, query)
+            except InvalidSearchQuery as e:
+                return Response(
+                    {"detail": str(e)},
+                    status=400,
+                )
 
         select_extra = {}
 
@@ -491,7 +503,13 @@ class OrganizationReleasesStatsEndpoint(OrganizationReleasesBaseEndpoint, Enviro
         queryset = add_date_filter_to_queryset(queryset, filter_params)
         queryset = add_environment_to_queryset(queryset, filter_params)
         if query:
-            queryset = _filter_releases_by_query(queryset, organization, query)
+            try:
+                queryset = _filter_releases_by_query(queryset, organization, query)
+            except InvalidSearchQuery as e:
+                return Response(
+                    {"detail": str(e)},
+                    status=400,
+                )
 
         return self.paginate(
             request=request,

+ 3 - 2
src/sentry/api/release_search.py

@@ -1,13 +1,14 @@
 from functools import partial
 
 from sentry.api.event_search import SearchConfig, default_config, parse_search_query
-from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
+from sentry.search.events.constants import RELEASE_STAGE_ALIAS, SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 
 RELEASE_FREE_TEXT_KEY = "release"
+RELEASE_STAGE_KEY = "release.stage"
 
 release_search_config = SearchConfig.create_from(
     default_config,
-    allowed_keys={SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS},
+    allowed_keys={RELEASE_STAGE_ALIAS, SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS},
     allow_boolean=False,
     free_text_key=RELEASE_FREE_TEXT_KEY,
 )

+ 2 - 1
src/sentry/api/serializers/models/group.py

@@ -49,7 +49,7 @@ from sentry.notifications.helpers import (
 )
 from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
 from sentry.reprocessing2 import get_progress
-from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
+from sentry.search.events.constants import RELEASE_STAGE_ALIAS, SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.search.events.filter import convert_search_filter_to_snuba_query
 from sentry.tagstore.snuba.backend import fix_tag_value_data
 from sentry.tsdb.snuba import SnubaTSDB
@@ -758,6 +758,7 @@ class GroupSerializerSnuba(GroupSerializerBase):
         # TODO: Above comment is wrong, we do need to filter by at least `SEMVER_ALIAS` here since
         # groups can appear across releases. Probably unnecessary for the `SEMVER_PACKAGE_ALIAS`
         # though, since package tends to be tied to a specific project.
+        RELEASE_STAGE_ALIAS,
         SEMVER_ALIAS,
         SEMVER_PACKAGE_ALIAS,
     }

+ 53 - 1
src/sentry/models/release.py

@@ -7,7 +7,7 @@ from typing import List, Mapping, Optional, Sequence, Union
 
 import sentry_sdk
 from django.db import IntegrityError, models, transaction
-from django.db.models import Case, F, Func, Sum, Value, When
+from django.db.models import Case, F, Func, Q, Subquery, Sum, Value, When
 from django.utils import timezone
 from django.utils.functional import cached_property
 from django.utils.translation import ugettext_lazy as _
@@ -24,6 +24,7 @@ from sentry.db.models import (
     Model,
     sane_repr,
 )
+from sentry.exceptions import InvalidSearchQuery
 from sentry.models import CommitFileChange, GroupInboxRemoveAction, remove_group_from_inbox
 from sentry.signals import issue_resolved
 from sentry.utils import metrics
@@ -170,6 +171,48 @@ class ReleaseQuerySet(models.QuerySet):
             ).filter(**{f"semver__{semver_filter.operator}": filter_func})
         return qs
 
+    def filter_by_stage(
+        self,
+        organization_id: int,
+        operator: str,
+        value,
+        project_ids: Sequence[int] = None,
+    ) -> models.QuerySet:
+        from sentry.models import ReleaseProjectEnvironment
+        from sentry.search.events.filter import to_list
+
+        filters = {
+            "adopted": Q(adopted__isnull=False, unadopted__isnull=True),
+            "replaced": Q(adopted__isnull=False, unadopted__isnull=False),
+            "not_adopted": Q(adopted__isnull=True, unadopted__isnull=True),
+        }
+        value = to_list(value)
+        operator_conversions = {"=": "IN", "!=": "NOT IN"}
+        if operator in operator_conversions.keys():
+            operator = operator_conversions.get(operator)
+
+        for stage in value:
+            if stage not in filters:
+                raise InvalidSearchQuery("Unsupported release.stage value.")
+
+        rpes = ReleaseProjectEnvironment.objects.filter(
+            release__organization_id=organization_id,
+        ).select_related("release")
+
+        if project_ids:
+            rpes = rpes.filter(project_id__in=project_ids)
+
+        query = Q()
+        if operator == "IN":
+            for stage in value:
+                query |= filters[stage]
+        elif operator == "NOT IN":
+            for stage in value:
+                query &= ~filters[stage]
+
+        qs = self.filter(id__in=Subquery(rpes.filter(query).values_list("release_id", flat=True)))
+        return qs
+
 
 class ReleaseModelManager(models.Manager):
     def get_queryset(self):
@@ -186,6 +229,15 @@ class ReleaseModelManager(models.Manager):
     ) -> models.QuerySet:
         return self.get_queryset().filter_by_semver(organization_id, semver_filter, project_ids)
 
+    def filter_by_stage(
+        self,
+        organization_id: int,
+        operator: str,
+        value,
+        project_ids: Sequence[int] = None,
+    ) -> models.QuerySet:
+        return self.get_queryset().filter_by_stage(organization_id, operator, value, project_ids)
+
     @staticmethod
     def _convert_build_code_to_build_number(build_code):
         """

+ 3 - 1
src/sentry/search/events/constants.py

@@ -13,6 +13,7 @@ PROJECT_NAME_ALIAS = "project.name"
 ISSUE_ALIAS = "issue"
 ISSUE_ID_ALIAS = "issue.id"
 RELEASE_ALIAS = "release"
+RELEASE_STAGE_ALIAS = "release.stage"
 SEMVER_ALIAS = "release.version"
 SEMVER_PACKAGE_ALIAS = "release.package"
 TIMESTAMP_TO_HOUR_ALIAS = "timestamp.to_hour"
@@ -68,6 +69,7 @@ SEARCH_MAP = {
     "last_seen": "last_seen",
     "times_seen": "times_seen",
     SEMVER_ALIAS: SEMVER_ALIAS,
+    RELEASE_STAGE_ALIAS: RELEASE_STAGE_ALIAS,
 }
 SEARCH_MAP.update(**DATASETS[Dataset.Events])
 SEARCH_MAP.update(**DATASETS[Dataset.Discover])
@@ -103,7 +105,7 @@ OPERATOR_NEGATION_MAP = {
 }
 OPERATOR_TO_DJANGO = {">=": "gte", "<=": "lte", ">": "gt", "<": "lt", "=": "exact"}
 
-SEMVER_MAX_SEARCH_RELEASES = 1000
+MAX_SEARCH_RELEASES = 1000
 SEMVER_EMPTY_RELEASE = "____SENTRY_EMPTY_RELEASE____"
 SEMVER_FAKE_PACKAGE = "__sentry_fake__"
 SEMVER_WILDCARDS = frozenset(["X", "*"])

+ 2 - 0
src/sentry/search/events/fields.py

@@ -406,6 +406,8 @@ def normalize_count_if_value(args: Mapping[str, str]) -> Union[float, str, int]:
 # When updating this list, also check if the following need to be updated:
 # - convert_search_filter_to_snuba_query (otherwise aliased field will be treated as tag)
 # - static/app/utils/discover/fields.tsx FIELDS (for discover column list and search box autocomplete)
+
+# TODO: I think I have to support the release stage alias here maybe?
 FIELD_ALIASES = {
     field.name: field
     for field in [

+ 41 - 6
src/sentry/search/events/filter.py

@@ -28,16 +28,17 @@ from sentry.search.events.constants import (
     ISSUE_ALIAS,
     ISSUE_ID_ALIAS,
     KEY_TRANSACTION_ALIAS,
+    MAX_SEARCH_RELEASES,
     NO_CONVERSION_FIELDS,
     OPERATOR_NEGATION_MAP,
     OPERATOR_TO_DJANGO,
     PROJECT_ALIAS,
     PROJECT_NAME_ALIAS,
     RELEASE_ALIAS,
+    RELEASE_STAGE_ALIAS,
     SEMVER_ALIAS,
     SEMVER_EMPTY_RELEASE,
     SEMVER_FAKE_PACKAGE,
-    SEMVER_MAX_SEARCH_RELEASES,
     SEMVER_PACKAGE_ALIAS,
     SEMVER_WILDCARDS,
     TEAM_KEY_TRANSACTION_ALIAS,
@@ -341,6 +342,39 @@ def _flip_field_sort(field: str):
     return field[1:] if field.startswith("-") else f"-{field}"
 
 
+def _release_stage_filter_converter(
+    search_filter: SearchFilter,
+    name: str,
+    params: Optional[Mapping[str, Union[int, str, datetime]]],
+) -> Tuple[str, str, Sequence[str]]:
+    """
+    Parses a release stage search and returns a snuba condition to filter to the
+    requested releases.
+    """
+    # TODO: Filter by project here as well. It's done elsewhere, but could critcally limit versions
+    # for orgs with thousands of projects, each with their own releases (potentailly drowning out ones we care about)
+
+    if not params or "organization_id" not in params:
+        raise ValueError("organization_id is a required param")
+
+    organization_id: int = params["organization_id"]
+    qs = (
+        Release.objects.filter_by_stage(
+            organization_id, search_filter.operator, search_filter.value.value
+        )
+        .values_list("version", flat=True)
+        .order_by("date_added")[:MAX_SEARCH_RELEASES]
+    )
+    versions = list(qs)
+    final_operator = "IN"
+
+    if not versions:
+        # XXX: Just return a filter that will return no results if we have no versions
+        versions = [SEMVER_EMPTY_RELEASE]
+
+    return ["release", final_operator, versions]
+
+
 def _semver_filter_converter(
     search_filter: SearchFilter,
     name: str,
@@ -378,11 +412,11 @@ def _semver_filter_converter(
     qs = (
         Release.objects.filter_by_semver(organization_id, parse_semver(version, operator))
         .values_list("version", flat=True)
-        .order_by(*order_by)[:SEMVER_MAX_SEARCH_RELEASES]
+        .order_by(*order_by)[:MAX_SEARCH_RELEASES]
     )
     versions = list(qs)
     final_operator = "IN"
-    if len(versions) == SEMVER_MAX_SEARCH_RELEASES:
+    if len(versions) == MAX_SEARCH_RELEASES:
         # We want to limit how many versions we pass through to Snuba. If we've hit
         # the limit, make an extra query and see whether the inverse has fewer ids.
         # If so, we can do a NOT IN query with these ids instead. Otherwise, we just
@@ -394,7 +428,7 @@ def _semver_filter_converter(
         qs_flipped = (
             Release.objects.filter_by_semver(organization_id, parse_semver(version, operator))
             .order_by(*map(_flip_field_sort, order_by))
-            .values_list("version", flat=True)[:SEMVER_MAX_SEARCH_RELEASES]
+            .values_list("version", flat=True)[:MAX_SEARCH_RELEASES]
         )
 
         exclude_versions = list(qs_flipped)
@@ -417,7 +451,7 @@ def _semver_package_filter_converter(
 ) -> Tuple[str, str, Sequence[str]]:
     """
     Applies a semver package filter to the search. Note that if the query returns more than
-    `SEMVER_MAX_SEARCH_RELEASES` here we arbitrarily return a subset of the releases.
+    `MAX_SEARCH_RELEASES` here we arbitrarily return a subset of the releases.
     """
     if not params or "organization_id" not in params:
         raise ValueError("organization_id is a required param")
@@ -428,7 +462,7 @@ def _semver_package_filter_converter(
     versions = list(
         Release.objects.filter_by_semver(
             organization_id, SemverFilter("exact", [], package)
-        ).values_list("version", flat=True)[:SEMVER_MAX_SEARCH_RELEASES]
+        ).values_list("version", flat=True)[:MAX_SEARCH_RELEASES]
     )
 
     if not versions:
@@ -503,6 +537,7 @@ key_conversion_map: Mapping[
     "error.handled": _error_handled_filter_converter,
     KEY_TRANSACTION_ALIAS: _key_transaction_filter_converter,
     TEAM_KEY_TRANSACTION_ALIAS: _team_key_transaction_filter_converter,
+    RELEASE_STAGE_ALIAS: _release_stage_filter_converter,
     SEMVER_ALIAS: _semver_filter_converter,
     SEMVER_PACKAGE_ALIAS: _semver_package_filter_converter,
 }

+ 31 - 0
src/sentry/tagstore/snuba/backend.py

@@ -18,6 +18,7 @@ from sentry.models import (
 )
 from sentry.search.events.constants import (
     PROJECT_ALIAS,
+    RELEASE_STAGE_ALIAS,
     SEMVER_ALIAS,
     SEMVER_PACKAGE_ALIAS,
     SEMVER_WILDCARDS,
@@ -795,6 +796,33 @@ class SnubaTagStorage(TagStorage):
             ]
         )
 
+    def _get_tag_values_for_release_stages(self, projects, environments, query):
+        from sentry.api.paginator import SequencePaginator
+
+        organization_id = Project.objects.filter(id=projects[0]).values_list(
+            "organization_id", flat=True
+        )[0]
+        versions = Release.objects.filter_by_stage(
+            organization_id,
+            "=",
+            query,
+            project_ids=projects,
+        )
+        if environments:
+            versions = versions.filter(
+                id__in=ReleaseEnvironment.objects.filter(
+                    environment_id__in=environments
+                ).values_list("release_id", flat=True)
+            )
+
+        versions = versions.order_by("version").values_list("version", flat=True)[:1000]
+        return SequencePaginator(
+            [
+                (i, TagValue(RELEASE_STAGE_ALIAS, v, None, None, None))
+                for i, v in enumerate(versions)
+            ]
+        )
+
     def get_tag_value_paginator_for_projects(
         self,
         projects,
@@ -869,6 +897,9 @@ class SnubaTagStorage(TagStorage):
             # If doing a search on semver, we want to hit postgres to query the releases
             return self._get_tag_values_for_semver(projects, environments, query)
 
+        if key == RELEASE_STAGE_ALIAS:
+            return self._get_tag_values_for_release_stages(projects, environments, query)
+
         conditions = []
         # transaction status needs a special case so that the user interacts with the names and not codes
         transaction_status = snuba_key == "transaction_status"

+ 1 - 1
src/sentry/tasks/releasemonitor.py

@@ -169,7 +169,7 @@ def adopt_releases(org_id, totals):
                 total_releases = len(environment_totals["releases"])
                 for release in environment_totals["releases"]:
                     threshold = 0.1 / total_releases
-                    if (
+                    if environment_totals["total_sessions"] != 0 and (
                         environment_totals["releases"][release]
                         / environment_totals["total_sessions"]
                         >= threshold

+ 141 - 2
tests/sentry/api/endpoints/test_organization_releases.py

@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
 
 import pytz
 from django.urls import reverse
+from django.utils import timezone
 from exam import fixture
 
 from sentry.api.endpoints.organization_releases import (
@@ -27,7 +28,7 @@ from sentry.models import (
     Repository,
 )
 from sentry.plugins.providers.dummy.repository import DummyRepositoryProvider
-from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
+from sentry.search.events.constants import RELEASE_STAGE_ALIAS, SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.testutils import APITestCase, ReleaseCommitPatchTest, SetRefsTestCase, TestCase
 from sentry.utils.compat.mock import patch
 
@@ -295,6 +296,75 @@ class OrganizationReleaseListTest(APITestCase):
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:2.2.1")
         assert [r["version"] for r in response.data] == []
 
+    def test_release_stage_filter(self):
+        self.login_as(user=self.user)
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:adopted"
+        )
+        assert [r["version"] for r in response.data] == []
+
+        replaced_release = self.create_release(version="replaced_release")
+        adopted_release = self.create_release(version="adopted_release")
+        not_adopted_release = self.create_release(version="not_adopted_release")
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=adopted_release.id,
+            environment_id=self.environment.id,
+            adopted=timezone.now(),
+        )
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=replaced_release.id,
+            environment_id=self.environment.id,
+            adopted=timezone.now(),
+            unadopted=timezone.now(),
+        )
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=not_adopted_release.id,
+            environment_id=self.environment.id,
+        )
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:adopted"
+        )
+        assert [r["version"] for r in response.data] == [adopted_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:not_adopted"
+        )
+        assert [r["version"] for r in response.data] == [not_adopted_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:replaced"
+        )
+        assert [r["version"] for r in response.data] == [replaced_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[adopted,replaced]"
+        )
+        assert [r["version"] for r in response.data] == [
+            adopted_release.version,
+            replaced_release.version,
+        ]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[not_adopted]"
+        )
+        assert [r["version"] for r in response.data] == [not_adopted_release.version]
+
+        # TODO: Test release stage sort here. Not currently supported
+        # response = self.get_valid_response(
+        #     self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[adopted,not_adopted,replaced]", sort="adopted"
+        # )
+        # assert [r["version"] for r in response.data] == [adopted_release.version, replaced_release.version, not_adopted_release.version]
+
+        response = self.get_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:invalid_stage"
+        )
+        assert response.status_code == 400
+
     def test_project_permissions(self):
         user = self.create_user(is_staff=False, is_superuser=False)
         org = self.create_organization()
@@ -421,7 +491,7 @@ class OrganizationReleaseListTest(APITestCase):
         assert len(response.data) == 1
 
 
-class OrganizationReleaseStatsTest(APITestCase):
+class OrganizationReleasesStatsTest(APITestCase):
     endpoint = "sentry-api-0-organization-releases-stats"
 
     def setUp(self):
@@ -596,6 +666,75 @@ class OrganizationReleaseStatsTest(APITestCase):
         )
         assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
 
+    def test_release_stage_filter(self):
+        self.login_as(user=self.user)
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:adopted"
+        )
+        assert [r["version"] for r in response.data] == []
+
+        replaced_release = self.create_release(version="replaced_release")
+        adopted_release = self.create_release(version="adopted_release")
+        not_adopted_release = self.create_release(version="not_adopted_release")
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=adopted_release.id,
+            environment_id=self.environment.id,
+            adopted=timezone.now(),
+        )
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=replaced_release.id,
+            environment_id=self.environment.id,
+            adopted=timezone.now(),
+            unadopted=timezone.now(),
+        )
+        ReleaseProjectEnvironment.objects.create(
+            project_id=self.project.id,
+            release_id=not_adopted_release.id,
+            environment_id=self.environment.id,
+        )
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:adopted"
+        )
+        assert [r["version"] for r in response.data] == [adopted_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:not_adopted"
+        )
+        assert [r["version"] for r in response.data] == [not_adopted_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:replaced"
+        )
+        assert [r["version"] for r in response.data] == [replaced_release.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[adopted,replaced]"
+        )
+        assert [r["version"] for r in response.data] == [
+            adopted_release.version,
+            replaced_release.version,
+        ]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[not_adopted]"
+        )
+        assert [r["version"] for r in response.data] == [not_adopted_release.version]
+
+        # TODO: Test release stage sort here. Not currently supported
+        # response = self.get_valid_response(
+        #     self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:[adopted,not_adopted,replaced]", sort="adopted"
+        # )
+        # assert [r["version"] for r in response.data] == [adopted_release.version, replaced_release.version, not_adopted_release.version]
+
+        response = self.get_response(
+            self.organization.slug, query=f"{RELEASE_STAGE_ALIAS}:invalid_stage"
+        )
+        assert response.status_code == 400
+
     def test_query_filter(self):
         self.login_as(user=self.user)
 

Some files were not shown because too many files changed in this diff