fix(releases): Support environment filter in the query param#112805
fix(releases): Support environment filter in the query param#112805
Conversation
The /releases endpoint returned 400 when the query param contained an environment: token (e.g. query=environment:production), because the release search config only allowed a fixed set of keys. Other endpoints accept environment either as a dedicated ?environment= param or inline in the query string. This change brings releases in line with that behaviour. - Add "environment" to the release search config allowed keys - Handle the environment search filter in _filter_releases_by_query, including wildcard patterns (contains / starts_with / ends_with) via classify_and_format_wildcard(), matching how other filters like RELEASE_ALIAS already work Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When environment is supplied as both a dedicated ?environment= param and as environment: in the query string, the two filters are AND-ed. Add a test that locks this in so any future change to the semantics is deliberate rather than accidental. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…TestCase helpers Replace manual reverse() + self.client.get() calls with get_success_response / get_error_response, matching the convention used by other test classes in this file. No behaviour change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| url = reverse( | ||
| "sentry-api-0-organization-releases", kwargs={"organization_id_or_slug": self.org.slug} | ||
| ) | ||
| response = self.client.get(url + "?environment=" + self.env1.name, format="json") |
There was a problem hiding this comment.
Migrated these tests to the less verbose style used for other test cases.
| if search_filter.key.name == ENVIRONMENT_KEY: | ||
| negated = search_filter.operator in ("!=", "NOT IN") | ||
| kind, value_o = search_filter.value.classify_and_format_wildcard() |
There was a problem hiding this comment.
I noticed some of the other filter keys do queryset.filter_by_<key> for example queryset.filter_by_semver, should we have filter_by_semver.filter_by_enviornment instead?
There was a problem hiding this comment.
Thanks for the suggestion - I've updated it to now use that pattern. Let me know if that's looking better 🙏
…ases The environment filter in _filter_releases_by_query uses a JOIN on releaseprojectenvironment. When a release is associated with the same environment across multiple projects, this produces duplicate rows. __get_old already handles this with .distinct() but __get_new did not. Add it there to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Unconditional
distinct()regresses performance-optimized path- I replaced the query-time environment filter with an Exists subquery scoped by project and removed the unconditional distinct() so the optimized path no longer pays global DISTINCT overhead.
Or push these changes by commenting:
@cursor push e85c43a17d
Preview (e85c43a17d)
diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py
--- a/src/sentry/api/endpoints/organization_releases.py
+++ b/src/sentry/api/endpoints/organization_releases.py
@@ -5,7 +5,7 @@
import sentry_sdk
from django.db import IntegrityError
-from django.db.models import F, Q
+from django.db.models import Exists, F, OuterRef, Q
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ParseError
from rest_framework.request import Request
@@ -182,32 +182,28 @@
)
if search_filter.key.name == ENVIRONMENT_KEY:
+ from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment
+
negated = search_filter.operator in ("!=", "NOT IN")
kind, value_o = search_filter.value.classify_and_format_wildcard()
project_ids = filter_params["project_id"]
+ env_filter: dict[str, object] = {
+ "release": OuterRef("pk"),
+ "project_id__in": project_ids,
+ }
if kind == "infix":
- env_q = Q(
- releaseprojectenvironment__environment__name__icontains=value_o,
- releaseprojectenvironment__project_id__in=project_ids,
- )
+ env_filter["environment__name__icontains"] = value_o
elif kind == "prefix":
- env_q = Q(
- releaseprojectenvironment__environment__name__istartswith=value_o,
- releaseprojectenvironment__project_id__in=project_ids,
- )
+ env_filter["environment__name__istartswith"] = value_o
elif kind == "suffix":
- env_q = Q(
- releaseprojectenvironment__environment__name__iendswith=value_o,
- releaseprojectenvironment__project_id__in=project_ids,
- )
+ env_filter["environment__name__iendswith"] = value_o
else:
env_names = value_o if isinstance(value_o, list) else [value_o]
- env_q = Q(
- releaseprojectenvironment__environment__name__in=env_names,
- releaseprojectenvironment__project_id__in=project_ids,
- )
- queryset = queryset.exclude(env_q) if negated else queryset.filter(env_q)
+ env_filter["environment__name__in"] = env_names
+ env_query = Exists(ReleaseProjectEnvironment.objects.filter(**env_filter))
+ queryset = queryset.exclude(env_query) if negated else queryset.filter(env_query)
+
return queryset
@@ -418,7 +414,6 @@
status=400,
)
- queryset = queryset.distinct()
queryset = filter_releases_by_projects(queryset, filter_params["project_id"])
if sort == "date":This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
…nvironment Replace the inline JOIN-based environment filter in _filter_releases_by_query with a new ReleaseQuerySet.filter_by_environment method that uses an EXISTS subquery. This avoids duplicate rows without requiring DISTINCT, which is especially important on the performance-optimized __get_new path. Also adds a delegation method on ReleaseModelManager for consistency with other queryset methods like filter_by_semver and filter_by_stage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit aa0dc34. Configure here.
…Endpoint The stats endpoint builds its queryset with .values() before passing it to _filter_releases_by_query. This test confirms that filter_by_environment()'s Exists subquery works correctly on that ValuesQuerySet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous isinstance(value, list) guard would mishandle any Sequence that isn't a list (e.g. a tuple): it would wrap the tuple as a single element, producing 'environment__name__in=[tuple]' instead of spreading the values. Checking for str instead correctly handles all Sequence types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
It appears to me to be the same behaviour (use both) based on here where it applies the query first, and then appends conditions from the separate query params (e.g. environment). |


The
/releasesendpoint returns 400 when thequeryparam contains anenvironment:token (e.g.?query=environment:production). Other endpoints accept environment either as a dedicated?environment=param or inline in the query string; this brings releases in line with that behaviour.The frontend already sends
environment:in the query string for some widgets (e.g. crashes by release), so the 400 is a real user-facing breakage.What changed
src/sentry/api/release_search.py— addedENVIRONMENT_KEY = "environment"alongside the other local key constants, and included it in therelease_search_configallowed-key set so the parser accepts it instead of raisingInvalidSearchQuery.src/sentry/api/endpoints/organization_releases.py— added a handler forENVIRONMENT_KEYin_filter_releases_by_query. It callsclassify_and_format_wildcard()(the same helper used byRELEASE_ALIAS) to map wildcard patterns to the right ORM lookups:environment:*foo*→__icontainsenvironment:foo*→__istartswithenvironment:*foo→__iendswithenvironment:prod→ exact__in!environment:*foo*→exclude(...)variant for eachThis handles the exact frontend format (
!environment:\uF00DContains\uF00Dfoo) that was previously breaking.Notes
When
environmentis supplied both as?environment=prodand asenvironment:stagingin the query string, the two filters are AND-ed — both.filter()calls are applied independently. This is consistent with how other chained filters work and is covered by a test.release.stageusesfilter_params.get("environment")for its environment scoping, which is only populated from the dedicated?environment=param. This pre-existing inconsistency is out of scope here.Refs DAIN-1281