Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .github/workflows/scripts/compute-sentry-selected-tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@
"tests/sentry/backup/test_validate.py",
}

# Seer public-API matrix discovers PUBLIC mutations at collection time, so
# endpoint module edits (including publish_status flips) need an explicit include.
PUBLIC_API_MATRIX_TEST = "tests/sentry/seer/endpoints/test_organization_agent_token.py"
PUBLIC_API_MATRIX_PATH_TRIGGERS: list[re.Pattern[str]] = [
# Endpoint modules live under */endpoints/ across product areas.
re.compile(r"^src/sentry/.*/endpoints/.*\.py$"),
]


def _is_test(path: str) -> bool:
return any(path.startswith(d) for d in TEST_DIRS)
Expand All @@ -168,6 +176,14 @@ def _matches_trigger(file_path: str, trigger: str | re.Pattern[str]) -> bool:
return file_path == trigger


def _changed_files_match_public_api_matrix_paths(changed_files: list[str]) -> list[str]:
return [
f
for f in changed_files
if any(_matches_trigger(f, t) for t in PUBLIC_API_MATRIX_PATH_TRIGGERS)
]


def _query_coverage(coverage_db_path: str, db_file_paths: list[str]) -> set[str]:
"""Query coverage DB for test contexts covering the given source files."""
conn = sqlite3.connect(coverage_db_path)
Expand Down Expand Up @@ -313,6 +329,14 @@ def main() -> int:
# Always run these tests
affected_test_files.update(ALWAYS_RUN_TESTS)

endpoint_sources = _changed_files_match_public_api_matrix_paths(changed)
if endpoint_sources:
print(
"Including public API matrix test due to endpoint path(s): "
+ ", ".join(endpoint_sources)
)
affected_test_files.add(PUBLIC_API_MATRIX_TEST)

# Filter to sentry tests only (drop any getsentry tests from coverage)
affected_test_files = {f for f in affected_test_files if _is_test(f)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
EXTRA_DIR_TO_TEST_MAPPING,
EXTRA_FILE_TO_TEST_MAPPING,
FULL_SUITE_TRIGGERS,
PUBLIC_API_MATRIX_TEST,
_changed_files_match_public_api_matrix_paths,
_query_coverage,
main,
)
Expand Down Expand Up @@ -427,6 +429,71 @@ def test_missing_db_returns_error(self):
ret = _run(["--coverage-db", "/nonexistent/coverage.db", "--changed-files", "foo.py"])
assert ret == 1

def test_endpoint_path_force_includes_public_api_matrix(self, tmp_path):
db_path = tmp_path / "coverage.db"
_create_coverage_db(str(db_path), {})
output = tmp_path / "output.txt"
gh_output = tmp_path / "gh_output"
gh_output.write_text("")

with mock.patch("compute_sentry_selected_tests.Path.exists", return_value=True):
_run(
[
"--coverage-db",
str(db_path),
"--changed-files",
"src/sentry/api/endpoints/views.py",
"--output",
str(output),
"--github-output",
],
{"GITHUB_OUTPUT": str(gh_output)},
)

selected = set(output.read_text().splitlines())
assert PUBLIC_API_MATRIX_TEST in selected
assert selected == ALWAYS_RUN_TESTS | {PUBLIC_API_MATRIX_TEST}
assert "has-selected-tests=true" in gh_output.read_text()

def test_non_endpoint_source_does_not_force_public_api_matrix(self, tmp_path):
db_path = tmp_path / "coverage.db"
_create_coverage_db(str(db_path), {})
output = tmp_path / "output.txt"
gh_output = tmp_path / "gh_output"
gh_output.write_text("")

with mock.patch("compute_sentry_selected_tests.Path.exists", return_value=True):
_run(
[
"--coverage-db",
str(db_path),
"--changed-files",
"src/sentry/utils/thing.py",
"--output",
str(output),
"--github-output",
],
{"GITHUB_OUTPUT": str(gh_output)},
)

assert set(output.read_text().splitlines()) == ALWAYS_RUN_TESTS
assert PUBLIC_API_MATRIX_TEST not in output.read_text()


class TestPublicApiMatrixPathTriggers:
def test_matches_endpoint_paths(self):
assert _changed_files_match_public_api_matrix_paths(
[
"src/sentry/api/endpoints/views.py",
"src/sentry/issues/endpoints/organization_group_search_views.py",
"src/sentry/utils/thing.py",
"tests/sentry/api/endpoints/test_views.py",
]
) == [
"src/sentry/api/endpoints/views.py",
"src/sentry/issues/endpoints/organization_group_search_views.py",
]


