diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index ea4c88eb9c47ac..5b15eedf266dd7 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -286,7 +286,8 @@ def download(self, debug_file_id, project: Project): raise Http404 @extend_schema( - operation_id="List a Project's Debug Information Files", + operation_id="listProjectDebugFiles", + summary="List a Project's Debug Information Files", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/event_attachment_details.py b/src/sentry/api/endpoints/event_attachment_details.py index dce0aee42aef84..b6eac73e103e9d 100644 --- a/src/sentry/api/endpoints/event_attachment_details.py +++ b/src/sentry/api/endpoints/event_attachment_details.py @@ -93,7 +93,8 @@ def stream_attachment(): return response @extend_schema( - operation_id="Retrieve an Event Attachment", + operation_id="getProjectEventAttachment", + summary="Retrieve an Event Attachment", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/event_attachments.py b/src/sentry/api/endpoints/event_attachments.py index 0df10eb6e5fb92..a9e359405cc09e 100644 --- a/src/sentry/api/endpoints/event_attachments.py +++ b/src/sentry/api/endpoints/event_attachments.py @@ -39,7 +39,8 @@ class EventAttachmentsEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="List an Event's Attachments", + operation_id="listProjectEventAttachments", + summary="List an Event's Attachments", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/organization_events.py b/src/sentry/api/endpoints/organization_events.py index 3e6aadb2f09845..23b6a306f31329 100644 --- a/src/sentry/api/endpoints/organization_events.py +++ b/src/sentry/api/endpoints/organization_events.py @@ -155,7 +155,8 @@ def get_features(self, organization: Organization, request: Request) -> Mapping[ return all_features @extend_schema( - operation_id="Query Explore Events in Table Format", + operation_id="listOrganizationEvents", + summary="Query Explore Events in Table Format", parameters=[ GlobalParams.END, GlobalParams.ENVIRONMENT, diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py index f05be4ef42dfcf..033dea792eb2b7 100644 --- a/src/sentry/api/endpoints/organization_events_timeseries.py +++ b/src/sentry/api/endpoints/organization_events_timeseries.py @@ -129,7 +129,8 @@ def get_comparison_delta(self, request: Request) -> timedelta | None: return None @extend_schema( - operation_id="Query Explore Events in Timeseries Format", + operation_id="listOrganizationEventsTimeseries", + summary="Query Explore Events in Timeseries Format", parameters=[ GlobalParams.END, GlobalParams.ENVIRONMENT, diff --git a/src/sentry/api/endpoints/organization_profiling_profiles.py b/src/sentry/api/endpoints/organization_profiling_profiles.py index 469298f92e1595..c0d2c609ce6bea 100644 --- a/src/sentry/api/endpoints/organization_profiling_profiles.py +++ b/src/sentry/api/endpoints/organization_profiling_profiles.py @@ -116,7 +116,8 @@ class OrganizationProfilingFlamegraphEndpoint(OrganizationProfilingBaseEndpoint) } @extend_schema( - operation_id="Retrieve a Flamegraph for an Organization", + operation_id="getOrganizationProfilingFlamegraph", + summary="Retrieve a Flamegraph for an Organization", parameters=[ GlobalParams.ORG_ID_OR_SLUG, OrganizationParams.PROJECT, @@ -213,7 +214,8 @@ class OrganizationProfilingChunksEndpoint(OrganizationProfilingBaseEndpoint): } @extend_schema( - operation_id="Retrieve Profile Chunks for an Organization", + operation_id="listOrganizationProfilingChunks", + summary="Retrieve Profile Chunks for an Organization", parameters=[ GlobalParams.ORG_ID_OR_SLUG, CHUNKS_PROJECT_PARAM, diff --git a/src/sentry/api/endpoints/organization_project_keys.py b/src/sentry/api/endpoints/organization_project_keys.py index 6f35048ed61282..7ad38e70b34877 100644 --- a/src/sentry/api/endpoints/organization_project_keys.py +++ b/src/sentry/api/endpoints/organization_project_keys.py @@ -41,7 +41,8 @@ class OrganizationProjectKeysEndpoint(OrganizationEndpoint): ) @extend_schema( - operation_id="List an Organization's Client Keys", + operation_id="listOrganizationProjectKeys", + summary="List an Organization's Client Keys", parameters=[ GlobalParams.ORG_ID_OR_SLUG, CursorQueryParam, diff --git a/src/sentry/api/endpoints/organization_relay_usage.py b/src/sentry/api/endpoints/organization_relay_usage.py index de3c4f5e7fffdc..8332a9dbdb836a 100644 --- a/src/sentry/api/endpoints/organization_relay_usage.py +++ b/src/sentry/api/endpoints/organization_relay_usage.py @@ -29,7 +29,8 @@ class OrganizationRelayUsage(OrganizationEndpoint): permission_classes = (OrganizationPermission,) @extend_schema( - operation_id="List an Organization's trusted Relays", + operation_id="listOrganizationRelayUsage", + summary="List an Organization's trusted Relays", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=None, responses={ diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 74610f19dca50a..d301f3efa50aec 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -357,7 +357,8 @@ def get_projects( ) @extend_schema( - operation_id="List an Organization's Releases", + operation_id="listOrganizationReleases", + summary="List an Organization's Releases", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.ENVIRONMENT, @@ -737,7 +738,8 @@ def qs_load_func(queryset, total_offset, qs_offset, limit): ) @extend_schema( - operation_id="Create a New Release for an Organization", + operation_id="createOrganizationRelease", + summary="Create a New Release for an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=ReleaseSerializerWithProjects, responses={ diff --git a/src/sentry/api/endpoints/organization_sessions.py b/src/sentry/api/endpoints/organization_sessions.py index 10a31e290cdc94..cd06d6530a4eaf 100644 --- a/src/sentry/api/endpoints/organization_sessions.py +++ b/src/sentry/api/endpoints/organization_sessions.py @@ -39,7 +39,8 @@ class OrganizationSessionsEndpoint(OrganizationEndpoint): owner = ApiOwner.TELEMETRY_EXPERIENCE @extend_schema( - operation_id="Retrieve Release Health Session Statistics", + operation_id="getOrganizationSessions", + summary="Retrieve Release Health Session Statistics", parameters=[ GlobalParams.START, GlobalParams.END, diff --git a/src/sentry/api/endpoints/organization_stats_summary.py b/src/sentry/api/endpoints/organization_stats_summary.py index a2816d97e3429f..80adea2809323a 100644 --- a/src/sentry/api/endpoints/organization_stats_summary.py +++ b/src/sentry/api/endpoints/organization_stats_summary.py @@ -129,7 +129,8 @@ class OrganizationStatsSummaryEndpoint(OrganizationEndpoint): owner = ApiOwner.DASHBOARDS @extend_schema( - operation_id="Retrieve an Organization's Events Count by Project", + operation_id="getOrganizationStatsSummary", + summary="Retrieve an Organization's Events Count by Project", parameters=[GlobalParams.ORG_ID_OR_SLUG, OrgStatsSummaryQueryParamsSerializer], request=None, responses={ diff --git a/src/sentry/api/endpoints/organization_stats_v2.py b/src/sentry/api/endpoints/organization_stats_v2.py index 4ba51ecc891ff4..9e454bc60ce12e 100644 --- a/src/sentry/api/endpoints/organization_stats_v2.py +++ b/src/sentry/api/endpoints/organization_stats_v2.py @@ -159,7 +159,8 @@ class OrganizationStatsEndpointV2(OrganizationEndpoint): permission_classes = (OrganizationAndStaffPermission,) @extend_schema( - operation_id="Retrieve Event Counts for an Organization (v2)", + operation_id="listOrganizationStatsV2", + summary="Retrieve Event Counts for an Organization (v2)", parameters=[GlobalParams.ORG_ID_OR_SLUG, OrgStatsQueryParamsSerializer], request=None, responses={ diff --git a/src/sentry/api/endpoints/organization_tags.py b/src/sentry/api/endpoints/organization_tags.py index 4658d5078176a3..a8b3fb29a21ce6 100644 --- a/src/sentry/api/endpoints/organization_tags.py +++ b/src/sentry/api/endpoints/organization_tags.py @@ -38,7 +38,8 @@ class OrganizationTagsEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING @extend_schema( - operation_id="List an Organization's Tags", + operation_id="listOrganizationTags", + summary="List an Organization's Tags", parameters=[ GlobalParams.ORG_ID_OR_SLUG, OrganizationParams.PROJECT, diff --git a/src/sentry/api/endpoints/organization_trace.py b/src/sentry/api/endpoints/organization_trace.py index 9fdc3b27f4dc3c..2346928b4f7b09 100644 --- a/src/sentry/api/endpoints/organization_trace.py +++ b/src/sentry/api/endpoints/organization_trace.py @@ -125,7 +125,8 @@ def query_trace_data( ) @extend_schema( - operation_id="Retrieve a Trace", + operation_id="getOrganizationTrace", + summary="Retrieve a Trace", parameters=[ GlobalParams.ORG_ID_OR_SLUG, TRACE_ID_PATH_PARAM, diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index d10cfdb1c02e2e..22766a25c6f0ae 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -353,7 +353,8 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd } @extend_schema( - operation_id="List Trace Item Attributes", + operation_id="listOrganizationTraceItemAttributes", + summary="List Trace Item Attributes", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.STATS_PERIOD, diff --git a/src/sentry/api/endpoints/organization_trace_meta.py b/src/sentry/api/endpoints/organization_trace_meta.py index 302f6bb15e1ac0..0a669b14a0864e 100644 --- a/src/sentry/api/endpoints/organization_trace_meta.py +++ b/src/sentry/api/endpoints/organization_trace_meta.py @@ -221,7 +221,8 @@ def query_meta_data( ) @extend_schema( - operation_id="Retrieve Trace Metadata", + operation_id="getOrganizationTraceMeta", + summary="Retrieve Trace Metadata", parameters=[ GlobalParams.ORG_ID_OR_SLUG, TRACE_ID_PATH_PARAM, diff --git a/src/sentry/api/endpoints/project_filter_details.py b/src/sentry/api/endpoints/project_filter_details.py index 7654bbdf7e4c66..c46e3a6370c75e 100644 --- a/src/sentry/api/endpoints/project_filter_details.py +++ b/src/sentry/api/endpoints/project_filter_details.py @@ -29,7 +29,8 @@ class ProjectFilterDetailsEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="Update an Inbound Data Filter", + operation_id="updateProjectFilter", + summary="Update an Inbound Data Filter", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/project_filters.py b/src/sentry/api/endpoints/project_filters.py index 197613aaa4c361..c678db1232e713 100644 --- a/src/sentry/api/endpoints/project_filters.py +++ b/src/sentry/api/endpoints/project_filters.py @@ -29,7 +29,8 @@ class ProjectFiltersEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="List a Project's Data Filters", + operation_id="listProjectFilters", + summary="List a Project's Data Filters", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/project_member_index.py b/src/sentry/api/endpoints/project_member_index.py index ebf617942dc83f..27a2e112126032 100644 --- a/src/sentry/api/endpoints/project_member_index.py +++ b/src/sentry/api/endpoints/project_member_index.py @@ -25,7 +25,8 @@ class ProjectMemberIndexEndpoint(ProjectEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List a Project's Organization Members", + operation_id="listProjectMembers", + summary="List a Project's Organization Members", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], request=None, responses={ diff --git a/src/sentry/api/endpoints/project_profiling_profile.py b/src/sentry/api/endpoints/project_profiling_profile.py index 03009b426fab46..33ba5a7c279ff2 100644 --- a/src/sentry/api/endpoints/project_profiling_profile.py +++ b/src/sentry/api/endpoints/project_profiling_profile.py @@ -44,7 +44,8 @@ class ProjectProfilingProfileEndpoint(ProjectProfilingBaseEndpoint): } @extend_schema( - operation_id="Retrieve a Profile", + operation_id="getProjectProfilingProfile", + summary="Retrieve a Profile", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/project_repo.py b/src/sentry/api/endpoints/project_repo.py index 86589a0d1bba06..4be1cdf629837d 100644 --- a/src/sentry/api/endpoints/project_repo.py +++ b/src/sentry/api/endpoints/project_repo.py @@ -68,7 +68,8 @@ class ProjectRepoEndpoint(ProjectEndpoint): permission_classes = (ProjectPermission,) @extend_schema( - operation_id="Link a Repository to a Project", + operation_id="linkProjectRepository", + summary="Link a Repository to a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/project_servicehooks.py b/src/sentry/api/endpoints/project_servicehooks.py index 7e5d007d0e60cc..044ed446f10dd7 100644 --- a/src/sentry/api/endpoints/project_servicehooks.py +++ b/src/sentry/api/endpoints/project_servicehooks.py @@ -42,7 +42,8 @@ def has_feature(self, request: Request, project): return features.has("projects:servicehooks", project=project, actor=request.user) @extend_schema( - operation_id="List a Project's Service Hooks", + operation_id="listProjectHooks", + summary="List a Project's Service Hooks", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -89,7 +90,8 @@ def get(self, request: Request, project) -> Response[list[ServiceHookSerializerR ) @extend_schema( - operation_id="Register a New Service Hook", + operation_id="registerProjectServiceHook", + summary="Register a New Service Hook", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], request=ServiceHookValidator, responses={ diff --git a/src/sentry/api/endpoints/project_symbol_sources.py b/src/sentry/api/endpoints/project_symbol_sources.py index 2cc048ce3de2b4..95de33e3cf4079 100644 --- a/src/sentry/api/endpoints/project_symbol_sources.py +++ b/src/sentry/api/endpoints/project_symbol_sources.py @@ -258,7 +258,8 @@ class ProjectSymbolSourcesEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="Retrieve a Project's Symbol Sources", + operation_id="listProjectSymbolSources", + summary="Retrieve a Project's Symbol Sources", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -294,7 +295,8 @@ def get( return Response(redacted) @extend_schema( - operation_id="Delete a Symbol Source from a Project", + operation_id="deleteProjectSymbolSource", + summary="Delete a Symbol Source from a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -330,7 +332,8 @@ def delete( return Response(data={"error": "Missing source id"}, status=404) @extend_schema( - operation_id="Add a Symbol Source to a Project", + operation_id="addProjectSymbolSource", + summary="Add a Symbol Source to a Project", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], request=SourceSerializer, responses={ @@ -369,7 +372,8 @@ def post(self, request: Request, project: Project) -> Response[_SymbolSource] | return Response(data=redacted_single[0], status=201) @extend_schema( - operation_id="Update a Project's Symbol Source", + operation_id="updateProjectSymbolSource", + summary="Update a Project's Symbol Source", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/project_tagkey_values.py b/src/sentry/api/endpoints/project_tagkey_values.py index 5031d37f5792a0..34b9e82b6bc7d9 100644 --- a/src/sentry/api/endpoints/project_tagkey_values.py +++ b/src/sentry/api/endpoints/project_tagkey_values.py @@ -40,7 +40,8 @@ class ProjectTagKeyValuesEndpoint(ProjectEndpoint): ) @extend_schema( - operation_id="List a Tag's Values", + operation_id="listProjectTagValues", + summary="List a Tag's Values", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py index e04421aec00311..102ef73746eeea 100644 --- a/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py +++ b/src/sentry/api/endpoints/release_thresholds/release_threshold_status_index.py @@ -119,7 +119,8 @@ class ReleaseThresholdStatusIndexEndpoint(OrganizationReleasesBaseEndpoint): } @extend_schema( - operation_id="Retrieve Statuses of Release Thresholds (Alpha)", + operation_id="listOrganizationReleaseThresholdStatuses", + summary="Retrieve Statuses of Release Thresholds (Alpha)", parameters=[GlobalParams.ORG_ID_OR_SLUG, ReleaseThresholdStatusIndexSerializer], request=None, responses={ diff --git a/src/sentry/api/endpoints/seer_models.py b/src/sentry/api/endpoints/seer_models.py index a2258a528a6433..9ced0a63761dd1 100644 --- a/src/sentry/api/endpoints/seer_models.py +++ b/src/sentry/api/endpoints/seer_models.py @@ -60,7 +60,8 @@ class SeerModelsEndpoint(Endpoint): ) @extend_schema( - operation_id="List Seer AI Models", + operation_id="listSeerModels", + summary="List Seer AI Models", responses={ 200: inline_sentry_response_serializer("SeerModelsResponse", SeerModelsResponse), }, diff --git a/src/sentry/api/endpoints/source_map_debug.py b/src/sentry/api/endpoints/source_map_debug.py index a5c02110e8c32b..7794d3e8e3f143 100644 --- a/src/sentry/api/endpoints/source_map_debug.py +++ b/src/sentry/api/endpoints/source_map_debug.py @@ -137,7 +137,8 @@ class SourceMapDebugEndpoint(ProjectEndpoint): owner = ApiOwner.WEB_FRONTEND_SDKS @extend_schema( - operation_id="Get Debug Information Related to Source Maps for a Given Event", + operation_id="getProjectEventSourceMapDebug", + summary="Get Debug Information Related to Source Maps for a Given Event", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/apidocs/hooks.py b/src/sentry/apidocs/hooks.py index 123023581ddf46..9397d7105af70d 100644 --- a/src/sentry/apidocs/hooks.py +++ b/src/sentry/apidocs/hooks.py @@ -232,10 +232,24 @@ def custom_postprocessing_hook(result: Any, generator: Any, **kwargs: Any) -> An # Fetch schema component references schema_components = result["components"]["schemas"] + # The API docs derive each page's URL slug from its summary, so two public + # operations sharing a summary would collide on the same docs URL. + summaries_seen: dict[str, str] = {} + for path, endpoints in result["paths"].items(): for method_info in endpoints.values(): endpoint_name = f"'{method_info['operationId']}'" + summary = method_info.get("summary") + if summary is not None: + if summary in summaries_seen: + raise SentryApiBuildError( + f"Duplicate summary {summary!r} on endpoints {summaries_seen[summary]} " + f"and {endpoint_name}. Summaries must be unique because the API docs " + "derive each page's URL from them." + ) + summaries_seen[summary] = endpoint_name + _check_tag(method_info, endpoint_name) _check_description( method_info, diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 3202eb95ee49e4..84b2147445d253 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -1081,7 +1081,8 @@ class OrganizationDetailsEndpoint(OrganizationEndpoint): } @extend_schema( - operation_id="Retrieve an Organization", + operation_id="getOrganization", + summary="Retrieve an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG, OrganizationParams.DETAILED], request=None, responses={ @@ -1126,7 +1127,8 @@ def get( return self.respond(context) @extend_schema( - operation_id="Update an Organization", + operation_id="updateOrganization", + summary="Update an Organization", parameters=[ GlobalParams.ORG_ID_OR_SLUG, ], diff --git a/src/sentry/core/endpoints/organization_environments.py b/src/sentry/core/endpoints/organization_environments.py index aea0a1c19cae2f..5a563bbdc1bcec 100644 --- a/src/sentry/core/endpoints/organization_environments.py +++ b/src/sentry/core/endpoints/organization_environments.py @@ -30,7 +30,8 @@ class OrganizationEnvironmentsEndpoint(OrganizationEndpoint): } @extend_schema( - operation_id="List an Organization's Environments", + operation_id="listOrganizationEnvironments", + summary="List an Organization's Environments", parameters=[GlobalParams.ORG_ID_OR_SLUG, EnvironmentParams.VISIBILITY], responses={ 200: inline_sentry_response_serializer( diff --git a/src/sentry/core/endpoints/organization_index.py b/src/sentry/core/endpoints/organization_index.py index 19614cc56d044b..da3cdc88b17028 100644 --- a/src/sentry/core/endpoints/organization_index.py +++ b/src/sentry/core/endpoints/organization_index.py @@ -125,7 +125,8 @@ class OrganizationIndexEndpoint(Endpoint): permission_classes = (OrganizationPermission,) @extend_schema( - operation_id="List Your Organizations", + operation_id="listOrganizations", + summary="List Your Organizations", parameters=[ OrganizationParams.OWNER, CursorQueryParam, diff --git a/src/sentry/core/endpoints/organization_member_details.py b/src/sentry/core/endpoints/organization_member_details.py index d59fa694299450..4ee3651f75f240 100644 --- a/src/sentry/core/endpoints/organization_member_details.py +++ b/src/sentry/core/endpoints/organization_member_details.py @@ -111,7 +111,8 @@ def _get_member( raise OrganizationMember.DoesNotExist() @extend_schema( - operation_id="Retrieve an Organization Member", + operation_id="getOrganizationMember", + summary="Retrieve an Organization Member", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the organization member."), @@ -144,7 +145,8 @@ def get( return Response(body) @extend_schema( - operation_id="Update an Organization Member's Roles", + operation_id="updateOrganizationMember", + summary="Update an Organization Member's Roles", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to update."), @@ -456,7 +458,8 @@ def _handle_deletion_by_member( return Response(status=204) @extend_schema( - operation_id="Delete an Organization Member", + operation_id="deleteOrganizationMember", + summary="Delete an Organization Member", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to delete."), diff --git a/src/sentry/core/endpoints/organization_member_index.py b/src/sentry/core/endpoints/organization_member_index.py index 006f58a17f3990..7fb80050246436 100644 --- a/src/sentry/core/endpoints/organization_member_index.py +++ b/src/sentry/core/endpoints/organization_member_index.py @@ -193,7 +193,8 @@ class OrganizationMemberIndexEndpoint(OrganizationEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List an Organization's Members", + operation_id="listOrganizationMembers", + summary="List an Organization's Members", parameters=[ GlobalParams.ORG_ID_OR_SLUG, CursorQueryParam, @@ -318,7 +319,8 @@ def get( ) @extend_schema( - operation_id="Add a Member to an Organization", + operation_id="addOrganizationMember", + summary="Add a Member to an Organization", parameters=[ GlobalParams.ORG_ID_OR_SLUG, ], diff --git a/src/sentry/core/endpoints/organization_member_team_details.py b/src/sentry/core/endpoints/organization_member_team_details.py index f420685e0a87b5..bea03a133f1368 100644 --- a/src/sentry/core/endpoints/organization_member_team_details.py +++ b/src/sentry/core/endpoints/organization_member_team_details.py @@ -234,7 +234,8 @@ def get( ) @extend_schema( - operation_id="Add an Organization Member to a Team", + operation_id="addOrganizationMemberTeam", + summary="Add an Organization Member to a Team", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the organization member to add to the team"), @@ -368,7 +369,8 @@ def post( return Response(body, status=201) @extend_schema( - operation_id="Update an Organization Member's Team Role", + operation_id="updateOrganizationMemberTeam", + summary="Update an Organization Member's Team Role", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the organization member to change"), @@ -475,7 +477,8 @@ def _change_team_member_role( ) @extend_schema( - operation_id="Delete an Organization Member from a Team", + operation_id="deleteOrganizationMemberTeam", + summary="Delete an Organization Member from a Team", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the organization member to delete from the team"), diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 8a267d615aeb79..e0f0b62022f42c 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -130,7 +130,8 @@ class OrganizationProjectsEndpoint(OrganizationEndpoint): logger = logging.getLogger("team-project.create") @extend_schema( - operation_id="List an Organization's Projects", + operation_id="listOrganizationProjects", + summary="List an Organization's Projects", parameters=[ GlobalParams.ORG_ID_OR_SLUG, CursorQueryParam, @@ -291,7 +292,8 @@ def should_add_creator_to_team(self, user: User | AnonymousUser) -> TypeIs[User] @extend_schema( tags=["Projects"], - operation_id="Create a Project for an Organization", + operation_id="createOrganizationProject", + summary="Create a Project for an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=ProjectPostSerializer, responses={ diff --git a/src/sentry/core/endpoints/organization_teams.py b/src/sentry/core/endpoints/organization_teams.py index f60da6e42932fc..ce8c74bc4c55d5 100644 --- a/src/sentry/core/endpoints/organization_teams.py +++ b/src/sentry/core/endpoints/organization_teams.py @@ -86,7 +86,8 @@ def team_serializer_for_post(self): return TeamSerializer() @extend_schema( - operation_id="List an Organization's Teams", + operation_id="listOrganizationTeams", + summary="List an Organization's Teams", parameters=[ GlobalParams.ORG_ID_OR_SLUG, TeamParams.DETAILED, @@ -173,7 +174,8 @@ def should_add_creator_to_team(self, request: Request): return request.user.is_authenticated @extend_schema( - operation_id="Create a New Team", + operation_id="createOrganizationTeam", + summary="Create a New Team", parameters=[ GlobalParams.ORG_ID_OR_SLUG, ], diff --git a/src/sentry/core/endpoints/organization_user_teams.py b/src/sentry/core/endpoints/organization_user_teams.py index 7215ff87cce737..719d7c0d8859f0 100644 --- a/src/sentry/core/endpoints/organization_user_teams.py +++ b/src/sentry/core/endpoints/organization_user_teams.py @@ -29,7 +29,8 @@ class OrganizationUserTeamsEndpoint(OrganizationEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List a User's Teams for an Organization", + operation_id="listOrganizationUserTeams", + summary="List a User's Teams for an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=None, responses={ diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index aea9709f20ec37..2a4584af83d8ca 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -547,7 +547,8 @@ def _get_unresolved_count(self, project): return queryset.count() @extend_schema( - operation_id="Retrieve a Project", + operation_id="getProject", + summary="Retrieve a Project", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], request=None, responses={ @@ -596,7 +597,8 @@ def get( return Response(data) @extend_schema( - operation_id="Update a Project", + operation_id="updateProject", + summary="Update a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -1188,7 +1190,8 @@ def put( return Response(body) @extend_schema( - operation_id="Delete a Project", + operation_id="deleteProject", + summary="Delete a Project", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], responses={ 204: RESPONSE_NO_CONTENT, diff --git a/src/sentry/core/endpoints/project_environment_details.py b/src/sentry/core/endpoints/project_environment_details.py index 62d9f1ceb91f8c..f615de123b46a4 100644 --- a/src/sentry/core/endpoints/project_environment_details.py +++ b/src/sentry/core/endpoints/project_environment_details.py @@ -33,7 +33,8 @@ class ProjectEnvironmentDetailsEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="Retrieve a Project Environment", + operation_id="getProjectEnvironment", + summary="Retrieve a Project Environment", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -65,7 +66,8 @@ def get( return Response(body) @extend_schema( - operation_id="Update a Project Environment", + operation_id="updateProjectEnvironment", + summary="Update a Project Environment", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_environments.py b/src/sentry/core/endpoints/project_environments.py index 574654c8e618f6..a386ca15f506eb 100644 --- a/src/sentry/core/endpoints/project_environments.py +++ b/src/sentry/core/endpoints/project_environments.py @@ -40,7 +40,8 @@ class ProjectEnvironmentsEndpoint(ProjectEndpoint): } @extend_schema( - operation_id="List a Project's Environments", + operation_id="listProjectEnvironments", + summary="List a Project's Environments", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_key_details.py b/src/sentry/core/endpoints/project_key_details.py index b259202d6d1c1e..95cf6fb6956916 100644 --- a/src/sentry/core/endpoints/project_key_details.py +++ b/src/sentry/core/endpoints/project_key_details.py @@ -41,7 +41,8 @@ class ProjectKeyDetailsEndpoint(ProjectKeyEndpoint): } @extend_schema( - operation_id="Retrieve a Client Key", + operation_id="getProjectKey", + summary="Retrieve a Client Key", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -65,7 +66,8 @@ def get( return Response(body, status=200) @extend_schema( - operation_id="Update a Client Key", + operation_id="updateProjectKey", + summary="Update a Client Key", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -174,7 +176,8 @@ def put( return Response(body, status=200) @extend_schema( - operation_id="Delete a Client Key", + operation_id="deleteProjectKey", + summary="Delete a Client Key", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_keys.py b/src/sentry/core/endpoints/project_keys.py index aa868ec73a0659..d9d9d673b4a28b 100644 --- a/src/sentry/core/endpoints/project_keys.py +++ b/src/sentry/core/endpoints/project_keys.py @@ -50,7 +50,8 @@ class ProjectKeysEndpoint(ProjectEndpoint): ) @extend_schema( - operation_id="List a Project's Client Keys", + operation_id="listProjectKeys", + summary="List a Project's Client Keys", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -90,7 +91,8 @@ def get(self, request: Request, project) -> Response[list[ProjectKeySerializerRe ) @extend_schema( - operation_id="Create a New Client Key", + operation_id="createProjectKey", + summary="Create a New Client Key", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_stats.py b/src/sentry/core/endpoints/project_stats.py index 55e270ac8560b9..d669313ad1006e 100644 --- a/src/sentry/core/endpoints/project_stats.py +++ b/src/sentry/core/endpoints/project_stats.py @@ -67,7 +67,8 @@ class ProjectStatsEndpoint(ProjectEndpoint, StatsMixin): ) @extend_schema( - operation_id="Retrieve Event Counts for a Project", + operation_id="listProjectStats", + summary="Retrieve Event Counts for a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_team_details.py b/src/sentry/core/endpoints/project_team_details.py index 659cae43962524..4c95c99a2fad51 100644 --- a/src/sentry/core/endpoints/project_team_details.py +++ b/src/sentry/core/endpoints/project_team_details.py @@ -66,7 +66,8 @@ def convert_args( return (args, kwargs) @extend_schema( - operation_id="Add a Team to a Project", + operation_id="addProjectTeam", + summary="Add a Team to a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, @@ -100,7 +101,8 @@ def post(self, request: Request, project, team: Team) -> Response[ProjectWithTea return Response(body, status=201) @extend_schema( - operation_id="Delete a Team from a Project", + operation_id="deleteProjectTeam", + summary="Delete a Team from a Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/project_teams.py b/src/sentry/core/endpoints/project_teams.py index 4a9f49bb41f125..485e4691bdcca9 100644 --- a/src/sentry/core/endpoints/project_teams.py +++ b/src/sentry/core/endpoints/project_teams.py @@ -25,7 +25,8 @@ class ProjectTeamsEndpoint(ProjectEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List a Project's Teams", + operation_id="listProjectTeams", + summary="List a Project's Teams", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, CursorQueryParam], request=None, responses={ diff --git a/src/sentry/core/endpoints/project_users.py b/src/sentry/core/endpoints/project_users.py index dad08ea834f7d6..3e14793583571c 100644 --- a/src/sentry/core/endpoints/project_users.py +++ b/src/sentry/core/endpoints/project_users.py @@ -51,7 +51,8 @@ class ProjectUsersEndpoint(ProjectEndpoint): permission_classes = (ProjectAndStaffPermission,) @extend_schema( - operation_id="List a Project's Users", + operation_id="listProjectUsers", + summary="List a Project's Users", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/scim/members.py b/src/sentry/core/endpoints/scim/members.py index cb74e0fc300745..f1a34fca8904ab 100644 --- a/src/sentry/core/endpoints/scim/members.py +++ b/src/sentry/core/endpoints/scim/members.py @@ -247,7 +247,8 @@ def _should_delete_member(self, operation): return False @extend_schema( - operation_id="Query an Individual Organization Member", + operation_id="getOrganizationScimV2User", + summary="Query an Individual Organization Member", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to query."), @@ -276,7 +277,8 @@ def get( return Response(body) @extend_schema( - operation_id="Update an Organization Member's Attributes", + operation_id="updateOrganizationScimV2User", + summary="Update an Organization Member's Attributes", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to update."), @@ -334,7 +336,8 @@ def patch( return Response(body) @extend_schema( - operation_id="Delete an Organization Member via SCIM", + operation_id="deleteOrganizationScimV2User", + summary="Delete an Organization Member via SCIM", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to delete."), @@ -365,7 +368,8 @@ def delete( return Response(status=204) @extend_schema( - operation_id="Update an Organization Member's Attributes", + operation_id="replaceOrganizationScimV2User", + summary="Replace an Organization Member's Attributes", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.member_id("The ID of the member to update."), @@ -485,7 +489,8 @@ class OrganizationSCIMMemberIndex(SCIMEndpoint): permission_classes = (OrganizationSCIMMemberPermission,) @extend_schema( - operation_id="List an Organization's SCIM Members", + operation_id="listOrganizationScimV2Users", + summary="List an Organization's SCIM Members", parameters=[GlobalParams.ORG_ID_OR_SLUG, SCIMQueryParamSerializer], responses={ 200: inline_sentry_response_serializer( @@ -544,7 +549,8 @@ def on_results(results): ) @extend_schema( - operation_id="Provision a New Organization Member", + operation_id="provisionOrganizationScimV2User", + summary="Provision a New Organization Member", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=inline_serializer( name="SCIMMemberProvision", diff --git a/src/sentry/core/endpoints/scim/teams.py b/src/sentry/core/endpoints/scim/teams.py index 6ec1bb974121da..626425fee884b6 100644 --- a/src/sentry/core/endpoints/scim/teams.py +++ b/src/sentry/core/endpoints/scim/teams.py @@ -186,7 +186,8 @@ class OrganizationSCIMTeamIndex(SCIMEndpoint): permission_classes = (OrganizationSCIMTeamPermission,) @extend_schema( - operation_id="List an Organization's Paginated Teams", + operation_id="listOrganizationScimV2Groups", + summary="List an Organization's Paginated Teams", parameters=[GlobalParams.ORG_ID_OR_SLUG, SCIMQueryParamSerializer], request=None, responses={ @@ -235,7 +236,8 @@ def on_results(results): ) @extend_schema( - operation_id="Provision a New Team", + operation_id="provisionOrganizationScimV2Group", + summary="Provision a New Team", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=inline_serializer( name="SCIMTeamRequestBody", @@ -350,7 +352,8 @@ def _get_team(self, organization, team_id_or_slug): return team @extend_schema( - operation_id="Query an Individual Team", + operation_id="getOrganizationScimV2Group", + summary="Query an Individual Team", parameters=[GlobalParams.TEAM_ID_OR_SLUG, GlobalParams.ORG_ID_OR_SLUG], request=None, responses={ @@ -548,7 +551,8 @@ def _handle_replace_patch_op( return None @extend_schema( - operation_id="Update a Team's Attributes", + operation_id="updateOrganizationScimV2Group", + summary="Update a Team's Attributes", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG], request=SCIMTeamPatchRequestSerializer, responses={ @@ -641,7 +645,8 @@ def patch( return self.respond(status=204) @extend_schema( - operation_id="Delete an Individual Team", + operation_id="deleteOrganizationScimV2Group", + summary="Delete an Individual Team", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG], responses={ 204: RESPONSE_SUCCESS, diff --git a/src/sentry/core/endpoints/team_details.py b/src/sentry/core/endpoints/team_details.py index 1c491166a0efc1..bc2743aadc2152 100644 --- a/src/sentry/core/endpoints/team_details.py +++ b/src/sentry/core/endpoints/team_details.py @@ -72,7 +72,8 @@ def can_modify_idp_team(self, team: Team): return self._allow_idp_changes @extend_schema( - operation_id="Retrieve a Team", + operation_id="getTeam", + summary="Retrieve a Team", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG, @@ -106,7 +107,8 @@ def get(self, request: Request, team) -> Response[TeamSerializerResponse]: return Response(body) @extend_schema( - operation_id="Update a Team", + operation_id="updateTeam", + summary="Update a Team", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG], request=TeamDetailsSerializer, responses={ @@ -154,7 +156,8 @@ def put( return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) @extend_schema( - operation_id="Delete a Team", + operation_id="deleteTeam", + summary="Delete a Team", parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG], responses={ 204: RESPONSE_NO_CONTENT, diff --git a/src/sentry/core/endpoints/team_members.py b/src/sentry/core/endpoints/team_members.py index 4f1f9c0b4e058c..2bf36f69885d37 100644 --- a/src/sentry/core/endpoints/team_members.py +++ b/src/sentry/core/endpoints/team_members.py @@ -68,7 +68,8 @@ class TeamMembersEndpoint(TeamEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List a Team's Members", + operation_id="listTeamMembers", + summary="List a Team's Members", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG, diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index 0107e0c95c5abe..1159f991a6e534 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -152,7 +152,8 @@ class TeamProjectsEndpoint(TeamEndpoint): owner = ApiOwner.FOUNDATIONS @extend_schema( - operation_id="List a Team's Projects", + operation_id="listTeamProjects", + summary="List a Team's Projects", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG, @@ -211,7 +212,8 @@ def get( @extend_schema( # Ensure POST is in the projects tab tags=["Projects"], - operation_id="Create a New Project", + operation_id="createTeamProject", + summary="Create a New Project", parameters=[ GlobalParams.ORG_ID_OR_SLUG, GlobalParams.TEAM_ID_OR_SLUG, diff --git a/tests/apidocs/test_hooks.py b/tests/apidocs/test_hooks.py index f6e288a3507488..93c1afa7dee81a 100644 --- a/tests/apidocs/test_hooks.py +++ b/tests/apidocs/test_hooks.py @@ -1,6 +1,10 @@ +from typing import Any from unittest import TestCase +import pytest + from sentry.apidocs.hooks import _ENDPOINT_SERVERS, custom_postprocessing_hook +from sentry.apidocs.utils import SentryApiBuildError class EndpointServersTest(TestCase): @@ -46,6 +50,39 @@ def test_servers_applied_to_endpoint(self) -> None: assert "servers" not in processed["paths"]["/api/0/other/endpoint/"]["get"] +class SummaryUniquenessTest(TestCase): + def _operation(self, summary: str) -> dict[str, Any]: + return { + "tags": ["Events"], + "description": "An endpoint", + "operationId": summary.lower().replace(" ", "-"), + "summary": summary, + "parameters": [], + } + + def test_duplicate_summary_raises(self) -> None: + result = { + "components": {"schemas": {}}, + "paths": { + "/api/0/foo/": {"get": self._operation("List Foos")}, + "/api/0/bar/": {"get": self._operation("List Foos")}, + }, + } + with pytest.raises(SentryApiBuildError): + custom_postprocessing_hook(result, None) + + def test_unique_summaries_pass(self) -> None: + result = { + "components": {"schemas": {}}, + "paths": { + "/api/0/foo/": {"get": self._operation("List Foos")}, + "/api/0/bar/": {"get": self._operation("List Bars")}, + }, + } + # Should not raise. + custom_postprocessing_hook(result, None) + + class FixIssueRoutesTest(TestCase): def test_issue_route_fixes(self) -> None: BEFORE = { diff --git a/tests/apidocs/test_operation_id_uniqueness.py b/tests/apidocs/test_operation_id_uniqueness.py new file mode 100644 index 00000000000000..02953ffac27d0c --- /dev/null +++ b/tests/apidocs/test_operation_id_uniqueness.py @@ -0,0 +1,40 @@ +"""Guards against duplicate ``operation_id`` values across ``@extend_schema`` decorators. + +Two operations sharing an ``operation_id`` produce an invalid OpenAPI document and +duplicate SDK function names. drf-spectacular's ``--fail-on-warn`` build only sees PUBLIC +operations, so it never catches a clash that involves a non-public method (e.g. a PUT/PATCH +pair on the same endpoint where only one is public). This test scans the source instead, so +it covers every ``@extend_schema`` regardless of publish status. + +(Summary uniqueness — which guards against docs-URL collisions — is enforced separately in +``custom_postprocessing_hook``, where each operation's real summary is directly available.) +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from pathlib import Path + +SENTRY_SRC = Path(__file__).resolve().parents[2] / "src" / "sentry" + +# operation_id="..." appears only as an @extend_schema kwarg, so a literal scan is safe. +# Match either quote style so apostrophes inside double-quoted, sentence-style values +# (e.g. "List a Project's Tags") aren't truncated. +_OPERATION_ID = re.compile(r"""operation_id=(?:"([^"]*)"|'([^']*)')""") + + +def test_operation_ids_are_unique() -> None: + locations: dict[str, list[str]] = defaultdict(list) + for path in SENTRY_SRC.rglob("*.py"): + with path.open(encoding="utf-8") as f: + for lineno, line in enumerate(f, start=1): + for double, single in _OPERATION_ID.findall(line): + value = double or single + rel = path.relative_to(SENTRY_SRC) + locations[value].append(f"src/sentry/{rel}:{lineno}") + + dups = {value: locs for value, locs in locations.items() if len(locs) > 1} + assert not dups, "Duplicate @extend_schema operation_id values:\n" + "\n".join( + f" {value!r}: {', '.join(locs)}" for value, locs in sorted(dups.items()) + )