class TestConfigPaths:
"""Assert every literal path in the selective testing config still exists on disk.
Expand All @@ -440,7 +507,7 @@ class TestConfigPaths:
def test_full_suite_triggers_exist(self, trigger: str) -> None:
assert (_REPO_ROOT / trigger).exists(), _stale_msg(trigger)

@pytest.mark.parametrize("path", sorted(ALWAYS_RUN_TESTS))
@pytest.mark.parametrize("path", sorted(ALWAYS_RUN_TESTS | {PUBLIC_API_MATRIX_TEST}))
def test_always_run_tests_exist(self, path: str) -> None:
assert (_REPO_ROOT / path).exists(), _stale_msg(path, "test file")

Expand Down
56 changes: 44 additions & 12 deletions src/sentry/issues/endpoints/organization_group_search_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
RESPONSE_NOT_FOUND,
RESPONSE_UNAUTHORIZED,
)
from sentry.apidocs.parameters import GlobalParams
from sentry.apidocs.parameters import CursorQueryParam, GlobalParams
from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewVisibility
Expand Down Expand Up @@ -73,13 +73,18 @@ class OrganizationGroupSearchViewGetSerializer(serializers.Serializer[None]):
createdBy = serializers.ChoiceField(
choices=("me", "others"),
required=False,
help_text="Whether to return issue views created by the current user or other members.",
)
sort = serializers.ListField(
child=serializers.ChoiceField(choices=list(SORT_MAP.keys())),
required=False,
default=["-visited"],
help_text="The fields used to sort issue views, in order of precedence.",
)
query = serializers.CharField(
required=False,
help_text="A case-insensitive search against issue view names and queries.",
)
query = serializers.CharField(required=False)

def validate_query(self, value: str | None) -> str | None:
return value.strip() if value else None
Expand All @@ -89,13 +94,37 @@ def validate_query(self, value: str | None) -> str | None:
@cell_silo_endpoint
class OrganizationGroupSearchViewsEndpoint(OrganizationEndpoint):
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"GET": ApiPublishStatus.PUBLIC,
"POST": ApiPublishStatus.PUBLIC,
}
owner = ApiOwner.ISSUES
permission_classes = (MemberPermission,)

def get(self, request: Request, organization: Organization) -> Response:
@extend_schema(
operation_id="listOrganizationIssueViews",
summary="List Issue Views",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
OrganizationGroupSearchViewGetSerializer,
CursorQueryParam,
],
responses={
200: inline_sentry_response_serializer(
"OrganizationIssueViewList", list[GroupSearchViewSerializerResponse]
),
400: RESPONSE_BAD_REQUEST,
401: RESPONSE_UNAUTHORIZED,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def get(
self, request: Request, organization: Organization
) -> (
Response[list[GroupSearchViewSerializerResponse]]
| Response[ValidationErrorResponse]
| Response[None]
):
"""
List the current organization member's custom views
`````````````````````````````````````````
Expand All @@ -107,7 +136,7 @@ def get(self, request: Request, organization: Organization) -> Response:

serializer = OrganizationGroupSearchViewGetSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST)

starred_view_ids = GroupSearchViewStarred.objects.filter(
organization=organization, user_id=request.user.id
Expand Down Expand Up @@ -177,17 +206,20 @@ def get(self, request: Request, organization: Organization) -> Response:
else:
raise ValueError(f"Unexpected createdBy value: {createdBy}")

def serialize_views(
views: list[GroupSearchView],
) -> list[GroupSearchViewSerializerResponse]:
return serialize(
views,
request.user,
serializer=GroupSearchViewSerializer(organization=organization),
)

return self.paginate(
request=request,
sources=[starred_query, non_starred_query],
paginator_cls=ChainPaginator,
on_results=lambda x: serialize(
x,
request.user,
serializer=GroupSearchViewSerializer(
organization=organization,
),
),
on_results=serialize_views,
)

@extend_schema(
Expand Down
21 changes: 15 additions & 6 deletions tests/apidocs/endpoints/events/test_organization_issue_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ class OrganizationIssueViewsDocs(APIDocsTestCase):
def setUp(self) -> None:
self.login_as(user=self.user)
self.url = f"/api/0/organizations/{self.organization.slug}/group-search-views/"

@with_feature({"organizations:issue-views": True})
def test_post(self) -> None:
data = {
self.data = {
"name": "My Issues",
"query": "is:unresolved",
"querySort": "date",
Expand All @@ -20,7 +17,19 @@ def test_post(self) -> None:
"timeFilters": {"period": "14d"},
}

response = self.client.post(self.url, data)
request = RequestFactory().post(self.url, data)
@with_feature({"organizations:issue-views": True})
def test_get(self) -> None:
create_response = self.client.post(self.url, self.data)
assert create_response.status_code == 201

response = self.client.get(self.url)
request = RequestFactory().get(self.url)

self.validate_schema(request, response)

@with_feature({"organizations:issue-views": True})
def test_post(self) -> None:
response = self.client.post(self.url, self.data)
request = RequestFactory().post(self.url, self.data)

self.validate_schema(request, response)
Loading