diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f304f57d164524..72a22d5a22a6e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,7 +100,7 @@ repos: - id: format name: format language: system - types_or: [yaml, ts, tsx, javascript, jsx, css, mdx, markdown] + types_or: [yaml, ts, tsx, javascript, jsx, css, mdx, markdown, json] entry: ./node_modules/.bin/oxfmt - id: knip diff --git a/eslint.config.ts b/eslint.config.ts index d632d8b2b6da84..5dfce125646ecf 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -310,8 +310,6 @@ export default typescript.config([ rules: { 'array-callback-return': 'error', 'block-scoped-var': 'error', - 'consistent-return': 'error', - 'default-case': 'error', 'dot-notation': 'error', eqeqeq: 'error', 'guard-for-in': 'off', // TODO(ryan953): Fix violations and enable this rule @@ -604,6 +602,10 @@ export default typescript.config([ '@typescript-eslint/no-for-in-array': 'error', '@typescript-eslint/no-unnecessary-template-expression': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/switch-exhaustiveness-check': [ + 'error', + {considerDefaultExhaustiveForUnions: true}, + ], '@typescript-eslint/only-throw-error': 'error', '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/prefer-promise-reject-errors': 'error', diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 778a82c4a0744d..f1d2336b29497c 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1057_drop_legacy_alert_rule_tables +sentry: 1058_change_code_mapping_unique_constraint social_auth: 0003_social_auth_json_field diff --git a/package.json b/package.json index 0a99a6193c5309..5067ac389d35c5 100644 --- a/package.json +++ b/package.json @@ -123,8 +123,8 @@ "@swc/plugin-emotion": "14.3.0", "@tanstack/query-async-storage-persister": "5.83.1", "@tanstack/react-devtools": "0.9.9", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-form-devtools": "0.2.17", + "@tanstack/react-form": "1.28.6", + "@tanstack/react-form-devtools": "0.2.20", "@tanstack/react-pacer": "^0.17.0", "@tanstack/react-pacer-devtools": "0.5.3", "@tanstack/react-query": "5.85.0", @@ -264,8 +264,8 @@ "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "6.1.0", - "eslint-plugin-regexp": "^3.0.0", "eslint-plugin-react-you-might-not-need-an-effect": "0.5.3", + "eslint-plugin-regexp": "^3.0.0", "eslint-plugin-sentry": "^2.10.0", "eslint-plugin-testing-library": "^7.16.0", "eslint-plugin-typescript-sort-keys": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb102858f13f2f..b2383794ae1590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,11 +232,11 @@ importers: specifier: 0.9.9 version: 0.9.9(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.11) '@tanstack/react-form': - specifier: ^1.28.0 - version: 1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 1.28.6 + version: 1.28.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-form-devtools': - specifier: 0.2.17 - version: 0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) + specifier: 0.2.20 + version: 0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) '@tanstack/react-pacer': specifier: ^0.17.0 version: 0.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3613,10 +3613,6 @@ packages: resolution: {integrity: sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==} engines: {node: '>=18'} - '@tanstack/devtools-event-client@0.4.0': - resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} - engines: {node: '>=18'} - '@tanstack/devtools-event-client@0.4.1': resolution: {integrity: sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg==} engines: {node: '>=18'} @@ -3633,6 +3629,12 @@ packages: peerDependencies: solid-js: '>=1.9.7' + '@tanstack/devtools-ui@0.5.1': + resolution: {integrity: sha512-T9JjAdqMSnxsVO6AQykD5vhxPF4iFLKtbYxee/bU3OLlk446F5C1220GdCmhDSz7y4lx+m8AvIS0bq6zzvdDUA==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/devtools-utils@0.3.0': resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==} engines: {node: '>=18'} @@ -3654,6 +3656,28 @@ packages: vue: optional: true + '@tanstack/devtools-utils@0.4.0': + resolution: {integrity: sha512-KsGzYhA8L/fCNgyyMyoUy+TKtx+DjNbzWwqH6wXL48Llzo7kvV9RynYJlaO8Qkzwm+NdHXSgsljQNjQ3CKPpZA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@types/react': '>=17.0.0' + preact: '>=10.0.0' + react: '>=17.0.0' + solid-js: '>=1.9.7' + vue: '>=3.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + preact: + optional: true + react: + optional: true + solid-js: + optional: true + vue: + optional: true + '@tanstack/devtools@0.10.10': resolution: {integrity: sha512-/SSJcyhZtq1+HB9UViz8e0Y7Io4JPIbyJ0Wns5ENwzSHsNOAheANA8QnEQBVXETY/osCcaGAVOyVfGgn5aBJKA==} engines: {node: '>=18'} @@ -3665,14 +3689,11 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 - '@tanstack/form-core@1.28.0': - resolution: {integrity: sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA==} - - '@tanstack/form-core@1.28.4': - resolution: {integrity: sha512-2eox5ePrJ6kvA1DXD5QHk/GeGr3VFZ0uYR63UgQOe7bUg6h1JfXaIMqTjZK9sdGyE4oRNqFpoW54H0pZM7nObQ==} + '@tanstack/form-core@1.28.6': + resolution: {integrity: sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==} - '@tanstack/form-devtools@0.2.17': - resolution: {integrity: sha512-1i+hAmhbyOm4lJOoQWvDA41bHFFyeSjA79kHxirU2FCSGWk58u1+eyvw6+dUweWfJLW2yTFU9VyQBbFSbG0qig==} + '@tanstack/form-devtools@0.2.20': + resolution: {integrity: sha512-4cW/eU5DBTrWP53mxwHKp4NQWTIQ3XCA91pMWK7dFNNClIwFnxoSJoKwyUa6b8kRIO6uq1Sjk2mhkAtj5kB22A==} peerDependencies: solid-js: '>=1.9.9' @@ -3711,13 +3732,13 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form-devtools@0.2.17': - resolution: {integrity: sha512-0asnrx9xBRuHptFh6hOB6sl1PrPb4gmjxHU/25L+lnNc0+OLgP13t3+CpC8qS95mdg2HJ42wieG1SvZTsuj0Nw==} + '@tanstack/react-form-devtools@0.2.20': + resolution: {integrity: sha512-aXtorJ7p3TbzOapjaxbjGX/c0uQh/wbYSwgzFt3qatNMb1xL4HM/j00Bx7hDENZNBCf8MF8YEEtvpBmnGb4rnQ==} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-form@1.28.0': - resolution: {integrity: sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg==} + '@tanstack/react-form@1.28.6': + resolution: {integrity: sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3764,8 +3785,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3784,15 +3805,15 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/store@0.8.0': - resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/store@0.8.1': resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.13.6': resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} @@ -13314,8 +13335,6 @@ snapshots: '@tanstack/devtools-event-client@0.3.4': {} - '@tanstack/devtools-event-client@0.4.0': {} - '@tanstack/devtools-event-client@0.4.1': {} '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.11)': @@ -13335,6 +13354,15 @@ snapshots: transitivePeerDependencies: - csstype + '@tanstack/devtools-ui@0.5.1(csstype@3.2.3)(solid-js@1.9.11)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.19 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.11 + transitivePeerDependencies: + - csstype + '@tanstack/devtools-utils@0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) @@ -13345,6 +13373,12 @@ snapshots: transitivePeerDependencies: - csstype + '@tanstack/devtools-utils@0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11)': + optionalDependencies: + '@types/react': 19.2.1 + react: 19.2.3 + solid-js: 1.9.11 + '@tanstack/devtools@0.10.10(csstype@3.2.3)(solid-js@1.9.11)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) @@ -13369,23 +13403,17 @@ snapshots: - supports-color - typescript - '@tanstack/form-core@1.28.0': - dependencies: - '@tanstack/devtools-event-client': 0.4.0 - '@tanstack/pacer-lite': 0.1.1 - '@tanstack/store': 0.7.7 - - '@tanstack/form-core@1.28.4': + '@tanstack/form-core@1.28.6': dependencies: - '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/devtools-event-client': 0.4.1 '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.9.1 - '@tanstack/form-devtools@0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': + '@tanstack/form-devtools@0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) - '@tanstack/form-core': 1.28.4 + '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3)(solid-js@1.9.11) + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/form-core': 1.28.6 clsx: 2.1.1 dayjs: 1.11.19 goober: 2.1.18(csstype@3.2.3) @@ -13446,10 +13474,10 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form-devtools@0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': + '@tanstack/react-form-devtools@0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) - '@tanstack/form-devtools': 0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/form-devtools': 0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) react: 19.2.3 transitivePeerDependencies: - '@types/react' @@ -13458,10 +13486,10 @@ snapshots: - solid-js - vue - '@tanstack/react-form@1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-form@1.28.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/form-core': 1.28.0 - '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/form-core': 1.28.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 transitivePeerDependencies: - react-dom @@ -13512,9 +13540,9 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.5.0(react@19.2.3) - '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-store@0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.9.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -13532,12 +13560,12 @@ snapshots: '@tanstack/store@0.7.7': {} - '@tanstack/store@0.8.0': {} - '@tanstack/store@0.8.1': {} '@tanstack/store@0.9.1': {} + '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.13.6': {} '@testing-library/dom@10.4.1': diff --git a/src/sentry/constants.py b/src/sentry/constants.py index f2aff76cadff2d..fda38cfaf0768a 100644 --- a/src/sentry/constants.py +++ b/src/sentry/constants.py @@ -727,7 +727,6 @@ class InsightModules(Enum): # Seer Org level default for code review triggers DEFAULT_CODE_REVIEW_TRIGGERS: list[str] = [ "on_ready_for_review", - "on_new_commit", ] SEER_DEFAULT_CODING_AGENT_DEFAULT = "seer" SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT = "code_changes" diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings.py b/src/sentry/integrations/api/endpoints/organization_code_mappings.py index 3eea2dada5e284..9d747c5bb58029 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings.py @@ -70,13 +70,15 @@ def organization(self): def validate(self, attrs): query = RepositoryProjectPathConfig.objects.filter( - project_id=attrs.get("project_id"), stack_root=attrs.get("stack_root") + project_id=attrs.get("project_id"), + stack_root=attrs.get("stack_root"), + source_root=attrs.get("source_root"), ) if self.instance: query = query.exclude(id=self.instance.id) if query.exists(): raise serializers.ValidationError( - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ) return attrs diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py index 6a56cbf750e2a9..25662a251d9666 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py @@ -256,11 +256,9 @@ def post(self, request: Request, organization: Organization) -> Response: config = RepositoryProjectPathConfig.objects.select_for_update().get( project=project, stack_root=mapping["stack_root"], + source_root=mapping["source_root"], ) - for key, value in { - **defaults, - "source_root": mapping["source_root"], - }.items(): + for key, value in defaults.items(): setattr(config, key, value) created = False except RepositoryProjectPathConfig.DoesNotExist: diff --git a/src/sentry/integrations/models/repository_project_path_config.py b/src/sentry/integrations/models/repository_project_path_config.py index 3dd1e22b12c75b..96c41074ac55ea 100644 --- a/src/sentry/integrations/models/repository_project_path_config.py +++ b/src/sentry/integrations/models/repository_project_path_config.py @@ -37,7 +37,7 @@ class RepositoryProjectPathConfig(DefaultFieldsModelExisting): class Meta: app_label = "sentry" db_table = "sentry_repositoryprojectpathconfig" - unique_together = (("project", "stack_root"),) + unique_together = (("project", "stack_root", "source_root"),) def __repr__(self) -> str: return ( diff --git a/src/sentry/integrations/perforce/client.py b/src/sentry/integrations/perforce/client.py index c48a0cb113d009..f7a8cdf9d422c6 100644 --- a/src/sentry/integrations/perforce/client.py +++ b/src/sentry/integrations/perforce/client.py @@ -608,10 +608,61 @@ def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False ) -> str: """ - Get file contents from Perforce depot. - Required by abstract base class but not used (CODEOWNERS). + Get file contents from Perforce depot using ``p4 print``. + + API docs: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_print.html + + Perforce supports two revision specifiers: + - ``@N`` — changelist number (global point-in-time snapshot) + - ``#N`` — file revision (per-file version counter) + + Args: + repo: Repository object containing depot path config + path: File path relative to depot root + ref: Revision specifier. Accepts: + - ``"#3"`` → file revision 3 + - ``"@42"`` → changelist 42 + - ``"42"`` → treated as changelist (``@42``) + - ``None`` → head revision + codeowners: Not used for Perforce + + Returns: + File contents as a UTF-8 string + + Raises: + ApiError(404): File not found in depot + ApiError(500): Perforce connection or command error """ - raise NotImplementedError("get_file is not supported for Perforce") + with self._connect() as p4: + depot_path = self.build_depot_path(repo, path) + + if ref and "#" not in depot_path and "@" not in depot_path: + if ref.startswith("#") or ref.startswith("@"): + depot_path = f"{depot_path}{ref}" + else: + depot_path = f"{depot_path}@{ref}" + + try: + result = p4.run("print", depot_path) + except P4Exception as e: + error_msg = str(e) + if "no such file" in error_msg.lower() or "not in client view" in error_msg.lower(): + raise ApiError(error_msg, code=404) + raise ApiError(error_msg, code=500) + + # p4 print returns a list: first element is file metadata dict, + # remaining elements are file content strings/bytes + if not result or not isinstance(result[0], dict): + raise ApiError(f"File not found: {depot_path}", code=404) + + content_parts = result[1:] + if not content_parts: + return "" + + return "".join( + part.decode("utf-8", errors="replace") if isinstance(part, bytes) else part + for part in content_parts + ) def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any: """Create comment. Not applicable for Perforce.""" diff --git a/src/sentry/issues/auto_source_code_config/code_mapping.py b/src/sentry/issues/auto_source_code_config/code_mapping.py index 5e5cbda07490e5..4918b88728bd5a 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -348,12 +348,12 @@ def create_code_mapping( new_code_mapping, _ = RepositoryProjectPathConfig.objects.update_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_id": organization.id, "integration_id": installation.model.id, "organization_integration_id": installation.org_integration.id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, # This function is called from the UI, thus, we know that the code mapping is user generated "automatically_generated": False, diff --git a/src/sentry/issues/auto_source_code_config/task.py b/src/sentry/issues/auto_source_code_config/task.py index 27101962c21931..b8aaec44eabc89 100644 --- a/src/sentry/issues/auto_source_code_config/task.py +++ b/src/sentry/issues/auto_source_code_config/task.py @@ -238,12 +238,12 @@ def create_code_mapping( _, created = RepositoryProjectPathConfig.objects.get_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_integration_id": org_integration.id, "integration_id": org_integration.integration_id, "organization_id": org_integration.organization_id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, "automatically_generated": True, }, diff --git a/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py new file mode 100644 index 00000000000000..09bcd9161edda6 --- /dev/null +++ b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.12 on 2026-03-27 11:40 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "1057_drop_legacy_alert_rule_tables"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.AddConstraint( + model_name="repositoryprojectpathconfig", + constraint=models.UniqueConstraint( + fields=["project", "stack_root", "source_root"], + name="sentry_repositoryproject_project_id_stack_root_so_c371dfa7_uniq", + ), + ), + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together=set(), + ), + ], + state_operations=[ + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together={("project", "stack_root", "source_root")}, + ), + ], + ), + ] diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 45fe2de8d841e3..a3f6df779157af 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2394,63 +2394,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# List of organization IDs that should be using segment metrics for boost low volume transactions. -register( - "dynamic-sampling.transactions.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in boost low volume transactions. -register( - "dynamic-sampling.transactions.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for recalibrate_orgs. -register( - "dynamic-sampling.recalibrate_orgs.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in recalibrate_orgs. -register( - "dynamic-sampling.recalibrate_orgs.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for sliding_window_org. -register( - "dynamic-sampling.sliding_window_org.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in sliding_window_org. -register( - "dynamic-sampling.sliding_window_org.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for boost_low_volume_projects. -register( - "dynamic-sampling.boost_low_volume_projects.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in boost_low_volume_projects. -register( - "dynamic-sampling.boost_low_volume_projects.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - - # === Hybrid cloud subsystem options === # UI rollout register( @@ -3309,27 +3252,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Write payload sets to per-span distributed keys AND merged keys. -# Flusher reads merged keys as before. -register( - "spans.buffer.write-distributed-payloads", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -# Switch flusher to read from distributed keys instead of merged. -register( - "spans.buffer.read-distributed-payloads", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -# Set to False to stop writing merged keys and skip set merges. -# Disable after read-distributed-payloads is stable. Rollback: re-enable -# this flag to resume merged writes before reverting read-distributed-payloads. -register( - "spans.buffer.write-merged-payloads", - default=True, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # List of trace_ids to enable debug logging for. Empty = debug off. # When set, logs detailed metrics about zunionstore set sizes, key existence, and trace structure. register( diff --git a/src/sentry/preprod/vcs/status_checks/size/tasks.py b/src/sentry/preprod/vcs/status_checks/size/tasks.py index 65094b6c3e6bd4..9a0c32fd8b3e5c 100644 --- a/src/sentry/preprod/vcs/status_checks/size/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/size/tasks.py @@ -37,6 +37,7 @@ ) from sentry.preprod.url_utils import get_preprod_artifact_url from sentry.preprod.vcs.status_checks.size.templates import ( + format_all_skipped_messages, format_no_quota_messages, format_status_check_messages, ) @@ -258,53 +259,57 @@ def create_preprod_status_check_task( "preprod.status_checks.create.all_skipped", extra={"artifact_id": preprod_artifact.id}, ) - return - - url_artifact = ( - preprod_artifact - if preprod_artifact.id in {a.id for a in all_artifacts} - else all_artifacts[0] - ) - target_url = get_preprod_artifact_url(url_artifact) - completed_at: datetime | None = None - - # Check if any artifact hit quota limits - show neutral status with quota message - if _has_no_quota_artifact(all_artifacts, size_metrics_map): - title, subtitle, summary = format_no_quota_messages() + title, subtitle, summary = format_all_skipped_messages(preprod_artifact.project) status = StatusCheckStatus.NEUTRAL completed_at = preprod_artifact.date_updated + target_url = get_preprod_artifact_url(preprod_artifact) triggered_rules: list[TriggeredRule] = [] else: - rules = _get_status_check_rules(preprod_artifact.project) - base_artifact_map, base_size_metrics_map = _fetch_base_size_metrics(all_artifacts) - - status, triggered_rules = _compute_overall_status( - all_artifacts, - size_metrics_map, - rules=rules, - base_artifact_map=base_artifact_map, - base_metrics_by_artifact=base_size_metrics_map, - approvals_map=approvals_map, + url_artifact = ( + preprod_artifact + if preprod_artifact.id in {a.id for a in all_artifacts} + else all_artifacts[0] ) + target_url = get_preprod_artifact_url(url_artifact) + completed_at = None - title, subtitle, summary = format_status_check_messages( - all_artifacts, - size_metrics_map, - status, - preprod_artifact.project, - base_artifact_map, - base_size_metrics_map, - triggered_rules, - ) - - if GITHUB_STATUS_CHECK_STATUS_MAPPING[status] == GitHubCheckStatus.COMPLETED: - completed_at = preprod_artifact.date_updated - - # When no rules are configured, always show neutral status. - # When rules exist, show actual status (in_progress, failure, success). - if not rules: + # Check if any artifact hit quota limits - show neutral status with quota message + if _has_no_quota_artifact(all_artifacts, size_metrics_map): + title, subtitle, summary = format_no_quota_messages() status = StatusCheckStatus.NEUTRAL completed_at = preprod_artifact.date_updated + triggered_rules = [] + else: + rules = _get_status_check_rules(preprod_artifact.project) + base_artifact_map, base_size_metrics_map = _fetch_base_size_metrics(all_artifacts) + + status, triggered_rules = _compute_overall_status( + all_artifacts, + size_metrics_map, + rules=rules, + base_artifact_map=base_artifact_map, + base_metrics_by_artifact=base_size_metrics_map, + approvals_map=approvals_map, + ) + + title, subtitle, summary = format_status_check_messages( + all_artifacts, + size_metrics_map, + status, + preprod_artifact.project, + base_artifact_map, + base_size_metrics_map, + triggered_rules, + ) + + if GITHUB_STATUS_CHECK_STATUS_MAPPING[status] == GitHubCheckStatus.COMPLETED: + completed_at = preprod_artifact.date_updated + + # When no rules are configured, always show neutral status. + # When rules exist, show actual status (in_progress, failure, success). + if not rules: + status = StatusCheckStatus.NEUTRAL + completed_at = preprod_artifact.date_updated try: check_id = provider.create_status_check( diff --git a/src/sentry/preprod/vcs/status_checks/size/templates.py b/src/sentry/preprod/vcs/status_checks/size/templates.py index f25498f5e385f1..080355030aa044 100644 --- a/src/sentry/preprod/vcs/status_checks/size/templates.py +++ b/src/sentry/preprod/vcs/status_checks/size/templates.py @@ -20,6 +20,15 @@ def format_no_quota_messages() -> tuple[str, str, str]: return str(title), str(subtitle), str(summary) +def format_all_skipped_messages(project: Project) -> tuple[str, str, str]: + """Format status check messages when all artifacts are filtered/skipped.""" + title = _SIZE_ANALYZER_TITLE_BASE + subtitle = _("Size analysis skipped") + settings_url = _get_settings_url(project) + summary = str(_format_configure_link(project, settings_url)) + return str(title), str(subtitle), str(summary) + + def format_status_check_messages( artifacts: list[PreprodArtifact], size_metrics_map: dict[int, list[PreprodArtifactSizeMetrics]], diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 3dbc0005d5abee..99e260fcf69e7c 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -25,10 +25,11 @@ from sentry.issues.grouptype import WebVitalsGroup from sentry.models.commitauthor import CommitAuthor from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import EventsResponse, SnubaParams -from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES, AutofixReferrer from sentry.seer.autofix.types import ( AutofixCreatePRPayload, AutofixSelectRootCausePayload, @@ -832,6 +833,15 @@ def update_autofix( """ Issue an update to an autofix run. Intentionally matching the output of trigger_autofix. """ + if payload.get("type") in CODING_PAYLOAD_TYPES: + try: + org = Organization.objects.get(id=organization_id) + if not org.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + return Response( + {"detail": "Code generation is disabled for this organization"}, status=403 + ) + except Organization.DoesNotExist: + return Response({"detail": "Organization not found"}, status=404) data = AutofixUpdateRequest(organization_id=organization_id, run_id=run_id, payload=payload) body = orjson.dumps(data) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 16bca7feffb63f..2e0898cc375f7c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -6,7 +6,9 @@ from typing import TYPE_CHECKING, Literal from pydantic import BaseModel +from rest_framework.exceptions import PermissionDenied +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.seer.autofix.artifact_schemas import ( ImpactAssessmentArtifact, RootCauseArtifact, @@ -428,7 +430,11 @@ def trigger_coding_agent_handoff( Returns: Dictionary with 'successes' and 'failures' lists """ - # Fetch project preferences for repos and auto_create_pr setting + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + auto_create_pr = False repo_definitions: list[SeerRepoDefinition] = [] try: @@ -509,6 +515,11 @@ def trigger_push_changes( state: SeerRunState | None = None, repo_name: str | None = None, ): + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + client = get_autofix_explorer_client(group) if state is None: diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 76b68531da5b00..ad9e9fd3cca7db 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,7 +12,7 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features -from sentry.constants import ObjectStatus +from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus from sentry.integrations.claude_code.integration import ( ClaudeCodeIntegrationMetadata, ) @@ -428,6 +428,9 @@ def launch_coding_agents_for_run( except Organization.DoesNotExist: raise NotFound("Organization not found") + if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + raise PermissionDenied("Code generation is disabled for this organization") + integration = None installation: CodingAgentIntegration | None = None client: CodingAgentClient | None = None diff --git a/src/sentry/seer/autofix/constants.py b/src/sentry/seer/autofix/constants.py index eb24d76b441eba..28c08290afcfaf 100644 --- a/src/sentry/seer/autofix/constants.py +++ b/src/sentry/seer/autofix/constants.py @@ -1,5 +1,7 @@ import enum +CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) + # An issue group must have >= this number of occurrences in order to be # a target for 'workflow' autofix. AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD = 10 diff --git a/src/sentry/seer/endpoints/group_autofix_update.py b/src/sentry/seer/endpoints/group_autofix_update.py index 09a815365de226..a32535a492a6f3 100644 --- a/src/sentry/seer/endpoints/group_autofix_update.py +++ b/src/sentry/seer/endpoints/group_autofix_update.py @@ -12,9 +12,10 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.helpers.deprecation import deprecated -from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.constants import CELL_API_DEPRECATION_DATE, ENABLE_SEER_CODING_DEFAULT from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, @@ -36,7 +37,7 @@ def post(self, request: Request, group: Group) -> Response: """ Send an update event to autofix for a given group. """ - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a run_id and payload"}) user = request.user @@ -46,6 +47,17 @@ def post(self, request: Request, group: Group) -> Response: data={"error": "You must be authenticated to use this endpoint"}, ) + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/autofix/update" body = orjson.dumps( diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 873425d846f000..722fedc69ac669 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -10,7 +10,9 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.explorer.client_utils import ( explorer_connection_pool, has_seer_explorer_access_with_detail, @@ -43,9 +45,20 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res if not has_access: return Response({"detail": error}, status=403) - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a payload"}) + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/explorer/update" body = orjson.dumps( diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 5d8867ad06eab9..03648c3aaaa39c 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from sentry import features, options +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization from sentry.models.project import Project from sentry.seer.explorer.client_models import ExplorerRun, ExplorerRunWithPrs, SeerRunState @@ -550,7 +551,13 @@ def push_changes( Raises: TimeoutError: If polling exceeds timeout SeerApiError: If the Seer API request fails + SeerPermissionError: If code generation is disabled for the organization """ + if not self.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise SeerPermissionError("Code generation is disabled for this organization") + # Trigger PR creation payload: dict[str, Any] = {"type": "create_pr"} if repo_name: diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 43135a2180f5b3..bc785e83708fcd 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -625,41 +625,18 @@ class Referrer(StrEnum): DELETIONS_GROUP = "deletions.group" DISCOVER = "discover" DISCOVER_SLACK_UNFURL = "discover.slack.unfurl" - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_BREAKDOWN = ( - "dynamic-sampling.distribution.fetch-project-breakdown" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_SDK_VERSIONS_INFO = ( - "dynamic-sampling.distribution.fetch-project-sdk-versions-info" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_STATS = ( - "dynamic-sampling.distribution.fetch-project-stats" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_TRANSACTIONS_COUNT = ( - "dynamic-sampling.distribution.fetch-transactions-count" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_TRANSACTIONS = ( - "dynamic-sampling.distribution.fetch-transactions" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_GET_MOST_RECENT_DAY_WITH_TRANSACTIONS = ( - "dynamic-sampling.distribution.get-most-recent-day-with-transactions" - ) DYNAMIC_SAMPLING_COUNTERS_GET_ORG_TRANSACTION_VOLUMES = ( "dynamic_sampling.counters.get_org_transaction_volumes" ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_ORGS_WITH_COUNT_PER_ROOT = ( - "dynamic_sampling.distribution.fetch_orgs_with_count_per_root_total_volumes" - ) DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECTS_WITH_COUNT_PER_ROOT = ( "dynamic_sampling.distribution.fetch_projects_with_count_per_root_total_volumes" ) DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_COUNT_PER_TRANSACTION = ( "dynamic_sampling.counters.fetch_projects_with_count_per_transaction_volumes" ) - DYNAMIC_SAMPLING_COUNTERS_GET_ACTIVE_ORGS = "dynamic_sampling.counters.get_active_orgs" DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_TRANSACTION_TOTALS = ( "dynamic_sampling.counters.fetch_projects_with_transaction_totals" ) - DYNAMIC_SAMPLING_COUNTERS_FETCH_ACTIVE_ORGS = "dynamic_sampling.counters.fetch_active_orgs" DYNAMIC_SAMPLING_SETTINGS_GET_SPAN_COUNTS = "dynamic_sampling.settings.get_project_span_counts" ESCALATING_GROUPS = "sentry.issues.escalating" EVENTSTORE_GET_EVENT_BY_ID_NODESTORE = "eventstore.backend.get_event_by_id_nodestore" diff --git a/static/app/components/arithmeticBuilder/token/index.spec.tsx b/static/app/components/arithmeticBuilder/token/index.spec.tsx index ef5996d6b5913b..a6b934fc775119 100644 --- a/static/app/components/arithmeticBuilder/token/index.spec.tsx +++ b/static/app/components/arithmeticBuilder/token/index.spec.tsx @@ -878,6 +878,28 @@ describe('token', () => { expect(await screen.findByRole('row', {name: '10'})).toBeInTheDocument(); expect(screen.getByTestId(dataTestId)).toBeInTheDocument(); }); + + it('completes literal on blur', async () => { + const dispatch = jest.fn(); + render(); + + expect(await screen.findByRole('row', {name: '1'})).toBeInTheDocument(); + + const input = screen.getByRole('textbox', { + name: 'Add a literal', + }); + expect(input).toBeInTheDocument(); + + await userEvent.click(input); + expect(input).toHaveFocus(); + expect(input).toHaveValue('1'); + await userEvent.type(input, '00'); + + // Tab away to trigger blur without pressing Enter + await userEvent.tab(); + + expect(await screen.findByRole('row', {name: '100'})).toBeInTheDocument(); + }); }); describe('ArithmeticTokenOperator', () => { diff --git a/static/app/components/arithmeticBuilder/token/literal.tsx b/static/app/components/arithmeticBuilder/token/literal.tsx index 3e2d4dd9f5c3b0..ecb3209a2fe673 100644 --- a/static/app/components/arithmeticBuilder/token/literal.tsx +++ b/static/app/components/arithmeticBuilder/token/literal.tsx @@ -83,8 +83,15 @@ function InternalInput({item, state, token}: InternalInputProps) { }, [updateSelectionIndex]); const onInputBlur = useCallback(() => { + const trimmed = inputValue.trim(); + const text = validateLiteral(trimmed) ? trimmed : token.text; + dispatch({ + text, + type: 'REPLACE_TOKEN', + token, + }); resetInputValue(); - }, [resetInputValue]); + }, [dispatch, inputValue, token, resetInputValue]); const onInputChange = useCallback( (evt: ChangeEvent) => { diff --git a/static/app/components/avatarChooser/avatarCropper.tsx b/static/app/components/avatarChooser/avatarCropper.tsx index f11fd48355cdfd..ab38e9a3b4be1e 100644 --- a/static/app/components/avatarChooser/avatarCropper.tsx +++ b/static/app/components/avatarChooser/avatarCropper.tsx @@ -332,7 +332,6 @@ function AvatarCropper({maxDimension, minDimension, updateDataUrlState, dataUrl} document.addEventListener('mousemove', updateSize); document.addEventListener('mouseup', stopResize); - // eslint-disable-next-line consistent-return return () => { document.removeEventListener('mousemove', updateSize); document.removeEventListener('mouseup', stopResize); diff --git a/static/app/components/charts/useChartXRangeSelection.tsx b/static/app/components/charts/useChartXRangeSelection.tsx index e38acab1feef4b..3134a5f04a08d6 100644 --- a/static/app/components/charts/useChartXRangeSelection.tsx +++ b/static/app/components/charts/useChartXRangeSelection.tsx @@ -361,7 +361,6 @@ export function useChartXRangeSelection({ document.body.addEventListener('click', handleInsideSelectionClick, true); - // eslint-disable-next-line consistent-return return () => { document.body.removeEventListener('click', handleInsideSelectionClick, true); }; @@ -422,7 +421,6 @@ export function useChartXRangeSelection({ enableBrushMode(); }); - // eslint-disable-next-line consistent-return return () => { if (brushStateSyncFrameRef.current) { cancelAnimationFrame(brushStateSyncFrameRef.current); diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 5319bdbba4913a..24c3338432c58f 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -2,14 +2,10 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import type { CommandPaletteAction, - CommandPaletteActionWithKey, + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, } from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; @@ -25,8 +21,8 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: Exclude) => { - if (action.type === 'navigate') { + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { navigate(normalizeUrl(action.to)); } else { action.onAction(); @@ -35,31 +31,31 @@ export function CommandPaletteDemo() { [navigate] ); - const demoActions = [ - makeCommandPaletteLink({ + const demoActions: CommandPaletteAction[] = [ + { display: {label: 'Go to Flex story'}, to: '/stories/layout/flex/', groupingKey: 'navigate', - }), - makeCommandPaletteCallback({ + }, + { display: {label: 'Execute an action'}, groupingKey: 'help', onAction: () => { addSuccessMessage('Action executed'); }, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'add', display: {label: 'Parent action'}, actions: [ - makeCommandPaletteCallback({ + { display: {label: 'Child action'}, onAction: () => { addSuccessMessage('Child action executed'); }, - }), + }, ], - }), + }, ]; return ( diff --git a/static/app/components/commandPalette/makeCommandPaletteAction.tsx b/static/app/components/commandPalette/makeCommandPaletteAction.tsx deleted file mode 100644 index 1de696ba149fdc..00000000000000 --- a/static/app/components/commandPalette/makeCommandPaletteAction.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { - CommandPaletteActionCallback, - CommandPaletteActionGroup, - CommandPaletteActionLink, -} from 'sentry/components/commandPalette/types'; - -export function makeCommandPaletteLink( - options: Omit -): CommandPaletteActionLink { - return { - ...options, - type: 'navigate', - }; -} - -export function makeCommandPaletteCallback( - options: Omit -): CommandPaletteActionCallback { - return { - ...options, - type: 'callback', - }; -} - -export function makeCommandPaletteGroup( - options: Omit -): CommandPaletteActionGroup { - return { - ...options, - type: 'group', - }; -} diff --git a/static/app/components/commandPalette/types.tsx b/static/app/components/commandPalette/types.tsx index e82a3485cb53a9..b585e964216381 100644 --- a/static/app/components/commandPalette/types.tsx +++ b/static/app/components/commandPalette/types.tsx @@ -23,7 +23,6 @@ interface CommonCommandPaletteAction { export interface CommandPaletteActionLink extends CommonCommandPaletteAction { /** Navigate to a route when selected */ to: LocationDescriptor; - type: 'navigate'; } export interface CommandPaletteActionCallback extends CommonCommandPaletteAction { @@ -32,7 +31,6 @@ export interface CommandPaletteActionCallback extends CommonCommandPaletteAction * Use the `to` prop if you want to navigate to a route. */ onAction: () => void; - type: 'callback'; } export type CommandPaletteActionChild = @@ -44,7 +42,6 @@ export interface CommandPaletteActionGroup< > extends CommonCommandPaletteAction { /** Nested actions to show when this action is selected */ actions: T[]; - type: 'group'; } export type CommandPaletteAction = diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 1b652dbef6c74a..a21d87c95b81ec 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -23,8 +23,11 @@ jest.mock('@tanstack/react-virtual', () => ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteAction, + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, +} from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -44,8 +47,8 @@ function GlobalActionsComponent({ const navigate = useNavigate(); const handleAction = useCallback( - (action: Exclude) => { - if (action.type === 'navigate') { + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { navigate(action.to); } else { action.onAction(); @@ -73,13 +76,11 @@ const globalActions: CommandPaletteAction[] = [ display: { label: 'Go to route', }, - type: 'navigate', }, { to: '/other/', groupingKey: 'help', display: {label: 'Other'}, - type: 'navigate', }, { groupingKey: 'add', @@ -88,10 +89,8 @@ const globalActions: CommandPaletteAction[] = [ { onAction: onChild, display: {label: 'Child action'}, - type: 'callback', }, ], - type: 'group', }, ]; @@ -234,13 +233,11 @@ describe('CommandPalette', () => { it('actions are ranked by match quality — better matches appear first', async () => { const actions: CommandPaletteAction[] = [ { - type: 'navigate', to: '/a/', display: {label: 'Something with issues buried'}, groupingKey: 'navigate', }, { - type: 'navigate', to: '/b/', display: {label: 'Issues'}, groupingKey: 'navigate', @@ -260,13 +257,11 @@ describe('CommandPalette', () => { it('top-level actions rank before child actions when both match the query', async () => { const actions: CommandPaletteAction[] = [ { - type: 'group', display: {label: 'Group'}, groupingKey: 'navigate', - actions: [{type: 'navigate', to: '/child/', display: {label: 'Issues child'}}], + actions: [{to: '/child/', display: {label: 'Issues child'}}], }, { - type: 'navigate', to: '/top/', display: {label: 'Issues'}, groupingKey: 'navigate', @@ -286,7 +281,6 @@ describe('CommandPalette', () => { it('actions with matching keywords are included in results', async () => { const actions: CommandPaletteAction[] = [ { - type: 'navigate', to: '/shortcuts/', display: {label: 'Keyboard shortcuts'}, keywords: ['hotkeys', 'keybindings'], @@ -305,12 +299,11 @@ describe('CommandPalette', () => { it("searching within a drilled-in group filters that group's children", async () => { const actions: CommandPaletteAction[] = [ { - type: 'group', display: {label: 'Theme'}, groupingKey: 'navigate', actions: [ - {type: 'callback', onAction: jest.fn(), display: {label: 'Light'}}, - {type: 'callback', onAction: jest.fn(), display: {label: 'Dark'}}, + {onAction: jest.fn(), display: {label: 'Light'}}, + {onAction: jest.fn(), display: {label: 'Dark'}}, ], }, ]; diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index edca2225d4b2c9..2ea0996909390b 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -21,6 +21,8 @@ import {Text} from '@sentry/scraps/text'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/context'; import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, CommandPaletteActionWithKey, CommandPaletteGroupKey, } from 'sentry/components/commandPalette/types'; @@ -63,7 +65,9 @@ type CommandPaletteActionWithPriority = CommandPaletteActionWithKey & { }; interface CommandPaletteProps { - onAction: (action: Exclude) => void; + onAction: ( + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey + ) => void; } export function CommandPalette(props: CommandPaletteProps) { @@ -81,7 +85,8 @@ export function CommandPalette(props: CommandPaletteProps) { const displayedActions = useMemo(() => { if ( - state.action?.value.action.type === 'group' && + state.action !== null && + 'actions' in state.action.value.action && state.action.value.action.actions.length > 0 ) { return flattenActions(state.action.value.action.actions); @@ -174,7 +179,7 @@ export function CommandPalette(props: CommandPaletteProps) { return; } - if (action.type === 'group') { + if ('actions' in action) { analytics.recordGroupAction(action, resultIndex); dispatch({type: 'push action', action}); return; @@ -399,7 +404,7 @@ function makeMenuItemFromAction( {action.display.icon} ), - children: action.type === 'group' ? action.actions.map(makeMenuItemFromAction) : [], + children: 'actions' in action ? action.actions.map(makeMenuItemFromAction) : [], hideCheck: true, }; } @@ -428,7 +433,7 @@ function flattenActions( flattened.push({...action, priority: 0}); } - if (action.type === 'group' && action.actions.length > 0) { + if ('actions' in action && action.actions.length > 0) { const childParentLabel = parentLabel ? `${parentLabel} → ${action.display.label}` : action.display.label; diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index a3a16e68d8bb9e..27955990c25402 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -3,12 +3,14 @@ import {css} from '@emotion/react'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {closeModal} from 'sentry/actionCreators/modal'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, +} from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteState} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions'; import type {Theme} from 'sentry/utils/theme'; -import {unreachable} from 'sentry/utils/unreachable'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -19,18 +21,11 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { useDsnLookupActions(query); const handleSelect = useCallback( - (action: Exclude) => { - const actionType = action.type; - switch (actionType) { - case 'navigate': - navigate(normalizeUrl(action.to)); - break; - case 'callback': - action.onAction(); - break; - default: - unreachable(actionType); - break; + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { + navigate(normalizeUrl(action.to)); + } else { + action.onAction(); } closeModal(); }, diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 9065be2e38565c..48b4704662ce1b 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -13,11 +13,6 @@ import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {toggleCommandPalette} from 'sentry/actionCreators/modal'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; import * as Storybook from 'sentry/stories'; @@ -45,31 +40,30 @@ import {useCommandPaletteActions} from 'sentry/components/commandPalette/useComm function YourComponent() { useCommandPaletteActions([ - makeCommandPaletteLink({ + { display: {label: 'Go to Input story'}, to: '/stories/core/input/', groupingKey: 'navigate', - }), - makeCommandPaletteCallback({ + }, + { display: {label: 'Execute an action'}, groupingKey: 'help', onAction: () => { addSuccessMessage('Action executed'); }, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'add', display: {label: 'Parent action'}, actions: [ - makeCommandPaletteCallback({ - key: 'child-action', + { display: {label: 'Child action'}, onAction: () => { addSuccessMessage('Child action executed'); }, - }), + }, ], - }), + }, ]); } ``` diff --git a/static/app/components/commandPalette/useCommandPaletteActions.tsx b/static/app/components/commandPalette/useCommandPaletteActions.tsx index c6947806709257..d36857f6317bc4 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.tsx +++ b/static/app/components/commandPalette/useCommandPaletteActions.tsx @@ -5,9 +5,8 @@ import {slugify} from 'sentry/utils/slugify'; import {useCommandPaletteRegistration} from './context'; import type { CommandPaletteAction, - CommandPaletteActionCallback, CommandPaletteActionCallbackWithKey, - CommandPaletteActionLink, + CommandPaletteActionChild, CommandPaletteActionLinkWithKey, CommandPaletteActionWithKey, } from './types'; @@ -17,9 +16,10 @@ function addKeysToActions( actions: CommandPaletteAction[] ): CommandPaletteActionWithKey[] { return actions.map(action => { - const actionKey = `${id}:${action.type}:${slugify(action.display.label)}`; + const kind = 'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback'; + const actionKey = `${id}:${kind}:${slugify(action.display.label)}`; - if (action.type === 'group') { + if ('actions' in action) { return { ...action, actions: addKeysToChildActions(id, action.actions), @@ -36,13 +36,13 @@ function addKeysToActions( function addKeysToChildActions( id: string, - actions: Array + actions: CommandPaletteActionChild[] ): Array { return actions.map(action => { const label = action.display.label.toLowerCase().replace(/ /g, '-'); - const disambiguator = - action.type === 'navigate' ? `:${JSON.stringify(action.to)}` : ''; - const actionKey = `${id}:${action.type}:${label}${disambiguator}`; + const disambiguator = 'to' in action ? `:${JSON.stringify(action.to)}` : ''; + const kind = 'to' in action ? 'navigate' : 'callback'; + const actionKey = `${id}:${kind}:${label}${disambiguator}`; return { ...action, key: actionKey, diff --git a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx index c657f92bb2453a..9b08eae2c19d63 100644 --- a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx +++ b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx @@ -2,7 +2,11 @@ import {useEffect, useMemo, useRef} from 'react'; import * as Sentry from '@sentry/react'; import uniqueId from 'lodash/uniqueId'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, + CommandPaletteActionWithKey, +} from 'sentry/components/commandPalette/types'; import { getActionPath, type LinkedList, @@ -32,7 +36,7 @@ function getLinkedListDepth(node: LinkedList | null): number { */ export function useCommandPaletteAnalytics(filteredActionCount: number): { recordAction: ( - action: Exclude, + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey, resultIndex: number, group: string ) => void; @@ -136,7 +140,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { return useMemo( () => ({ recordAction( - action: Exclude, + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey, resultIndex: number, group: string ) { @@ -149,7 +153,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { organization, action: label, query: s.state.query, - action_type: action.type, + action_type: 'to' in action ? 'navigate' : 'callback', group, result_index: resultIndex, session_id: s.sessionId, diff --git a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx index f3436487815812..23a15d1cb81f67 100644 --- a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx +++ b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx @@ -19,7 +19,9 @@ function DsnLookupHarness({query}: {query: string}) { {actions.map(action => ( {action.display.label} diff --git a/static/app/components/commandPalette/useDsnLookupActions.tsx b/static/app/components/commandPalette/useDsnLookupActions.tsx index 7469526a8c7c86..e598c8e4622cba 100644 --- a/static/app/components/commandPalette/useDsnLookupActions.tsx +++ b/static/app/components/commandPalette/useDsnLookupActions.tsx @@ -42,7 +42,6 @@ export function useDsnLookupActions(query: string): void { } return getDsnNavTargets(data).map((target, i) => ({ - type: 'navigate' as const, to: target.to, display: { label: target.label, diff --git a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx index 28b91c21750a26..53ebfc2b3414a1 100644 --- a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx +++ b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx @@ -2,11 +2,6 @@ import {ProjectAvatar} from '@sentry/scraps/avatar'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openInviteMembersModal} from 'sentry/actionCreators/modal'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import type { CommandPaletteAction, CommandPaletteActionChild, @@ -55,212 +50,202 @@ function useNavigationActions(): CommandPaletteAction[] { const {projects} = useProjects(); const issuesChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Feed'), }, to: `${prefix}/issues/`, - }), - ...Object.values(ISSUE_TAXONOMY_CONFIG).map(config => - makeCommandPaletteLink({ - display: { - label: config.label, - }, - to: `${prefix}/issues/${config.key}/`, - }) - ), - makeCommandPaletteLink({ + }, + ...Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ({ + display: { + label: config.label, + }, + to: `${prefix}/issues/${config.key}/`, + })), + { display: { label: t('User Feedback'), }, to: `${prefix}/issues/feedback/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Views'), }, to: `${prefix}/issues/views/`, - }), - ...starredViews.map(view => - makeCommandPaletteLink({ - display: { - label: view.label, - icon: , - }, - to: `${prefix}/issues/views/${view.id}/`, - }) - ), + }, + ...starredViews.map(view => ({ + display: { + label: view.label, + icon: , + }, + to: `${prefix}/issues/views/${view.id}/`, + })), ]; const exploreChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Traces'), }, to: `${prefix}/explore/traces/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Logs'), }, to: `${prefix}/explore/logs/`, hidden: !organization.features.includes('ourlogs-enabled'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Discover'), }, to: `${prefix}/explore/discover/homepage/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Profiles'), }, to: `${prefix}/explore/profiling/`, hidden: !organization.features.includes('profiling'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Replays'), }, to: `${prefix}/explore/replays/`, hidden: !organization.features.includes('session-replay-ui'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Releases'), }, to: `${prefix}/explore/releases/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Queries'), }, to: `${prefix}/explore/saved-queries/`, - }), + }, ]; const dashboardsChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('All Dashboards'), }, to: `${prefix}/dashboards/`, - }), - ...starredDashboards.map(dashboard => - makeCommandPaletteLink({ - display: { - label: dashboard.title, - icon: , - }, - to: `${prefix}/dashboard/${dashboard.id}/`, - }) - ), + }, + ...starredDashboards.map(dashboard => ({ + display: { + label: dashboard.title, + icon: , + }, + to: `${prefix}/dashboard/${dashboard.id}/`, + })), ]; const insightsChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Frontend'), }, to: `${prefix}/insights/${FRONTEND_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Backend'), }, to: `${prefix}/insights/${BACKEND_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Mobile'), }, to: `${prefix}/insights/${MOBILE_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Agents'), }, to: `${prefix}/insights/${AGENTS_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('MCP'), }, to: `${prefix}/insights/${MCP_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Crons'), }, to: `${prefix}/insights/crons/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Uptime'), }, to: `${prefix}/insights/uptime/`, hidden: !organization.features.includes('uptime'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Projects'), }, to: `${prefix}/insights/projects/`, - }), + }, ]; const settingsChildren: CommandPaletteActionChild[] = getUserOrgNavigationConfiguration().flatMap(item => - item.items.map(settingsChildItem => - makeCommandPaletteLink({ - display: { - label: settingsChildItem.title, - }, - to: settingsChildItem.path, - }) - ) + item.items.map(settingsChildItem => ({ + display: { + label: settingsChildItem.title, + }, + to: settingsChildItem.path, + })) ); const projectSettingsChildren: CommandPaletteActionChild[] = organization.features.includes('cmd-k-supercharged') - ? projects.map(project => - makeCommandPaletteLink({ - display: { - label: project.name, - icon: , - }, - to: `/settings/${slug}/projects/${project.slug}/`, - }) - ) + ? projects.map(project => ({ + display: { + label: project.name, + icon: , + }, + to: `/settings/${slug}/projects/${project.slug}/`, + })) : []; return [ - makeCommandPaletteGroup({ + { groupingKey: 'navigate', display: { label: t('Issues'), icon: , }, actions: issuesChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Explore'), icon: , }, actions: exploreChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Dashboards'), icon: , }, actions: dashboardsChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Insights'), @@ -268,26 +253,26 @@ function useNavigationActions(): CommandPaletteAction[] { }, actions: insightsChildren, hidden: !organization.features.includes('performance-view'), - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Settings'), icon: , }, actions: settingsChildren, - }), + }, organization.features.includes('cmd-k-supercharged') - ? makeCommandPaletteGroup({ + ? { groupingKey: 'navigate', display: { label: t('Project Settings'), icon: , }, actions: projectSettingsChildren, - }) + } : null, - ].filter(x => x !== null); + ].filter(x => x !== null) as CommandPaletteAction[]; } function useNavigationToggleCollapsed(): CommandPaletteAction { @@ -295,7 +280,6 @@ function useNavigationToggleCollapsed(): CommandPaletteAction { const isCollapsed = view !== 'expanded'; return { - type: 'callback', display: { label: isCollapsed ? t('Expand Navigation Sidebar') @@ -321,55 +305,55 @@ export function useGlobalCommandPaletteActions() { useCommandPaletteActions([ ...navigateActions, - makeCommandPaletteLink({ + { display: { label: t('Create Dashboard'), icon: , }, groupingKey: 'add', to: `${navPrefix}/dashboards/new/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Create Alert'), icon: , }, groupingKey: 'add', to: `${navPrefix}/issues/alerts/wizard/`, - }), - makeCommandPaletteLink({ + }, + { groupingKey: 'add', display: { label: t('Create Project'), icon: , }, to: `${navPrefix}/projects/new/`, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Invite Members'), icon: , }, groupingKey: 'add', onAction: () => openInviteMembersModal(), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Open Documentation'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://docs.sentry.io', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Join Discord'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://discord.gg/sentry', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Open GitHub Repository'), icon: , @@ -377,23 +361,23 @@ export function useGlobalCommandPaletteActions() { groupingKey: 'help', onAction: () => window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('View Changelog'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://sentry.io/changelog/', '_blank', 'noreferrer'), - }), + }, navigationToggleAction, - makeCommandPaletteGroup({ + { display: { label: t('Change Color Theme'), icon: , }, actions: [ - makeCommandPaletteCallback({ + { display: { label: t('System'), }, @@ -402,8 +386,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'system'}); addSuccessMessage(t('Theme preference saved: System')); }, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Light'), }, @@ -412,8 +396,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'light'}); addSuccessMessage(t('Theme preference saved: Light')); }, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Dark'), }, @@ -422,8 +406,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'dark'}); addSuccessMessage(t('Theme preference saved: Dark')); }, - }), + }, ], - }), + }, ]); } diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index b93385dd2f512d..c1a9fd7ef0cbd1 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -1,3 +1,6 @@ +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types'; import { isCodeChangesArtifact, @@ -5,11 +8,14 @@ import { isPullRequestsArtifact, isRootCauseArtifact, isSolutionArtifact, + useExplorerAutofix, type RootCauseArtifact, type SolutionArtifact, } from 'sentry/components/events/autofix/useExplorerAutofix'; import type {Artifact} from 'sentry/views/seerExplorer/types'; +jest.mock('sentry/actionCreators/indicator'); + function makeValidArtifact(data: T): Artifact { return { key: 'artifact-1', @@ -233,3 +239,76 @@ describe('isCodingAgentsArtifact', () => { expect(isCodingAgentsArtifact([{not_an: 'agent'}])).toBe(false); }); }); + +describe('useExplorerAutofix - createPR', () => { + const GROUP_ID = '123'; + const AUTOFIX_URL = `/organizations/org-slug/issues/${GROUP_ID}/autofix/`; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'GET', + body: {autofix: null}, + }); + }); + + it('sends correct POST request without repoName', async () => { + const mockPost = MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + body: {}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await act(() => result.current.createPR(42)); + + expect(mockPost).toHaveBeenCalledWith( + AUTOFIX_URL, + expect.objectContaining({ + method: 'POST', + query: {mode: 'explorer'}, + data: {step: 'open_pr', run_id: 42}, + }) + ); + }); + + it('includes repo_name when repoName is provided', async () => { + const mockPost = MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + body: {}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await act(() => result.current.createPR(42, 'org/repo')); + + expect(mockPost).toHaveBeenCalledWith( + AUTOFIX_URL, + expect.objectContaining({ + method: 'POST', + query: {mode: 'explorer'}, + data: {step: 'open_pr', run_id: 42, repo_name: 'org/repo'}, + }) + ); + }); + + it('calls addErrorMessage and throws on API error', async () => { + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + statusCode: 500, + body: {detail: 'Server error'}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await expect(act(() => result.current.createPR(42))).rejects.toThrow(); + + await waitFor(() => { + expect(addErrorMessage).toHaveBeenCalledWith('Server error'); + }); + }); +}); diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index d85c79d80e7d36..1b0d3774061815 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -601,18 +601,21 @@ export function useExplorerAutofix( const createPR = useCallback( async (runId: number, repoName?: string) => { try { + const data: Record = { + step: 'open_pr', + run_id: runId, + }; + if (repoName) { + data.repo_name = repoName; + } await api.requestPromise( - getApiUrl('/organizations/$organizationIdOrSlug/seer/explorer-update/$runId/', { - path: {organizationIdOrSlug: orgSlug, runId}, + getApiUrl('/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', { + path: {organizationIdOrSlug: orgSlug, issueId: groupId}, }), { method: 'POST', - data: { - payload: { - type: 'create_pr', - repo_name: repoName, - }, - }, + query: {mode: 'explorer'}, + data, } ); diff --git a/static/app/components/events/contexts/platformContext/utils.tsx b/static/app/components/events/contexts/platformContext/utils.tsx index 5f280c20374cef..8e21e5f08a70a9 100644 --- a/static/app/components/events/contexts/platformContext/utils.tsx +++ b/static/app/components/events/contexts/platformContext/utils.tsx @@ -54,8 +54,6 @@ export function getPlatformContextIcon({ case PlatformContextKeys.SPRING: platformIconName = 'java-spring'; break; - default: - break; } if (platformIconName.length === 0) { diff --git a/static/app/components/events/contexts/utils.tsx b/static/app/components/events/contexts/utils.tsx index 4b12829e6520df..2f9eab135721d8 100644 --- a/static/app/components/events/contexts/utils.tsx +++ b/static/app/components/events/contexts/utils.tsx @@ -355,8 +355,6 @@ export function getContextIcon({ case 'gpu': iconName = generateIconName(value?.vendor_name ? value?.vendor_name : value?.name); break; - default: - break; } if (iconName.length === 0) { return null; @@ -568,8 +566,6 @@ export function getContextSummary({ subtitleType = t('Version'); } break; - default: - break; } return { title, diff --git a/static/app/components/events/highlights/util.tsx b/static/app/components/events/highlights/util.tsx index d1f96eeff02dfc..10b91f3571e522 100644 --- a/static/app/components/events/highlights/util.tsx +++ b/static/app/components/events/highlights/util.tsx @@ -181,7 +181,6 @@ export function getRuntimeLabelAndTooltip( return null; } - // eslint-disable-next-line default-case switch (event.contexts.runtime?.name || '') { case 'node': return {label: t('Backend'), tooltip: t('Error from Node.js Server Runtime')}; diff --git a/static/app/components/events/interfaces/utils.tsx b/static/app/components/events/interfaces/utils.tsx index 13631cc1d1e4ef..a1b5b18ee6400d 100644 --- a/static/app/components/events/interfaces/utils.tsx +++ b/static/app/components/events/interfaces/utils.tsx @@ -278,7 +278,7 @@ export function parseAssembly(assembly: string | null) { for (let i = 1; i < pieces.length; i++) { const [key, value] = pieces[i]!.trim().split('='); - // eslint-disable-next-line default-case + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (key) { case 'Version': version = value; diff --git a/static/app/components/guidedSteps/guidedSteps.tsx b/static/app/components/guidedSteps/guidedSteps.tsx index 113e2dc8497db6..419b34849b5e5f 100644 --- a/static/app/components/guidedSteps/guidedSteps.tsx +++ b/static/app/components/guidedSteps/guidedSteps.tsx @@ -373,6 +373,7 @@ const ChildrenWrapper = styled('div')<{isActive: boolean}>` const StepDetails = styled('div')` overflow: hidden; grid-area: details; + min-width: 0; `; GuidedSteps.Step = Step; diff --git a/static/app/components/layouts/thirds.tsx b/static/app/components/layouts/thirds.tsx index 2f50400881541f..7ecbfaa0acdf93 100644 --- a/static/app/components/layouts/thirds.tsx +++ b/static/app/components/layouts/thirds.tsx @@ -2,7 +2,12 @@ import {useContext, type HTMLAttributes} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {Container, Stack, type FlexProps} from '@sentry/scraps/layout'; +import { + Container, + Stack, + type ContainerProps, + type FlexProps, +} from '@sentry/scraps/layout'; import {Tabs} from '@sentry/scraps/tabs'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; @@ -22,8 +27,8 @@ export function Page(props: FlexProps<'main'> & {withPadding?: boolean}) { if (hasPageFrame) { return ( & {withPadding?: boolean}) { : undefined : undefined } - background="secondary" + background="primary" {...rest} /> ); @@ -76,7 +81,12 @@ const StyledPageFrameStack = styled(Stack)<{roundedCorner: boolean}>` * * Use `noActionWrap` to disable wrapping if there are minimal actions. */ -export const Header = styled('header')<{ +export const Header = styled((props: ContainerProps<'header'>) => { + const hasPageFrame = useHasPageFrameFeature(); + return ( + + ); +})<{ borderStyle?: 'dashed' | 'solid'; noActionWrap?: boolean; /** @@ -91,7 +101,6 @@ export const Header = styled('header')<{ p.noActionWrap ? 'minmax(0, 1fr) auto' : 'minmax(0, 1fr)'}; padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl}; - background-color: ${p => p.theme.tokens.background.primary}; ${p => !p.unified && diff --git a/static/app/components/noAccess.tsx b/static/app/components/noAccess.tsx index 93809db5c9f2e7..666b37644c7ff5 100644 --- a/static/app/components/noAccess.tsx +++ b/static/app/components/noAccess.tsx @@ -1,16 +1,16 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; export function NoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/components/pageFilters/container.tsx b/static/app/components/pageFilters/container.tsx index c521d67cf5d5d4..2b1d73b87cc34d 100644 --- a/static/app/components/pageFilters/container.tsx +++ b/static/app/components/pageFilters/container.tsx @@ -1,7 +1,8 @@ import {Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import isEqual from 'lodash/isEqual'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import type {InitializeUrlStateParams} from 'sentry/components/pageFilters/actions'; import { @@ -252,9 +253,9 @@ export function PageFiltersContainer({ // would speed up orgs with tons of projects if (!isReady || !hasInitialized) { return ( - + - + ); } diff --git a/static/app/components/pipeline/usePipeline.tsx b/static/app/components/pipeline/usePipeline.tsx index cdca24c0cca27a..4a7715ed3fa6aa 100644 --- a/static/app/components/pipeline/usePipeline.tsx +++ b/static/app/components/pipeline/usePipeline.tsx @@ -198,8 +198,6 @@ export function usePipeline< error: new Error((response.data?.detail as string) ?? 'Pipeline error'), }); break; - default: - break; } }, onError: (error: Error, _variables, context) => { diff --git a/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx index 5cc7e93b437db5..59d384efc6d769 100644 --- a/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx @@ -114,8 +114,6 @@ export function useSearchTokenCombobox( case 'ArrowRight': state.selectionManager.setFocusedKey(null); break; - default: - break; } }; diff --git a/static/app/components/workflowEngine/layout/detail.tsx b/static/app/components/workflowEngine/layout/detail.tsx index 496d4038752f19..1e41b75763bbf6 100644 --- a/static/app/components/workflowEngine/layout/detail.tsx +++ b/static/app/components/workflowEngine/layout/detail.tsx @@ -1,12 +1,13 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import * as Layout from 'sentry/components/layouts/thirds'; import {HeaderActions} from 'sentry/components/layouts/thirds'; import type {AvatarProject} from 'sentry/types/project'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; interface WorkflowEngineDetailLayoutProps { /** @@ -20,13 +21,15 @@ interface WorkflowEngineDetailLayoutProps { * Precomposed 67/33 layout for Monitors / Alerts detail pages. */ function DetailLayoutComponent({children}: WorkflowEngineDetailLayoutProps) { - return {children}; + // TODO(JonasBadalic): Remove this once the page-frame feature is GA'd + const hasPageFrame = useHasPageFrameFeature(); + return ( + + {children} + + ); } -const StyledPage = styled(Layout.Page)` - background: ${p => p.theme.tokens.background.primary}; -`; - const StyledBody = styled(Layout.Body)` display: flex; flex-direction: column; diff --git a/static/app/components/workflowEngine/layout/edit.tsx b/static/app/components/workflowEngine/layout/edit.tsx index bf56ed28866bd4..ea648469bd13f7 100644 --- a/static/app/components/workflowEngine/layout/edit.tsx +++ b/static/app/components/workflowEngine/layout/edit.tsx @@ -9,6 +9,7 @@ import {HeaderActions} from 'sentry/components/layouts/thirds'; import {FullHeightForm} from 'sentry/components/workflowEngine/form/fullHeightForm'; import {StickyFooter} from 'sentry/components/workflowEngine/ui/footer'; import type {AvatarProject} from 'sentry/types/project'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; interface WorkflowEngineEditLayoutProps { /** @@ -23,18 +24,17 @@ interface WorkflowEngineEditLayoutProps { * Precomposed layout for Monitors / Alerts edit pages with form handling. */ function EditLayoutComponent({children, formProps}: WorkflowEngineEditLayoutProps) { + // TODO(JonasBadalic): Remove this once the page-frame feature is GA'd + const hasPageFrame = useHasPageFrameFeature(); return ( - {children} + + {children} + ); } -const StyledPage = styled(Layout.Page)` - background: ${p => p.theme.tokens.background.primary}; - flex: unset; -`; - const StyledLayoutHeader = styled(Layout.Header)` background-color: ${p => p.theme.tokens.background.primary}; `; diff --git a/static/app/components/workflowEngine/layout/list.tsx b/static/app/components/workflowEngine/layout/list.tsx index 1f22a02e092306..428ff70ff34e63 100644 --- a/static/app/components/workflowEngine/layout/list.tsx +++ b/static/app/components/workflowEngine/layout/list.tsx @@ -1,4 +1,4 @@ -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; @@ -28,7 +28,7 @@ export function WorkflowEngineListLayout({ const organization = useOrganization(); return ( - + @@ -47,6 +47,6 @@ export function WorkflowEngineListLayout({ - + ); } diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 6cf1358bd2d2c1..643cfe07f05265 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -99,10 +99,7 @@ export interface RepositoryWithSettings extends Repository { }; } -export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = [ - 'on_ready_for_review', - 'on_new_commit', -]; +export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = ['on_ready_for_review']; /** * Integration Repositories from OrganizationIntegrationReposEndpoint diff --git a/static/app/utils/extractJsonFromText.spec.ts b/static/app/utils/extractJsonFromText.spec.ts new file mode 100644 index 00000000000000..7d618fb6c15c58 --- /dev/null +++ b/static/app/utils/extractJsonFromText.spec.ts @@ -0,0 +1,483 @@ +import {extractJsonFromText, findMatchingBracket} from './extractJsonFromText'; + +describe('findMatchingBracket', () => { + it('finds matching curly brace', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('finds matching square bracket', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles nested braces', () => { + expect(findMatchingBracket('{{{}}}', 0)).toBe(5); + }); + + it('handles mixed bracket types', () => { + expect(findMatchingBracket('{[{}]}', 0)).toBe(5); + }); + + it('returns -1 for unmatched opening brace', () => { + expect(findMatchingBracket('{', 0)).toBe(-1); + }); + + it('returns -1 for unmatched opening bracket', () => { + expect(findMatchingBracket('[', 0)).toBe(-1); + }); + + it('starts from the given position', () => { + expect(findMatchingBracket('xx{yy}zz', 2)).toBe(5); + }); + + it('ignores braces inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "}"}', 0)).toBe(11); + }); + + it('ignores brackets inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "]"}', 0)).toBe(11); + }); + + it('handles escaped quotes inside strings', () => { + expect(findMatchingBracket('{"key": "val\\"ue"}', 0)).toBe(17); + }); + + it('handles escaped backslash before closing quote', () => { + // The value is a string ending with a literal backslash: "val\\" + // In the JSON: {"k": "val\\"} — the \\\\ is an escaped backslash, + // so the quote after it closes the string. + expect(findMatchingBracket('{"k": "val\\\\"}', 0)).toBe(13); + }); + + it('handles multiple escaped characters in a string', () => { + expect(findMatchingBracket('{"k": "a\\nb\\tc"}', 0)).toBe(15); + }); + + it('handles empty object', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('handles empty array', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles deeply nested structure', () => { + expect(findMatchingBracket('[[[[[]]]]]', 0)).toBe(9); + }); + + it('handles string containing opening braces', () => { + expect(findMatchingBracket('{"braces": "{{{"}', 0)).toBe(16); + }); + + it('handles string containing brackets and braces mixed', () => { + expect(findMatchingBracket('{"val": "[{]"}', 0)).toBe(13); + }); + + it('returns -1 when string has unbalanced quotes disrupting matching', () => { + // An unclosed string means the closing brace is "inside" the string + expect(findMatchingBracket('{"key: }', 0)).toBe(-1); + }); +}); + +describe('extractJsonFromText', () => { + describe('basic extraction', () => { + it('returns empty array for empty string', () => { + expect(extractJsonFromText('')).toEqual([]); + }); + + it('returns a single text segment for plain text', () => { + expect(extractJsonFromText('hello world')).toEqual([ + {type: 'text', value: 'hello world'}, + ]); + }); + + it('extracts a standalone JSON object', () => { + expect(extractJsonFromText('{"key": "value"}')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + + it('extracts a standalone JSON array', () => { + expect(extractJsonFromText('[1, 2, 3]')).toEqual([ + {type: 'json', value: '[1, 2, 3]'}, + ]); + }); + + it('extracts JSON object with surrounding text', () => { + expect(extractJsonFromText('prefix {"key": "value"} suffix')).toEqual([ + {type: 'text', value: 'prefix '}, + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' suffix'}, + ]); + }); + + it('extracts JSON array with surrounding text', () => { + expect(extractJsonFromText('data: [1, 2, 3] end')).toEqual([ + {type: 'text', value: 'data: '}, + {type: 'json', value: '[1, 2, 3]'}, + {type: 'text', value: ' end'}, + ]); + }); + + it('extracts JSON at the very start', () => { + expect(extractJsonFromText('{"key": "value"} trailing')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' trailing'}, + ]); + }); + + it('extracts JSON at the very end', () => { + expect(extractJsonFromText('leading {"key": "value"}')).toEqual([ + {type: 'text', value: 'leading '}, + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + }); + + describe('multiple JSON values', () => { + it('extracts multiple JSON objects', () => { + expect(extractJsonFromText('a {"x": 1} b {"y": 2} c')).toEqual([ + {type: 'text', value: 'a '}, + {type: 'json', value: '{"x": 1}'}, + {type: 'text', value: ' b '}, + {type: 'json', value: '{"y": 2}'}, + {type: 'text', value: ' c'}, + ]); + }); + + it('extracts adjacent JSON objects without separator', () => { + expect(extractJsonFromText('{"a": 1}{"b": 2}')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'json', value: '{"b": 2}'}, + ]); + }); + + it('extracts mixed objects and arrays', () => { + expect(extractJsonFromText('obj: {"a": 1} arr: [2, 3]')).toEqual([ + {type: 'text', value: 'obj: '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' arr: '}, + {type: 'json', value: '[2, 3]'}, + ]); + }); + }); + + describe('nested structures', () => { + it('handles nested objects', () => { + expect(extractJsonFromText('r: {"a": {"b": {"c": 1}}}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": {"b": {"c": 1}}}'}, + ]); + }); + + it('handles nested arrays', () => { + expect(extractJsonFromText('r: [[1, [2]], [3]]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[[1, [2]], [3]]'}, + ]); + }); + + it('handles objects containing arrays', () => { + expect(extractJsonFromText('r: {"a": [1, 2, 3]}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": [1, 2, 3]}'}, + ]); + }); + + it('handles arrays containing objects', () => { + expect(extractJsonFromText('r: [{"a": 1}, {"b": 2}]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[{"a": 1}, {"b": 2}]'}, + ]); + }); + }); + + describe('string literal handling (where naive packages fail)', () => { + it('handles braces inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "{user}"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "{user}"}'}, + ]); + }); + + it('handles brackets inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "[item]"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "[item]"}'}, + ]); + }); + + it('handles closing brace inside a string value', () => { + // This is the case that breaks balanced-match and extract-json-from-string + expect(extractJsonFromText('x {"key": "}"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "}"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles closing bracket inside a string value', () => { + expect(extractJsonFromText('x {"key": "]"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "]"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles escaped quotes in string values', () => { + expect(extractJsonFromText('d: {"msg": "say \\"hello\\""}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"msg": "say \\"hello\\""}'}, + ]); + }); + + it('handles escaped backslash before closing quote', () => { + // Value is literally: val\ (backslash at end) + // JSON encoding: "val\\" — the \\\\ is an escaped backslash + expect(extractJsonFromText('d: {"k": "val\\\\"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "val\\\\"}'}, + ]); + }); + + it('handles newlines and tabs in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "line1\\nline2"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "line1\\nline2"}'}, + ]); + }); + + it('handles unicode escapes in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "caf\\u00e9"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "caf\\u00e9"}'}, + ]); + }); + }); + + describe('non-JSON braces treated as text', () => { + it('treats template-style braces as text', () => { + expect(extractJsonFromText('hello {name} world')).toEqual([ + {type: 'text', value: 'hello {name} world'}, + ]); + }); + + it('treats unmatched opening brace as text', () => { + expect(extractJsonFromText('not {json')).toEqual([ + {type: 'text', value: 'not {json'}, + ]); + }); + + it('treats unmatched opening bracket as text', () => { + expect(extractJsonFromText('not [json')).toEqual([ + {type: 'text', value: 'not [json'}, + ]); + }); + + it('treats matched but syntactically invalid JSON as text', () => { + expect(extractJsonFromText('{invalid json content}')).toEqual([ + {type: 'text', value: '{invalid json content}'}, + ]); + }); + + it('treats Python-style dicts as text', () => { + expect(extractJsonFromText("data: {'key': 'value'}")).toEqual([ + {type: 'text', value: "data: {'key': 'value'}"}, + ]); + }); + + it('treats braces in code snippets as text', () => { + expect(extractJsonFromText('function() { return 1; }')).toEqual([ + {type: 'text', value: 'function() { return 1; }'}, + ]); + }); + + it('treats CSS-like braces as text', () => { + expect(extractJsonFromText('.class { color: red; }')).toEqual([ + {type: 'text', value: '.class { color: red; }'}, + ]); + }); + + it('treats multiple template vars as text', () => { + expect(extractJsonFromText('{user} logged in from {ip}')).toEqual([ + {type: 'text', value: '{user} logged in from {ip}'}, + ]); + }); + }); + + describe('JSON value types', () => { + it('does not treat bare primitives as JSON segments', () => { + expect(extractJsonFromText('value: 123')).toEqual([ + {type: 'text', value: 'value: 123'}, + ]); + }); + + it('extracts arrays of primitives', () => { + expect(extractJsonFromText('[true, false, null]')).toEqual([ + {type: 'json', value: '[true, false, null]'}, + ]); + }); + + it('extracts objects with various value types', () => { + const json = '{"s": "str", "n": 42, "b": true, "x": null}'; + expect(extractJsonFromText(`d: ${json}`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + ]); + }); + + it('extracts empty object', () => { + expect(extractJsonFromText('empty: {}')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '{}'}, + ]); + }); + + it('extracts empty array', () => { + expect(extractJsonFromText('empty: []')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '[]'}, + ]); + }); + + it('extracts array of strings', () => { + expect(extractJsonFromText('tags: ["a", "b", "c"]')).toEqual([ + {type: 'text', value: 'tags: '}, + {type: 'json', value: '["a", "b", "c"]'}, + ]); + }); + }); + + describe('text preservation invariant', () => { + const cases = [ + 'hello world', + 'prefix {"key": "value"} suffix', + 'a {"x": 1} b {"y": 2} c', + '{"a": 1}{"b": 2}', + 'no json here {invalid} at all', + 'mixed {"valid": true} and {invalid} stuff', + '', + '{"only": "json"}', + 'trailing text after {"json": true}', + '{"json": true} leading text before', + 'braces } without { matching [ pairs ]', + 'log: {"pattern": "{user}"}', + ]; + + it.each(cases)('concatenating segments reproduces the original: %s', input => { + const segments = extractJsonFromText(input); + const reconstructed = segments.map(s => s.value).join(''); + expect(reconstructed).toBe(input); + }); + }); + + describe('mixed valid and invalid JSON', () => { + it('extracts valid JSON surrounded by invalid braces', () => { + expect(extractJsonFromText('{bad} {"good": true} {bad}')).toEqual([ + {type: 'text', value: '{bad} '}, + {type: 'json', value: '{"good": true}'}, + {type: 'text', value: ' {bad}'}, + ]); + }); + + it('handles valid JSON after several invalid brace pairs', () => { + expect(extractJsonFromText('{a} {b} {c} {"d": 1}')).toEqual([ + {type: 'text', value: '{a} {b} {c} '}, + {type: 'json', value: '{"d": 1}'}, + ]); + }); + + it('handles invalid brace pair after valid JSON', () => { + expect(extractJsonFromText('{"a": 1} {b} done')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' {b} done'}, + ]); + }); + }); + + describe('whitespace handling', () => { + it('preserves whitespace in text segments', () => { + expect(extractJsonFromText(' {"a": 1} ')).toEqual([ + {type: 'text', value: ' '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' '}, + ]); + }); + + it('handles JSON with internal whitespace', () => { + expect(extractJsonFromText('d: { "key" : "value" }')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{ "key" : "value" }'}, + ]); + }); + + it('handles multiline JSON', () => { + const json = '{\n "key": "value"\n}'; + expect(extractJsonFromText(`d: ${json} end`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + {type: 'text', value: ' end'}, + ]); + }); + }); + + describe('real-world log patterns', () => { + it('extracts JSON from a log line', () => { + expect( + extractJsonFromText( + '2024-01-15 10:30:00 INFO {"event": "login", "user": "alice"}' + ) + ).toEqual([ + {type: 'text', value: '2024-01-15 10:30:00 INFO '}, + {type: 'json', value: '{"event": "login", "user": "alice"}'}, + ]); + }); + + it('extracts JSON from a message with context', () => { + expect( + extractJsonFromText( + 'This is my JSON: { "it": "would be", "nice": ["to", "highlight"], "this": true }' + ) + ).toEqual([ + {type: 'text', value: 'This is my JSON: '}, + { + type: 'json', + value: '{ "it": "would be", "nice": ["to", "highlight"], "this": true }', + }, + ]); + }); + + it('extracts JSON from an error message', () => { + expect( + extractJsonFromText( + 'Failed to process request: {"error": "timeout", "code": 504} - retrying' + ) + ).toEqual([ + {type: 'text', value: 'Failed to process request: '}, + {type: 'json', value: '{"error": "timeout", "code": 504}'}, + {type: 'text', value: ' - retrying'}, + ]); + }); + + it('handles a log line with no JSON', () => { + expect( + extractJsonFromText('2024-01-15 10:30:00 INFO User logged in successfully') + ).toEqual([ + { + type: 'text', + value: '2024-01-15 10:30:00 INFO User logged in successfully', + }, + ]); + }); + + it('handles a stack trace style message with braces', () => { + expect( + extractJsonFromText('Error at MyClass.method(file.java:42) caused by {unknown}') + ).toEqual([ + { + type: 'text', + value: 'Error at MyClass.method(file.java:42) caused by {unknown}', + }, + ]); + }); + }); +}); diff --git a/static/app/utils/extractJsonFromText.ts b/static/app/utils/extractJsonFromText.ts new file mode 100644 index 00000000000000..e056b5b474cdbf --- /dev/null +++ b/static/app/utils/extractJsonFromText.ts @@ -0,0 +1,136 @@ +type TextSegment = {type: 'text'; value: string}; +type JsonSegment = {type: 'json'; value: string}; + +export type ExtractedSegment = TextSegment | JsonSegment; + +/** + * Finds the position of the matching closing bracket for a `{` or `[` + * at position `start`. Correctly handles JSON string literals — bracket + * characters inside double-quoted strings are ignored, and backslash + * escapes within strings are respected. + * + * Returns the index of the matching closing bracket, or -1 if the + * brackets are unbalanced. + */ +export function findMatchingBracket(text: string, start: number): number { + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\' && inString) { + escaped = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch === '{' || ch === '[') { + depth++; + } + if (ch === '}' || ch === ']') { + depth--; + } + + if (depth === 0) { + return i; + } + } + + return -1; +} + +/** + * Extracts JSON object and array substrings from arbitrary text. + * + * Scans `text` for `{` / `[` characters, uses string-aware bracket + * matching to find the candidate closing bracket, then validates the + * candidate with `JSON.parse`. Returns an array of segments preserving + * the full original text — every character appears in exactly one + * segment, and concatenating all segment values reproduces the input. + * + * Only objects and arrays are recognized as JSON segments; bare + * primitives like `"hello"`, `42`, or `true` are left as text. + * + * @example + * extractJsonFromText('msg: {"level":"info"} ok') + * // [ + * // { type: 'text', value: 'msg: ' }, + * // { type: 'json', value: '{"level":"info"}' }, + * // { type: 'text', value: ' ok' }, + * // ] + */ +export function extractJsonFromText(text: string): ExtractedSegment[] { + const segments: ExtractedSegment[] = []; + let i = 0; + + while (i < text.length) { + let nextStart = -1; + for (let j = i; j < text.length; j++) { + if (text[j] === '{' || text[j] === '[') { + nextStart = j; + break; + } + } + + if (nextStart === -1) { + if (i < text.length) { + segments.push({type: 'text', value: text.slice(i)}); + } + break; + } + + if (nextStart > i) { + segments.push({type: 'text', value: text.slice(i, nextStart)}); + } + + const matchEnd = findMatchingBracket(text, nextStart); + if (matchEnd === -1) { + segments.push({type: 'text', value: text.slice(nextStart)}); + break; + } + + const candidate = text.slice(nextStart, matchEnd + 1); + try { + const parsed = JSON.parse(candidate); + if (typeof parsed === 'object' && parsed !== null) { + segments.push({type: 'json', value: candidate}); + i = matchEnd + 1; + } else { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } catch { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } + + // Merge consecutive text segments produced when invalid candidates + // cause the scanner to advance one character at a time. + const merged: ExtractedSegment[] = []; + for (const segment of segments) { + const last = merged[merged.length - 1]; + if (segment.type === 'text' && last?.type === 'text') { + last.value += segment.value; + } else { + merged.push(segment); + } + } + + return merged; +} diff --git a/static/app/views/alerts/create.tsx b/static/app/views/alerts/create.tsx index 7179f1f4db1bcf..206b4556c42008 100644 --- a/static/app/views/alerts/create.tsx +++ b/static/app/views/alerts/create.tsx @@ -1,4 +1,6 @@ -import {Fragment, useEffect, useRef} from 'react'; +import {useEffect, Fragment, useRef} from 'react'; + +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -137,7 +139,7 @@ export default function Create() { const title = t('New Alert Rule'); return ( - + @@ -228,6 +230,6 @@ export default function Create() { )} - + ); } diff --git a/static/app/views/alerts/edit.tsx b/static/app/views/alerts/edit.tsx index a5dced1453d396..ea1199d97a5c04 100644 --- a/static/app/views/alerts/edit.tsx +++ b/static/app/views/alerts/edit.tsx @@ -1,4 +1,6 @@ -import {Fragment, useState} from 'react'; +import {useState, Fragment} from 'react'; + +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -61,7 +63,7 @@ export default function ProjectAlertsEditor() { const {teams, isLoading: teamsLoading} = useUserTeams(); return ( - + )} - + ); } diff --git a/static/app/views/alerts/list/incidents/index.tsx b/static/app/views/alerts/list/incidents/index.tsx index eb2d9e0b008c59..9ba65f77dca19f 100644 --- a/static/app/views/alerts/list/incidents/index.tsx +++ b/static/app/views/alerts/list/incidents/index.tsx @@ -4,6 +4,7 @@ import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts'; @@ -263,7 +264,7 @@ class IncidentsList extends DeprecatedAsyncComponent< return ( - + @@ -286,7 +287,7 @@ class IncidentsList extends DeprecatedAsyncComponent< - + ); } @@ -305,7 +306,7 @@ export default function IncidentsListContainer() { }, []); const renderDisabled = () => ( - + @@ -315,7 +316,7 @@ export default function IncidentsListContainer() { - + ); return ( diff --git a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx index 63933dfadfe752..53583dd0d8ebe0 100644 --- a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx +++ b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx @@ -79,7 +79,6 @@ function LastMetricAlertIncident({rule}: {rule: MetricAlert}) { } export function AlertLastIncidentActivationInfo({rule}: Props) { - // eslint-disable-next-line default-case switch (rule.type) { case CombinedAlertType.UPTIME: return ; diff --git a/static/app/views/alerts/list/rules/alertRulesList.tsx b/static/app/views/alerts/list/rules/alertRulesList.tsx index 8d37a4e8d81516..b03e27616335ed 100644 --- a/static/app/views/alerts/list/rules/alertRulesList.tsx +++ b/static/app/views/alerts/list/rules/alertRulesList.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import { @@ -210,7 +211,7 @@ export default function AlertRulesList() { - + @@ -337,7 +338,7 @@ export default function AlertRulesList() { - + ); } diff --git a/static/app/views/alerts/rules/crons/details.tsx b/static/app/views/alerts/rules/crons/details.tsx index c190833c5e8492..2368fe7d238e87 100644 --- a/static/app/views/alerts/rules/crons/details.tsx +++ b/static/app/views/alerts/rules/crons/details.tsx @@ -1,8 +1,8 @@ -import {Fragment, useCallback, useState} from 'react'; +import {useCallback, useState, Fragment} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {updateMonitor} from 'sentry/actionCreators/monitors'; import {SectionHeading} from 'sentry/components/charts/styles'; @@ -111,14 +111,14 @@ export default function MonitorDetails() { if (!monitor) { return ( - + - + ); } return ( - + @@ -189,7 +189,7 @@ export default function MonitorDetails() { - + ); } diff --git a/static/app/views/alerts/rules/issue/details/ruleDetails.tsx b/static/app/views/alerts/rules/issue/details/ruleDetails.tsx index 2040b0d26c7628..b797d399f51ea0 100644 --- a/static/app/views/alerts/rules/issue/details/ruleDetails.tsx +++ b/static/app/views/alerts/rules/issue/details/ruleDetails.tsx @@ -4,7 +4,7 @@ import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -383,7 +383,7 @@ export default function AlertRuleDetails() { const {period, start, end, utc} = getDataDatetime(); const cursor = decodeScalar(location.query.cursor); return ( - + - + ); } diff --git a/static/app/views/alerts/rules/metric/details/index.tsx b/static/app/views/alerts/rules/metric/details/index.tsx index d754868aedec1d..2278526fbf1cd2 100644 --- a/static/app/views/alerts/rules/metric/details/index.tsx +++ b/static/app/views/alerts/rules/metric/details/index.tsx @@ -5,11 +5,11 @@ import pick from 'lodash/pick'; import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {fetchOrgMembers} from 'sentry/actionCreators/members'; import type {Client} from 'sentry/api'; import {DateTime} from 'sentry/components/dateTime'; -import * as Layout from 'sentry/components/layouts/thirds'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; @@ -252,7 +252,7 @@ class MetricAlertDetails extends Component { const {error} = this.state; return ( - + {error?.status === 404 @@ -260,7 +260,7 @@ class MetricAlertDetails extends Component { : t('An error occurred while fetching the alert rule.')} - + ); } diff --git a/static/app/views/alerts/rules/uptime/details.tsx b/static/app/views/alerts/rules/uptime/details.tsx index 5090d57e83c25b..126821537e2a0b 100644 --- a/static/app/views/alerts/rules/uptime/details.tsx +++ b/static/app/views/alerts/rules/uptime/details.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {updateUptimeRule} from 'sentry/actionCreators/uptime'; @@ -121,7 +121,7 @@ export default function UptimeAlertDetails() { ); return ( - + @@ -213,7 +213,7 @@ export default function UptimeAlertDetails() { /> - + ); } diff --git a/static/app/views/alerts/wizard/index.tsx b/static/app/views/alerts/wizard/index.tsx index 64539d234fffa5..5fab7389bb7b30 100644 --- a/static/app/views/alerts/wizard/index.tsx +++ b/static/app/views/alerts/wizard/index.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import styled from '@emotion/styled'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import Feature from 'sentry/components/acl/feature'; @@ -162,7 +162,7 @@ export default function AlertWizard() { ); const panelContent = getAlertWizardPanelContent({hasMetricIssues})[alertOption]; return ( - + @@ -237,7 +237,7 @@ export default function AlertWizard() { - + ); } diff --git a/static/app/views/automations/edit.tsx b/static/app/views/automations/edit.tsx index d840e4aedc6446..4e8208fb03ea46 100644 --- a/static/app/views/automations/edit.tsx +++ b/static/app/views/automations/edit.tsx @@ -3,7 +3,7 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import type {FieldValue} from 'sentry/components/forms/model'; @@ -226,7 +226,7 @@ function AutomationEditForm({automation}: {automation: Automation}) { > - + @@ -263,7 +263,7 @@ function AutomationEditForm({automation}: {automation: Automation}) { - + diff --git a/static/app/views/automations/new.tsx b/static/app/views/automations/new.tsx index eac0539d4be51a..0f18039d850e0e 100644 --- a/static/app/views/automations/new.tsx +++ b/static/app/views/automations/new.tsx @@ -5,7 +5,7 @@ import * as Sentry from '@sentry/react'; import {Observer} from 'mobx-react-lite'; import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {FormModel} from 'sentry/components/forms/model'; @@ -193,7 +193,7 @@ export default function AutomationNewSettings() { > - + @@ -229,7 +229,7 @@ export default function AutomationNewSettings() { - + diff --git a/static/app/views/dashboards/create.tsx b/static/app/views/dashboards/create.tsx index 6b97b7d333370c..6f6fa8378330bb 100644 --- a/static/app/views/dashboards/create.tsx +++ b/static/app/views/dashboards/create.tsx @@ -1,10 +1,10 @@ import {useState} from 'react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -46,13 +46,13 @@ export default function CreateDashboard() { function renderDisabled() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/dashboards/createFromSeer.tsx b/static/app/views/dashboards/createFromSeer.tsx index 3003fdbb3fb046..be8d41746e63e4 100644 --- a/static/app/views/dashboards/createFromSeer.tsx +++ b/static/app/views/dashboards/createFromSeer.tsx @@ -2,11 +2,11 @@ import {memo, useCallback, useEffect, useRef, useState} from 'react'; import * as Sentry from '@sentry/react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {validateDashboard} from 'sentry/actionCreators/dashboards'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; @@ -291,13 +291,13 @@ export default function CreateFromSeer() { if (!hasFeature) { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/dashboards/createFromSeerLoading.tsx b/static/app/views/dashboards/createFromSeerLoading.tsx index dc39f3502b5cd7..495802b39a8a67 100644 --- a/static/app/views/dashboards/createFromSeerLoading.tsx +++ b/static/app/views/dashboards/createFromSeerLoading.tsx @@ -1,8 +1,8 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; import {BlockComponent} from 'sentry/views/seerExplorer/blockComponents'; import type {Block} from 'sentry/views/seerExplorer/types'; @@ -13,8 +13,9 @@ interface CreateFromSeerLoadingProps { export function CreateFromSeerLoading({blocks, seerRunId}: CreateFromSeerLoadingProps) { const blocksToRender = blocks.slice(-3); + const hasPageFrame = useHasPageFrameFeature(); return ( - + {t('Generating Dashboard')} @@ -40,6 +41,6 @@ export function CreateFromSeerLoading({blocks, seerRunId}: CreateFromSeerLoading - + ); } diff --git a/static/app/views/dashboards/createFromSeerPrompt.tsx b/static/app/views/dashboards/createFromSeerPrompt.tsx index 7bbc2eb68c4007..f9271ced0b1a50 100644 --- a/static/app/views/dashboards/createFromSeerPrompt.tsx +++ b/static/app/views/dashboards/createFromSeerPrompt.tsx @@ -1,12 +1,11 @@ import {useCallback, useState} from 'react'; import {Button} from '@sentry/scraps/button'; -import {Container, Flex} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; import {TextArea} from '@sentry/scraps/textarea'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {fetchMutation} from 'sentry/utils/queryClient'; @@ -14,6 +13,7 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; export function CreateFromSeerPrompt() { const organization = useOrganization(); @@ -21,6 +21,7 @@ export function CreateFromSeerPrompt() { const navigate = useNavigate(); const [prompt, setPrompt] = useState(''); const [isGenerating, setIsGenerating] = useState(false); + const hasPageFrame = useHasPageFrameFeature(); const handleGenerate = useCallback(async () => { if (!prompt.trim()) { @@ -61,7 +62,7 @@ export function CreateFromSeerPrompt() { }, [prompt, organization.slug, location.query, navigate]); return ( - + {t('Describe your Dashboard')} @@ -100,6 +101,6 @@ export function CreateFromSeerPrompt() { - + ); } diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index fd7c8c80970ebc..ce2dd5aec10275 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -9,6 +9,8 @@ import isEqualWith from 'lodash/isEqualWith'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; +import {Stack} from '@sentry/scraps/layout'; + import { createDashboard, deleteDashboard, @@ -986,7 +988,7 @@ class DashboardDetail extends Component { }, }} > - + @@ -1061,7 +1063,7 @@ class DashboardDetail extends Component { - + ); } @@ -1108,7 +1110,7 @@ class DashboardDetail extends Component { ); const pageContent = ( - + @@ -1319,7 +1321,7 @@ class DashboardDetail extends Component { - + ); return ( diff --git a/static/app/views/dashboards/manage/dashboardGrid.spec.tsx b/static/app/views/dashboards/manage/dashboardGrid.spec.tsx index d9ac3775c24853..d9c2b8c8888053 100644 --- a/static/app/views/dashboards/manage/dashboardGrid.spec.tsx +++ b/static/app/views/dashboards/manage/dashboardGrid.spec.tsx @@ -1,9 +1,7 @@ import {DashboardListItemFixture} from 'sentry-fixture/dashboard'; -import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {UserFixture} from 'sentry-fixture/user'; -import {initializeOrg} from 'sentry-test/initializeOrg'; import { render, renderGlobalModal, @@ -25,8 +23,6 @@ describe('Dashboards - DashboardGrid', () => { features: ['dashboards-basic', 'dashboards-edit', 'discover-query'], }); - const {router} = initializeOrg(); - beforeEach(() => { MockApiClient.clearMockResponses(); @@ -106,7 +102,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={[]} - location={router.location} columnCount={3} rowCount={3} /> @@ -124,7 +119,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} /> @@ -140,7 +134,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} /> @@ -156,42 +149,28 @@ describe('Dashboards - DashboardGrid', () => { ); }); - it('persists global selection headers', () => { + it('does not forward query params from the list page to dashboard links', () => { render( - ); - - expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( - 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' - ); - }); - - it('does not forward search query parameter to dashboard links', () => { - render( - + />, + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/dashboards/', + query: {sort: 'title', query: 'agent'}, + }, + }, + } ); expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' + '/organizations/org-slug/dashboard/1/' ); }); @@ -200,7 +179,6 @@ describe('Dashboards - DashboardGrid', () => { { { { { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} />, @@ -383,7 +357,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} />, diff --git a/static/app/views/dashboards/manage/dashboardGrid.tsx b/static/app/views/dashboards/manage/dashboardGrid.tsx index a70f67c68cc06f..8ce43191c6b3c1 100644 --- a/static/app/views/dashboards/manage/dashboardGrid.tsx +++ b/static/app/views/dashboards/manage/dashboardGrid.tsx @@ -1,6 +1,5 @@ import {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; -import type {Location} from 'history'; import isEqual from 'lodash/isEqual'; import {Button} from '@sentry/scraps/button'; @@ -36,7 +35,6 @@ type Props = { api: Client; columnCount: number; dashboards: DashboardListItem[] | undefined; - location: Location; onDashboardsChange: () => void; organization: Organization; rowCount: number; @@ -46,7 +44,6 @@ type Props = { function DashboardGrid({ api, organization, - location, dashboards, onDashboardsChange, rowCount, @@ -167,13 +164,6 @@ function DashboardGrid({ return ; } - // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react - // router 6 handles empty query objects without appending a trailing ? - const {query: _searchQuery, ...queryWithoutSearch} = location.query; - const queryLocation = { - ...(Object.keys(queryWithoutSearch).length > 0 ? {query: queryWithoutSearch} : {}), - }; - function renderMiniDashboards() { // on pagination, render no dashboards to show placeholders while loading if ( @@ -189,10 +179,7 @@ function DashboardGrid({ {dashboardLimitData => ( { ); }); - it('persists global selection headers', async () => { - render( - - ); - - expect(await screen.findByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( - 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' - ); - }); - - it('does not forward search query parameter to dashboard links', async () => { + it('does not forward query params from the list page to dashboard links', async () => { render( { dashboards={dashboards} location={{ ...LocationFixture(), - query: {query: 'agent', statsPeriod: '7d'}, + query: {sort: 'title', query: 'agent', statsPeriod: '7d'}, }} /> ); expect(await screen.findByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' + '/organizations/org-slug/dashboard/1/' ); }); diff --git a/static/app/views/dashboards/manage/dashboardTable.tsx b/static/app/views/dashboards/manage/dashboardTable.tsx index dd581aaa3f87fc..77911b2e7c2ec5 100644 --- a/static/app/views/dashboards/manage/dashboardTable.tsx +++ b/static/app/views/dashboards/manage/dashboardTable.tsx @@ -146,13 +146,6 @@ function DashboardTable({ {key: ResponseKeys.CREATED, name: t('Created'), width: COL_WIDTH_UNDEFINED}, ]; - // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react - // router 6 handles empty query objects without appending a trailing ? - const {query: _searchQuery, ...queryWithoutSearch} = location.query; - const queryLocation = { - ...(Object.keys(queryWithoutSearch).length > 0 ? {query: queryWithoutSearch} : {}), - }; - function renderHeadCell(column: GridColumnOrder) { if (column.key in SortKeys) { const urlSort = decodeScalar(location.query.sort, 'mydashboards'); @@ -212,12 +205,7 @@ function DashboardTable({ if (column.key === ResponseKeys.NAME) { return ( - + {dataRow[ResponseKeys.NAME]} diff --git a/static/app/views/dashboards/manage/index.spec.tsx b/static/app/views/dashboards/manage/index.spec.tsx index a9e0fd8276493d..1aed58bc3fe193 100644 --- a/static/app/views/dashboards/manage/index.spec.tsx +++ b/static/app/views/dashboards/manage/index.spec.tsx @@ -123,10 +123,7 @@ describe('Dashboards > Detail', () => { await userEvent.click(await screen.findByTestId('dashboard-create')); - expect(mockNavigate).toHaveBeenCalledWith({ - pathname: '/organizations/org-slug/dashboards/new/', - query: {}, - }); + expect(mockNavigate).toHaveBeenCalledWith('/organizations/org-slug/dashboards/new/'); }); it('can sort', async () => { diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index 4e90d37c651b40..54b2fed0369b4d 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -9,7 +9,7 @@ import {Alert} from '@sentry/scraps/alert'; import {FeatureBadge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex, Grid} from '@sentry/scraps/layout'; +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; import {Switch} from '@sentry/scraps/switch'; @@ -478,13 +478,13 @@ function ManageDashboards() { function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } @@ -494,7 +494,6 @@ function ManageDashboards() { api={api} dashboards={dashboards} organization={organization} - location={location} onDashboardsChange={() => refetchDashboards()} isLoading={isLoading} rowCount={rowCount} @@ -543,19 +542,12 @@ function ManageDashboards() { ); } - const {query: _query, ...queryWithoutSearch} = location.query; - function onCreate() { trackAnalytics('dashboards_manage.create.start', { organization, }); - navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/new/`, - query: queryWithoutSearch, - }) - ); + navigate(normalizeUrl(`/organizations/${organization.slug}/dashboards/new/`)); } async function onAdd(dashboard: DashboardDetails) { @@ -578,10 +570,7 @@ function ManageDashboards() { function loadDashboard(dashboardId: string) { navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/${dashboardId}/`, - query: queryWithoutSearch, - }) + normalizeUrl(`/organizations/${organization.slug}/dashboards/${dashboardId}/`) ); } @@ -592,10 +581,7 @@ function ManageDashboards() { }); navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/new/${dashboardId}/`, - query: queryWithoutSearch, - }) + normalizeUrl(`/organizations/${organization.slug}/dashboards/new/${dashboardId}/`) ); } @@ -622,11 +608,11 @@ function ManageDashboards() { > {isError ? ( - + - + ) : ( - + @@ -769,7 +755,7 @@ function ManageDashboards() { - + )} diff --git a/static/app/views/dashboards/orgDashboards.tsx b/static/app/views/dashboards/orgDashboards.tsx index 90d4d6cf5a119b..d87ac9a2d2ed72 100644 --- a/static/app/views/dashboards/orgDashboards.tsx +++ b/static/app/views/dashboards/orgDashboards.tsx @@ -1,8 +1,9 @@ import {useEffect, useMemo, useRef, useState} from 'react'; import isEqual from 'lodash/isEqual'; +import {Stack} from '@sentry/scraps/layout'; + import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; @@ -202,9 +203,9 @@ export function OrgDashboards({children, initialDashboard}: OrgDashboardsProps) if (isDashboardsPending || isSelectedDashboardLoading || isPrebuiltDashboardLoading) { return ( - + - + ); } @@ -218,9 +219,9 @@ export function OrgDashboards({children, initialDashboard}: OrgDashboardsProps) // the URL does not contain filters yet. The filters can either match the // saved filters, or can be different (i.e. sharing an unsaved state) return ( - + - + ); } diff --git a/static/app/views/dashboards/view.tsx b/static/app/views/dashboards/view.tsx index 77a79023d2395b..5d80e57ce226d1 100644 --- a/static/app/views/dashboards/view.tsx +++ b/static/app/views/dashboards/view.tsx @@ -1,12 +1,12 @@ import {useEffect} from 'react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {updateDashboardVisit} from 'sentry/actionCreators/dashboards'; import Feature from 'sentry/components/acl/feature'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; @@ -69,13 +69,13 @@ type FeatureProps = { export function DashboardBasicFeature({organization, children}: FeatureProps) { const renderDisabled = () => ( - + {t("You don't have access to this feature")} - + ); return ( diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx index a6a0321cdf0166..c1820a384d0d31 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx @@ -1176,8 +1176,6 @@ export function useWidgetBuilderState(): { setTextContent(action.payload); break; } - default: - break; } }, [ diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx index 4cc71e78dec4da..d7cc2572da0013 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx @@ -386,8 +386,6 @@ function onTriggerCellAction(actions: Actions, value: string | number) { case Actions.EXCLUDE: setFilter(filter.filter(_value => _value !== value)); break; - default: - break; } } `} diff --git a/static/app/views/detectors/components/forms/error/index.tsx b/static/app/views/detectors/components/forms/error/index.tsx index 0162ee8cb5fcad..61687f255fce58 100644 --- a/static/app/views/detectors/components/forms/error/index.tsx +++ b/static/app/views/detectors/components/forms/error/index.tsx @@ -102,13 +102,13 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { export function NewErrorDetectorForm() { return ( - + - + ); } diff --git a/static/app/views/detectors/components/forms/index.tsx b/static/app/views/detectors/components/forms/index.tsx index 9fd522bc77574a..7f89bd34232249 100644 --- a/static/app/views/detectors/components/forms/index.tsx +++ b/static/app/views/detectors/components/forms/index.tsx @@ -1,4 +1,5 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; @@ -29,13 +30,13 @@ import { function PlaceholderForm() { return ( - + - + ); } diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 0339be2b780b28..395e84f6cba5c8 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -1,6 +1,8 @@ import orderBy from 'lodash/orderBy'; import {parseAsString, useQueryState} from 'nuqs'; +import {Stack} from '@sentry/scraps/layout'; + import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -28,13 +30,13 @@ export default function DetectorNewSettings() { if (isFetchingProjects) { return ( - + - + ); } diff --git a/static/app/views/discover/index.tsx b/static/app/views/discover/index.tsx index dcecc5a77aed8b..672a6ed58d6f6d 100644 --- a/static/app/views/discover/index.tsx +++ b/static/app/views/discover/index.tsx @@ -1,9 +1,9 @@ import {Outlet} from 'react-router-dom'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {Redirect} from 'sentry/components/redirect'; import {t} from 'sentry/locale'; @@ -23,13 +23,13 @@ function DiscoverContainer() { function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index 6ba57d02acaba7..0c14426e395671 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Switch} from '@sentry/scraps/switch'; @@ -46,13 +47,13 @@ const SORT_OPTIONS = [ function NoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } @@ -187,7 +188,7 @@ function DiscoverLanding() { renderDisabled={() => } > - + - + ); diff --git a/static/app/views/discover/results.tsx b/static/app/views/discover/results.tsx index cd36e27052ecf6..7857fa88e69e55 100644 --- a/static/app/views/discover/results.tsx +++ b/static/app/views/discover/results.tsx @@ -7,6 +7,7 @@ import omit from 'lodash/omit'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {updateSavedQueryVisit} from 'sentry/actionCreators/discoverSavedQueries'; @@ -836,7 +837,7 @@ export class Results extends Component { return ( - + { - + ); } diff --git a/static/app/views/discover/table/cellAction.tsx b/static/app/views/discover/table/cellAction.tsx index 95dabbceb94942..481ef92763d174 100644 --- a/static/app/views/discover/table/cellAction.tsx +++ b/static/app/views/discover/table/cellAction.tsx @@ -317,8 +317,6 @@ function getInternalLinkActionLabel(field: string): string { return t('Open issue'); case FieldKey.REPLAY_ID: return t('Open replay'); - default: - break; } return t('Open link'); } diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 0e6b030fb87dc1..a0d4b3c51f00de 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -233,4 +233,52 @@ describe('AttributesTreeValue', () => { .closest('pre'); expect(pre).not.toHaveClass('compact'); }); + + it('renders inline JSON highlighting for text containing a JSON object', () => { + const content = { + ...defaultProps.content, + value: 'msg: {"level": "info"}', + }; + + render(); + + const wrapper = screen.getByText(/msg/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders inline JSON highlighting for text containing a JSON array', () => { + const content = { + ...defaultProps.content, + value: 'tags: [1, 2, 3]', + }; + + render(); + + const wrapper = screen.getByText(/tags/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders URL with brackets as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api?filter=[1,2]', + }; + + render(); + + const link = screen.getByText('https://example.com/api?filter=[1,2]').closest('a'); + expect(link).toBeInTheDocument(); + }); + + it('renders URL with braces as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api/{id}', + }; + + render(); + + const link = screen.getByText('https://example.com/api/{id}').closest('a'); + expect(link).toBeInTheDocument(); + }); }); diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index aeb856dcbf0ad0..861ffef99dd363 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -7,6 +7,7 @@ import {StructuredEventData} from 'sentry/components/structuredEventData'; import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {isUrl} from 'sentry/utils/string/isUrl'; import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip'; +import {InlineJsonHighlight} from 'sentry/views/explore/components/traceItemAttributes/inlineJsonHighlight'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; import {TraceItemMetaInfo} from 'sentry/views/explore/utils'; @@ -94,20 +95,26 @@ export function AttributesTreeValue ); } - return isUrl(value) ? ( - - { - e.preventDefault(); - openNavigateToExternalLinkModal({linkText: value}); - }} - > - {defaultValue} - - - ) : ( - defaultValue - ); + if (isUrl(value)) { + return ( + + { + e.preventDefault(); + openNavigateToExternalLinkModal({linkText: value}); + }} + > + {defaultValue} + + + ); + } + + if (value.includes('{') || value.includes('[')) { + return ; + } + + return defaultValue; } const AttributeLinkText = styled('span')` diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx new file mode 100644 index 00000000000000..190b8243642238 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx @@ -0,0 +1,33 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {InlineJsonHighlight} from './inlineJsonHighlight'; + +describe('InlineJsonHighlight', () => { + it('renders plain text without highlighting', () => { + render(); + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); + + it('renders text with embedded JSON and highlights JSON portion', () => { + render(); + const wrapper = screen.getByText(/prefix/); + expect(wrapper).toHaveTextContent('prefix {"key": "value"} suffix'); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders invalid braces as plain text', () => { + render(); + expect(screen.getByText('not {json')).toBeInTheDocument(); + }); + + it('renders template-style braces as plain text', () => { + render(); + expect(screen.getByText('hello {name} world')).toBeInTheDocument(); + }); + + it('uses code element for JSON segments', () => { + render(); + const wrapper = screen.getByText(/data/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx new file mode 100644 index 00000000000000..527cd003aa0659 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx @@ -0,0 +1,57 @@ +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {extractJsonFromText} from 'sentry/utils/extractJsonFromText'; +import {usePrismTokens} from 'sentry/utils/usePrismTokens'; + +function JsonSegment({json}: {json: string}) { + const lines = usePrismTokens({code: json, language: 'json'}); + + return ( + + {lines.map((line, lineIdx) => ( + + {line.map((token, tokenIdx) => ( + + {token.children} + + ))} + + ))} + + ); +} + +/** + * Renders a string with inline syntax highlighting for embedded JSON. + * JSON objects and arrays within the text are colorized using Prism's JSON grammar. + * Non-JSON text is rendered as-is. + */ +export function InlineJsonHighlight({value}: {value: string}) { + const segments = useMemo(() => extractJsonFromText(value), [value]); + + if (segments.length === 1 && segments[0]!.type === 'text') { + return {value}; + } + + return ( + + {segments.map((segment, idx) => + segment.type === 'json' ? ( + + ) : ( + {segment.value} + ) + )} + + ); +} + +const InlineCode = styled('code')` + && { + background: transparent; + padding: 0; + white-space: pre-wrap; + font-size: inherit; + } +`; diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index d1b63c1ad9e917..e787f23a3e34b6 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -65,8 +65,6 @@ export function useAttributeBreakdownsTooltipAction(): TooltipActions['onAction' case Actions.COPY_TO_CLIPBOARD: copyToClipboard.copy(value); break; - default: - break; } }, [addSearchFilter, setGroupBys, copyToClipboard] @@ -136,7 +134,6 @@ export function useAttributeBreakdownsTooltip({ dom.addEventListener('click', handleClickAnywhere); dom.addEventListener('mouseleave', handleMouseLeave); - // eslint-disable-next-line consistent-return return () => { dom.removeEventListener('click', handleClickAnywhere); dom.removeEventListener('mouseleave', handleMouseLeave); @@ -185,7 +182,6 @@ export function useAttributeBreakdownsTooltip({ document.addEventListener('mouseover', handleMouseOver); document.addEventListener('mouseout', handleMouseOut); - // eslint-disable-next-line consistent-return return () => { document.removeEventListener('click', handleClickActions); document.removeEventListener('mouseover', handleMouseOver); diff --git a/static/app/views/explore/hooks/useCrossEventQueries.tsx b/static/app/views/explore/hooks/useCrossEventQueries.tsx index f0f74ae4fc9dd2..0e2c12b5431109 100644 --- a/static/app/views/explore/hooks/useCrossEventQueries.tsx +++ b/static/app/views/explore/hooks/useCrossEventQueries.tsx @@ -30,8 +30,6 @@ export function useCrossEventQueries() { case 'logs': logQuery.push(crossEvent.query); break; - default: - break; } } diff --git a/static/app/views/explore/logs/content.tsx b/static/app/views/explore/logs/content.tsx index f05313e9123946..dff06c1ebaefc7 100644 --- a/static/app/views/explore/logs/content.tsx +++ b/static/app/views/explore/logs/content.tsx @@ -1,5 +1,5 @@ import {LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -61,7 +61,7 @@ export default function LogsContent() { analyticsPageSource={LogsAnalyticsPageSource.EXPLORE_LOGS} source="location" > - + {defined(onboardingProject) ? ( @@ -74,7 +74,7 @@ export default function LogsContent() { )} - + diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index f5658dae658d6b..9086df330465d8 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -157,15 +157,12 @@ export const LogDetailTableActionsCell = styled(TableBodyCell)` padding: ${p => p.theme.space.xs} ${p => p.theme.space.xl}; } &:last-child { - padding: ${p => p.theme.space.xs} ${p => p.theme.space.xl}; + padding: ${p => p.theme.space.xs} 0; } `; export const LogDetailTableActionsButtonBar = styled('div')` display: flex; gap: ${p => p.theme.space.md}; - & button { - font-weight: ${p => p.theme.font.weight.sans.regular}; - } `; export const DetailsWrapper = styled('tr')` diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index 5519c4a8fb03d0..f1977b6caf2c79 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -612,13 +612,13 @@ function LogRowDetails({ } function LogRowDetailsFilterActions({tableDataRow}: {tableDataRow: LogTableRowItem}) { - const theme = useTheme(); const addSearchFilter = useAddSearchFilter(); return ( } onClick={() => { addSearchFilter({ key: OurLogKnownFieldKey.MESSAGE, @@ -626,12 +626,12 @@ function LogRowDetailsFilterActions({tableDataRow}: {tableDataRow: LogTableRowIt }); }} > - {t('Add to filter')} } onClick={() => { addSearchFilter({ key: OurLogKnownFieldKey.MESSAGE, @@ -640,7 +640,6 @@ function LogRowDetailsFilterActions({tableDataRow}: {tableDataRow: LogTableRowIt }); }} > - {t('Exclude from filter')} @@ -654,7 +653,6 @@ function LogRowDetailsActions({ fullLogDataResult: UseApiQueryResult; tableDataRow: LogTableRowItem; }) { - const theme = useTheme(); const {data, isPending, isError} = fullLogDataResult; const isFrozen = useLogsFrozenIsFrozen(); const organization = useOrganization(); @@ -689,12 +687,12 @@ function LogRowDetailsActions({ )} } onClick={betterCopyToClipboard} disabled={isPending || isError || !json} > - {t('Copy as JSON')} diff --git a/static/app/views/explore/metrics/content.tsx b/static/app/views/explore/metrics/content.tsx index ec5f771097bf57..d0dadd8ea97ee5 100644 --- a/static/app/views/explore/metrics/content.tsx +++ b/static/app/views/explore/metrics/content.tsx @@ -1,4 +1,5 @@ import {FeatureBadge} from '@sentry/scraps/badge'; +import {Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -48,7 +49,7 @@ export default function MetricsContent() { : undefined } > - + {defined(onboardingProject) ? ( )} - + ); diff --git a/static/app/views/explore/multiQueryMode/index.tsx b/static/app/views/explore/multiQueryMode/index.tsx index a1f1ad708dbc4e..e429ca30a6d101 100644 --- a/static/app/views/explore/multiQueryMode/index.tsx +++ b/static/app/views/explore/multiQueryMode/index.tsx @@ -1,4 +1,4 @@ -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; @@ -62,9 +62,9 @@ export default function MultiQueryMode() { - + - + ); diff --git a/static/app/views/explore/savedQueries/index.tsx b/static/app/views/explore/savedQueries/index.tsx index 32e3003239ebb0..0b44748131f42d 100644 --- a/static/app/views/explore/savedQueries/index.tsx +++ b/static/app/views/explore/savedQueries/index.tsx @@ -1,7 +1,7 @@ import {useNavigate} from 'react-router-dom'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; @@ -41,7 +41,7 @@ export default function SavedQueriesView() { return ( - + {t('All Queries')} @@ -88,7 +88,7 @@ export default function SavedQueriesView() { - + ); } diff --git a/static/app/views/explore/spans/content.tsx b/static/app/views/explore/spans/content.tsx index ebf11833118eb9..ed4a320a8ff288 100644 --- a/static/app/views/explore/spans/content.tsx +++ b/static/app/views/explore/spans/content.tsx @@ -2,7 +2,7 @@ import type {ReactNode} from 'react'; import {useMemo} from 'react'; import * as Sentry from '@sentry/react'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -83,7 +83,7 @@ function ExploreContentInner() { return ( - + {defined(onboardingProject) ? ( @@ -96,7 +96,7 @@ function ExploreContentInner() { )} - + ); diff --git a/static/app/views/feedback/index.tsx b/static/app/views/feedback/index.tsx index 17fb02de008045..92b1f827452fce 100644 --- a/static/app/views/feedback/index.tsx +++ b/static/app/views/feedback/index.tsx @@ -1,7 +1,8 @@ import {Outlet} from 'react-router-dom'; +import {Stack} from '@sentry/scraps/layout'; + import {AnalyticsArea} from 'sentry/components/analyticsArea'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {Redirect} from 'sentry/components/redirect'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -21,11 +22,11 @@ export default function FeedbackContainer() { return ( - + - + ); } diff --git a/static/app/views/insights/common/components/modulePageProviders.tsx b/static/app/views/insights/common/components/modulePageProviders.tsx index 4dea7e1c77ee3a..225eb75ce5dac4 100644 --- a/static/app/views/insights/common/components/modulePageProviders.tsx +++ b/static/app/views/insights/common/components/modulePageProviders.tsx @@ -1,4 +1,5 @@ -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter'; @@ -54,11 +55,11 @@ export function ModulePageProviders({ storageNamespace={view} > - + {children} - + ); diff --git a/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx b/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx index 93025fb1ed4efb..e8185ce773598e 100644 --- a/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx +++ b/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx @@ -3,6 +3,7 @@ import {useState} from 'react'; import omit from 'lodash/omit'; import {FeatureBadge, type FeatureBadgeProps} from '@sentry/scraps/badge'; +import {Stack} from '@sentry/scraps/layout'; import {TabList, Tabs} from '@sentry/scraps/tabs'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -125,7 +126,7 @@ function ScreenDetailsPage() { return ( - + handleTabChange(tabKey)}> - + ); } diff --git a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx index 89c089c855c4ac..72d5789750ea3b 100644 --- a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx +++ b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx @@ -2,6 +2,8 @@ import {useCallback, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; +import {Stack} from '@sentry/scraps/layout'; + import {ErrorBoundary} from 'sentry/components/errorBoundary'; import * as Layout from 'sentry/components/layouts/thirds'; import {TabbedCodeSnippet} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet'; @@ -264,7 +266,7 @@ function ScreensLandingPage() { moduleName={ModuleName.MOBILE_VITALS} maxPickableDays={maxPickableDays.maxPickableDays} > - + @@ -323,7 +325,7 @@ function ScreensLandingPage() { - + ); } diff --git a/static/app/views/insights/pages/conversations/layout.tsx b/static/app/views/insights/pages/conversations/layout.tsx index 9df28e8b02b4b9..4c51d48557a18d 100644 --- a/static/app/views/insights/pages/conversations/layout.tsx +++ b/static/app/views/insights/pages/conversations/layout.tsx @@ -1,6 +1,7 @@ import {Outlet, useMatches} from 'react-router-dom'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {ConversationsPageHeader} from 'sentry/views/insights/pages/conversations/conversationsPageHeader'; import {ModuleName} from 'sentry/views/insights/types'; @@ -8,12 +9,12 @@ function ConversationsLayout() { const handle = useMatches().at(-1)?.handle as {module?: ModuleName} | undefined; return ( - + {handle && 'module' in handle ? ( ) : null} - + ); } diff --git a/static/app/views/issueDetails/groupEventCarousel.tsx b/static/app/views/issueDetails/groupEventCarousel.tsx index bed30de0333f21..54d7ffa48295cb 100644 --- a/static/app/views/issueDetails/groupEventCarousel.tsx +++ b/static/app/views/issueDetails/groupEventCarousel.tsx @@ -230,8 +230,6 @@ function EventNavigationDropdown({group, event, isDisabled}: GroupEventNavigatio }); break; } - default: - break; } }} /> diff --git a/static/app/views/issueDetails/groupReplays/groupReplays.tsx b/static/app/views/issueDetails/groupReplays/groupReplays.tsx index fb23253dcae55c..c1c7da78a7b31d 100644 --- a/static/app/views/issueDetails/groupReplays/groupReplays.tsx +++ b/static/app/views/issueDetails/groupReplays/groupReplays.tsx @@ -5,7 +5,6 @@ import type {Location, Query} from 'history'; import {Button} from '@sentry/scraps/button'; import {Flex, Stack} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {Placeholder} from 'sentry/components/placeholder'; import { SelectedReplayIndexProvider, @@ -118,7 +117,7 @@ function GroupReplaysContent({group}: Props) { if (!eventView) { // Shown on load and no replay data available return ( - + @@ -145,7 +144,7 @@ function GroupReplaysContent({group}: Props) { return ( - + @@ -325,7 +324,7 @@ function ReplayOverlay({ ); } -const StyledLayoutPage = styled(Layout.Page)` +const StyledLayoutPage = styled(Stack)` background-color: ${p => p.theme.tokens.background.primary}; gap: ${p => p.theme.space.lg}; border: 1px solid ${p => p.theme.tokens.border.primary}; diff --git a/static/app/views/issueDetails/groupReplays/index.tsx b/static/app/views/issueDetails/groupReplays/index.tsx index 7a0b8aadd2afa5..58cec5dd873e76 100644 --- a/static/app/views/issueDetails/groupReplays/index.tsx +++ b/static/app/views/issueDetails/groupReplays/index.tsx @@ -1,7 +1,7 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; @@ -13,13 +13,13 @@ import {GroupReplays} from './groupReplays'; function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/issueList/editableIssueViewHeader.tsx b/static/app/views/issueList/editableIssueViewHeader.tsx index 6ee4dbebaa4d0b..03567dd6a2ffc5 100644 --- a/static/app/views/issueList/editableIssueViewHeader.tsx +++ b/static/app/views/issueList/editableIssueViewHeader.tsx @@ -101,8 +101,6 @@ function EditingViewTitle({ case 'Escape': stopEditing(); break; - default: - break; } }; diff --git a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx index e0bb7976a2cb1a..565c4980aebdb8 100644 --- a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx +++ b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import Feature from 'sentry/components/acl/feature'; @@ -358,7 +358,7 @@ export default function IssueViewsList() { return ( - + {t('All Views')} @@ -460,7 +460,7 @@ export default function IssueViewsList() { /> - + ); } diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index 7701534b39a2be..008349d65a7d28 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -9,9 +9,10 @@ import omit from 'lodash/omit'; import pickBy from 'lodash/pickBy'; import * as qs from 'query-string'; +import {Stack} from '@sentry/scraps/layout'; + import {addMessage} from 'sentry/actionCreators/indicator'; import {fetchOrgMembers, indexMembersByProject} from 'sentry/actionCreators/members'; -import * as Layout from 'sentry/components/layouts/thirds'; import {extractSelectionParameters} from 'sentry/components/pageFilters/parse'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import type {CursorHandler} from 'sentry/components/pagination'; @@ -873,7 +874,7 @@ function IssueListOverview({ const {numPreviousIssues, numIssuesOnPage} = getPageCounts(); return ( - + - + ); } diff --git a/static/app/views/issueList/pages/supergroups.tsx b/static/app/views/issueList/pages/supergroups.tsx index 4a011a4edc9984..03868ea3009a35 100644 --- a/static/app/views/issueList/pages/supergroups.tsx +++ b/static/app/views/issueList/pages/supergroups.tsx @@ -120,7 +120,7 @@ function Supergroups() { } return ( - + @@ -172,7 +172,7 @@ function Supergroups() { )} - + ); } diff --git a/static/app/views/navigation/navigationTour.tsx b/static/app/views/navigation/navigationTour.tsx index 737448fb027f59..b5e238099ef3af 100644 --- a/static/app/views/navigation/navigationTour.tsx +++ b/static/app/views/navigation/navigationTour.tsx @@ -208,8 +208,6 @@ export function NavigationTourProvider({children}: {children: React.ReactNode}) navigate(target, {replace: true}); } break; - default: - break; } }, [activeGroup, navigate, organization] diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index e87436d9f5bb6f..61486f222bce64 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -9,6 +9,7 @@ import {useFeedbackOnboardingDrawer} from 'sentry/components/feedback/feedbackOn import {Footer} from 'sentry/components/footer'; import {GlobalDrawer} from 'sentry/components/globalDrawer'; import {HookOrDefault} from 'sentry/components/hookOrDefault'; +import * as Layout from 'sentry/components/layouts/thirds'; import {usePerformanceOnboardingDrawer} from 'sentry/components/performanceOnboarding/sidebar'; import {useProfilingOnboardingDrawer} from 'sentry/components/profiling/profilingOnboardingSidebar'; import {useReplaysOnboardingDrawer} from 'sentry/components/replaysOnboarding/sidebar'; @@ -93,10 +94,12 @@ function AppLayout({organization}: LayoutProps) { {organization && } - + + + + - {organization ? : null} diff --git a/static/app/views/performance/index.tsx b/static/app/views/performance/index.tsx index a8359fc14b2de2..979f659bb5f52a 100644 --- a/static/app/views/performance/index.tsx +++ b/static/app/views/performance/index.tsx @@ -1,9 +1,9 @@ import {Outlet} from 'react-router-dom'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {t} from 'sentry/locale'; import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality'; @@ -17,13 +17,13 @@ function PerformanceContainer() { function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index f366f5a5ffd8bc..adedd207d7155e 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -2,9 +2,8 @@ import {useEffect, useMemo, useRef} from 'react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; -import {Flex, type FlexProps} from '@sentry/scraps/layout'; +import {Flex, Stack, type FlexProps} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; @@ -152,7 +151,7 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { orgSlug={organization.slug} > - + - + - + diff --git a/static/app/views/performance/transactionSummary/transactionReplays/index.tsx b/static/app/views/performance/transactionSummary/transactionReplays/index.tsx index 69a5c57d39499a..2b467b700753c4 100644 --- a/static/app/views/performance/transactionSummary/transactionReplays/index.tsx +++ b/static/app/views/performance/transactionSummary/transactionReplays/index.tsx @@ -1,7 +1,7 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -9,13 +9,13 @@ import {TransactionReplays} from './transactionReplays'; function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/permissionDenied.tsx b/static/app/views/permissionDenied.tsx index 963c461ff6bef0..361af23fdf6ca5 100644 --- a/static/app/views/permissionDenied.tsx +++ b/static/app/views/permissionDenied.tsx @@ -1,9 +1,9 @@ import {useEffect} from 'react'; import * as Sentry from '@sentry/react'; +import {Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; @@ -26,7 +26,7 @@ export function PermissionDenied() { return ( - + - + ); } diff --git a/static/app/views/preprod/buildComparison/buildComparison.tsx b/static/app/views/preprod/buildComparison/buildComparison.tsx index e64984b7950404..f849aa016be605 100644 --- a/static/app/views/preprod/buildComparison/buildComparison.tsx +++ b/static/app/views/preprod/buildComparison/buildComparison.tsx @@ -1,6 +1,7 @@ import {useTheme} from '@emotion/react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -88,7 +89,7 @@ export default function BuildComparison() { if (headBuildDetailsQuery.isLoading) { return ( - + - + ); } @@ -128,7 +129,7 @@ export default function BuildComparison() { return ( - + {mainContent} - + ); } diff --git a/static/app/views/preprod/buildDetails/buildDetails.spec.tsx b/static/app/views/preprod/buildDetails/buildDetails.spec.tsx index e10ffc2621a3d4..bdcda024fdf4bb 100644 --- a/static/app/views/preprod/buildDetails/buildDetails.spec.tsx +++ b/static/app/views/preprod/buildDetails/buildDetails.spec.tsx @@ -64,7 +64,6 @@ describe('BuildDetails', () => { initialRouterConfig, }); - expect(screen.getByRole('main')).toBeInTheDocument(); expect(screen.getByRole('banner')).toBeInTheDocument(); const loadingPlaceholders = screen.getAllByTestId('loading-placeholder'); diff --git a/static/app/views/preprod/buildDetails/buildDetails.tsx b/static/app/views/preprod/buildDetails/buildDetails.tsx index 76650cf11b67db..1b629d34837019 100644 --- a/static/app/views/preprod/buildDetails/buildDetails.tsx +++ b/static/app/views/preprod/buildDetails/buildDetails.tsx @@ -151,7 +151,7 @@ export default function BuildDetails() { ) { return ( - + )} - + ); } return ( - + - + ); } diff --git a/static/app/views/preprod/index.tsx b/static/app/views/preprod/index.tsx index 642b74bd12db4c..9e39711f2e7356 100644 --- a/static/app/views/preprod/index.tsx +++ b/static/app/views/preprod/index.tsx @@ -1,9 +1,9 @@ import {Outlet} from 'react-router-dom'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -16,13 +16,13 @@ export default function PreprodContainer() { features={['organizations:preprod-frontend-routes']} organization={organization} renderDisabled={() => ( - + {t("You don't have access to this feature")} - + )} > diff --git a/static/app/views/preprod/install/installPage.tsx b/static/app/views/preprod/install/installPage.tsx index d359d1f86edbe2..dea044dd56af1b 100644 --- a/static/app/views/preprod/install/installPage.tsx +++ b/static/app/views/preprod/install/installPage.tsx @@ -1,4 +1,4 @@ -import {Container, Flex} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -37,7 +37,7 @@ export default function InstallPage() { ); return ( - + - + ); } diff --git a/static/app/views/preprod/snapshots/snapshots.tsx b/static/app/views/preprod/snapshots/snapshots.tsx index 77c15e8fa05202..77edd78b1a79f0 100644 --- a/static/app/views/preprod/snapshots/snapshots.tsx +++ b/static/app/views/preprod/snapshots/snapshots.tsx @@ -2,7 +2,7 @@ import {useCallback, useDeferredValue, useEffect, useMemo, useRef, useState} fro import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -368,11 +368,11 @@ export default function SnapshotsPage() { if (isPending) { return ( - + - + ); } @@ -380,18 +380,18 @@ export default function SnapshotsPage() { if (isError || !data) { return ( - + {t('Unable to load snapshot data.')} - + ); } return ( - + {isComparisonProcessing ? processingContent : snapshotContent} - + ); } diff --git a/static/app/views/profiling/content.tsx b/static/app/views/profiling/content.tsx index de3b1c3f4c6c65..8b3f3b0fa1e571 100644 --- a/static/app/views/profiling/content.tsx +++ b/static/app/views/profiling/content.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {TabList, Tabs} from '@sentry/scraps/tabs'; import Feature from 'sentry/components/acl/feature'; @@ -157,7 +158,7 @@ export default function ProfilingContent() { : undefined } > - + @@ -243,7 +244,7 @@ export default function ProfilingContent() { )} - + ); diff --git a/static/app/views/profiling/continuousProfileProvider.tsx b/static/app/views/profiling/continuousProfileProvider.tsx index bbd1aec47844e8..fd05600c909cd8 100644 --- a/static/app/views/profiling/continuousProfileProvider.tsx +++ b/static/app/views/profiling/continuousProfileProvider.tsx @@ -66,7 +66,7 @@ export default function ProfileAndTransactionProvider(): React.ReactElement { setProfile={setProfile} > - + + ( - + {t("You don't have access to this feature")} - + )} > diff --git a/static/app/views/profiling/layoutPageWithHiddenFooter.tsx b/static/app/views/profiling/layoutPageWithHiddenFooter.tsx index ff8ace1842eae0..778ac099ebd525 100644 --- a/static/app/views/profiling/layoutPageWithHiddenFooter.tsx +++ b/static/app/views/profiling/layoutPageWithHiddenFooter.tsx @@ -1,11 +1,14 @@ import styled from '@emotion/styled'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; // The footer component is a sibling of this div. // Remove it so the flamegraph can take up the // entire screen. -export const LayoutPageWithHiddenFooter = styled(Layout.Page)` + +// @TODO(JonasBadalic): Remove this component once the page-frame feature is GA'd +// When that feature is enabled, the footer is no longer rendered at the bottom of the page. +export const LayoutPageWithHiddenFooter = styled(Stack)` ~ footer { display: none; } diff --git a/static/app/views/profiling/profileSummary/index.tsx b/static/app/views/profiling/profileSummary/index.tsx index d4137d3717c93d..b8aa39f5432e3b 100644 --- a/static/app/views/profiling/profileSummary/index.tsx +++ b/static/app/views/profiling/profileSummary/index.tsx @@ -773,7 +773,7 @@ const ProfileDigestLabel = styled('span')` export default function ProfileSummaryPageToggle() { return ( - + diff --git a/static/app/views/profiling/transactionProfileProvider.tsx b/static/app/views/profiling/transactionProfileProvider.tsx index 34a810413dc3ff..d46d1bc6f47c4f 100644 --- a/static/app/views/profiling/transactionProfileProvider.tsx +++ b/static/app/views/profiling/transactionProfileProvider.tsx @@ -47,7 +47,7 @@ export default function ProfileAndTransactionProvider(): React.ReactElement { setProfile={setProfile} > - + + - + ); } if (!loadingProjects && project && !project.hasAccess) { return ( - + - + ); } @@ -176,7 +176,7 @@ export function ProjectDetail() { skipLoadLastUsed showAbsolute={!hasOnlyBasicChart} > - + @@ -308,7 +308,7 @@ export function ProjectDetail() { - + ); diff --git a/static/app/views/projectEventRedirect.tsx b/static/app/views/projectEventRedirect.tsx index c704cf768d8b97..f6126ebd0214eb 100644 --- a/static/app/views/projectEventRedirect.tsx +++ b/static/app/views/projectEventRedirect.tsx @@ -1,9 +1,10 @@ import {useEffect} from 'react'; +import {Stack} from '@sentry/scraps/layout'; + import {DetailedError} from 'sentry/components/errors/detailedError'; import {NotFound} from 'sentry/components/errors/notFound'; import {getEventTimestampInSeconds} from 'sentry/components/events/interfaces/utils'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; @@ -122,9 +123,9 @@ export function ProjectEventRedirect() { (!isPending && event) // Prevents flash of loading error below once event is loaded successfully ) { return ( - + - + ); } diff --git a/static/app/views/projectInstall/gettingStarted.tsx b/static/app/views/projectInstall/gettingStarted.tsx index a2a8490c2bea2e..1be7999afbae67 100644 --- a/static/app/views/projectInstall/gettingStarted.tsx +++ b/static/app/views/projectInstall/gettingStarted.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Redirect} from 'sentry/components/redirect'; import {allPlatforms} from 'sentry/data/platforms'; @@ -30,7 +31,7 @@ export default function GettingStarted() { const currentPlatform = allPlatforms.find(p => p.id === currentPlatformKey); return ( - + {loadingProjects ? ( ) : project ? ( @@ -47,7 +48,6 @@ export default function GettingStarted() { ); } -const GettingStartedLayout = styled(Layout.Page)` +const GettingStartedLayout = styled(Stack)` background: ${p => p.theme.tokens.background.primary}; - padding-top: ${p => p.theme.space['2xl']}; `; diff --git a/static/app/views/projectInstall/newProject.tsx b/static/app/views/projectInstall/newProject.tsx index 19c5ea0d47685a..cc421c134bb87f 100644 --- a/static/app/views/projectInstall/newProject.tsx +++ b/static/app/views/projectInstall/newProject.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {CreateProject} from './createProject'; @@ -8,7 +9,7 @@ import {CreateProject} from './createProject'; function NewProject() { return ( - + @@ -16,7 +17,7 @@ function NewProject() { - + ); } diff --git a/static/app/views/projects/projectContext.tsx b/static/app/views/projects/projectContext.tsx index 4caea7c72e5876..f65201b364b905 100644 --- a/static/app/views/projects/projectContext.tsx +++ b/static/app/views/projects/projectContext.tsx @@ -2,11 +2,11 @@ import {Component, createContext} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {fetchOrgMembers} from 'sentry/actionCreators/members'; import {redirectToProject} from 'sentry/actionCreators/redirectToProject'; import type {Client} from 'sentry/api'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {MissingProjectMembership} from 'sentry/components/projects/missingProjectMembership'; @@ -267,13 +267,13 @@ class ProjectContextProvider extends Component { case ErrorTypes.PROJECT_NOT_FOUND: // TODO(chrissy): use scale for margin values return ( - + {t('The project you were looking for was not found.')} - + ); case ErrorTypes.MISSING_MEMBERSHIP: // TODO(dcramer): add various controls to improve this flow and break it diff --git a/static/app/views/projectsDashboard/index.tsx b/static/app/views/projectsDashboard/index.tsx index 8892b00e7e6697..0365f5e0e6dbb9 100644 --- a/static/app/views/projectsDashboard/index.tsx +++ b/static/app/views/projectsDashboard/index.tsx @@ -6,7 +6,7 @@ import debounce from 'lodash/debounce'; import uniqBy from 'lodash/uniqBy'; import {LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -307,13 +307,13 @@ function Dashboard() { function OrganizationDashboard() { const organization = useOrganization(); return ( - + - + ); } diff --git a/static/app/views/pullRequest/details/pullRequestDetails.tsx b/static/app/views/pullRequest/details/pullRequestDetails.tsx index 910c12f15973e8..60631ea8474a60 100644 --- a/static/app/views/pullRequest/details/pullRequestDetails.tsx +++ b/static/app/views/pullRequest/details/pullRequestDetails.tsx @@ -49,7 +49,7 @@ export default function PullRequestDetails() { if (isLoading) { return ( - + Pull Request Details @@ -60,14 +60,14 @@ export default function PullRequestDetails() { - + ); } const errorData = error?.responseJSON as PullRequestDetailsErrorResponse | undefined; if (error || !data) { return ( - + Pull Request Details @@ -90,7 +90,7 @@ export default function PullRequestDetails() { - + ); } @@ -105,7 +105,8 @@ export default function PullRequestDetails() { } return ( - @@ -123,6 +124,6 @@ export default function PullRequestDetails() { {mainContent} - + ); } diff --git a/static/app/views/pullRequest/index.tsx b/static/app/views/pullRequest/index.tsx index ec9fd6b69a14d9..17f25ca454d18c 100644 --- a/static/app/views/pullRequest/index.tsx +++ b/static/app/views/pullRequest/index.tsx @@ -1,9 +1,9 @@ import {Outlet} from 'react-router-dom'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {t} from 'sentry/locale'; import {UrlParamBatchProvider} from 'sentry/utils/url/urlParamBatchContext'; @@ -17,13 +17,13 @@ export default function PullRequestContainer() { features={['organizations:pr-page']} organization={organization} renderDisabled={() => ( - + {t("You don't have access to this feature")} - + )} > diff --git a/static/app/views/releases/detail/index.tsx b/static/app/views/releases/detail/index.tsx index 93919cfd50faf6..c33e8e3a3dadb1 100644 --- a/static/app/views/releases/detail/index.tsx +++ b/static/app/views/releases/detail/index.tsx @@ -4,8 +4,8 @@ import type {Location} from 'history'; import pick from 'lodash/pick'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; @@ -160,7 +160,7 @@ function ReleasesDetail({ ); return ( - + {possiblyWrongProject @@ -168,7 +168,7 @@ function ReleasesDetail({ : t('There was an error loading the release details')} - + ); }, @@ -189,9 +189,9 @@ function ReleasesDetail({ if (isPending) { return ( - + - + ); } @@ -205,7 +205,7 @@ function ReleasesDetail({ return ( - + - + ); } @@ -267,20 +267,20 @@ function ReleasesDetailContainer() { if (isPending) { return ( - + - + ); } if (isError && error.status === 404) { // This catches a 404 coming from the release endpoint and displays a custom error message. return ( - + {t('This release could not be found.')} - + ); } diff --git a/static/app/views/releases/list/index.tsx b/static/app/views/releases/list/index.tsx index b9d4df73486357..1bbf566b9ef3b3 100644 --- a/static/app/views/releases/list/index.tsx +++ b/static/app/views/releases/list/index.tsx @@ -408,7 +408,7 @@ export default function ReleasesList() { return ( - + @@ -571,7 +571,7 @@ export default function ReleasesList() { - + ); } diff --git a/static/app/views/relocation/index.tsx b/static/app/views/relocation/index.tsx index 3e0bcdd02ffc75..0c4aa674056b4a 100644 --- a/static/app/views/relocation/index.tsx +++ b/static/app/views/relocation/index.tsx @@ -1,7 +1,7 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {RelocationOnboarding} from './relocation'; @@ -12,13 +12,13 @@ export default function RelocationOnboardingContainer() { features={['relocation:enabled']} organizationAllowNull renderDisabled={() => ( - + {t("You don't have access to this feature")} - + )} > diff --git a/static/app/views/replays/detail/network/truncateJson/completeJson.ts b/static/app/views/replays/detail/network/truncateJson/completeJson.ts index dc14939e65f5e3..6d234052317987 100644 --- a/static/app/views/replays/detail/network/truncateJson/completeJson.ts +++ b/static/app/views/replays/detail/network/truncateJson/completeJson.ts @@ -37,7 +37,7 @@ export function completeJson(incompleteJson: string, stack: JsonToken[]): string for (let i = lastPos; i >= 0; i--) { const step = stack[i]; - // eslint-disable-next-line default-case + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (step) { case OBJ: json = `${json}}`; diff --git a/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts b/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts index b330ba09e505fc..8aeacf351b259d 100644 --- a/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts +++ b/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts @@ -41,7 +41,6 @@ function _evaluateJsonPos(stack: JsonToken[], json: string, pos: number): void { return; } - // eslint-disable-next-line default-case switch (char) { case '{': _handleObj(stack, curStep); diff --git a/static/app/views/replays/detail/page.tsx b/static/app/views/replays/detail/page.tsx index fd87f93edabfad..119d1eed454c0f 100644 --- a/static/app/views/replays/detail/page.tsx +++ b/static/app/views/replays/detail/page.tsx @@ -1,7 +1,6 @@ -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {ArchivedReplayAlert} from 'sentry/components/replays/alerts/archivedReplayAlert'; import {ReplayLoadingState} from 'sentry/components/replays/player/replayLoadingState'; import {ReplayProcessingError} from 'sentry/components/replays/replayProcessingError'; @@ -18,34 +17,34 @@ export function ReplayDetailsPage({readerResult}: Props) { ( - + - + )} renderError={({fetchError, onRetry}) => ( - + - + )} renderThrottled={({fetchError, onRetry}) => ( - + - + )} renderLoading={({replayRecord}) => ( )} renderMissing={() => ( - + - + )} renderProcessingError={() => ( - + - + )} > {({replay}) => ( diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx index bb770c24253dc7..8f24dc9a20fe4a 100644 --- a/static/app/views/replays/details.tsx +++ b/static/app/views/replays/details.tsx @@ -2,7 +2,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; import invariant from 'invariant'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; import {FullViewport} from 'sentry/components/layouts/fullViewport'; @@ -102,7 +102,7 @@ function ReplayDetailsContent() { return ( - + {replay ? ( - + ); } diff --git a/static/app/views/replays/list.tsx b/static/app/views/replays/list.tsx index 2ac63779f57284..e151a7e3b52ba5 100644 --- a/static/app/views/replays/list.tsx +++ b/static/app/views/replays/list.tsx @@ -1,6 +1,6 @@ import {Fragment} from 'react'; -import {Flex, Grid} from '@sentry/scraps/layout'; +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; import {HookOrDefault} from 'sentry/components/hookOrDefault'; @@ -115,7 +115,7 @@ export default function ReplaysListContainer() { - + @@ -140,7 +140,7 @@ export default function ReplaysListContainer() { - + diff --git a/static/app/views/routeNotFound.tsx b/static/app/views/routeNotFound.tsx index 31f5c267bed389..0921ce8639e535 100644 --- a/static/app/views/routeNotFound.tsx +++ b/static/app/views/routeNotFound.tsx @@ -1,8 +1,9 @@ import {useLayoutEffect} from 'react'; import * as Sentry from '@sentry/react'; +import {Stack} from '@sentry/scraps/layout'; + import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; import {useLocation} from 'sentry/utils/useLocation'; @@ -37,9 +38,9 @@ export function RouteNotFound() { return ( - + - + ); } diff --git a/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx b/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx index 5d5017f550f976..6ecec3a06e8ea0 100644 --- a/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx +++ b/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx @@ -181,8 +181,6 @@ export function AttributeField({ case 'Escape': setShowSuggestions(false); break; - default: - break; } }, [showSuggestions, filteredSuggestions, activeSuggestion, handleClickSuggestion] diff --git a/static/app/views/settings/components/settingsWrapper.tsx b/static/app/views/settings/components/settingsWrapper.tsx index 17b8500c9c5195..65e8eaf8da09e5 100644 --- a/static/app/views/settings/components/settingsWrapper.tsx +++ b/static/app/views/settings/components/settingsWrapper.tsx @@ -5,7 +5,6 @@ import type {Location} from 'history'; import {Flex} from '@sentry/scraps/layout'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; -import * as Layout from 'sentry/components/layouts/thirds'; import {useLocation} from 'sentry/utils/useLocation'; import {useScrollToTop} from 'sentry/utils/useScrollToTop'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; @@ -23,13 +22,11 @@ export function SettingsWrapper() { return ( - - - - - - - + + + + + ); } diff --git a/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts b/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts index ea176b65e6171a..01c62e74a8bfc5 100644 --- a/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts +++ b/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts @@ -112,12 +112,12 @@ function isReactFragment(nameNode: TSESTree.JSXTagNameExpression) { */ function getDisplayName(node: TSESTree.JSXTagNameExpression): string { switch (node.type) { + case AST_NODE_TYPES.JSXIdentifier: + return node.name; case AST_NODE_TYPES.JSXMemberExpression: return `${getDisplayName(node.object)}.${node.property.name}`; case AST_NODE_TYPES.JSXNamespacedName: return `${node.namespace.name}:${node.name.name}`; - default: - return node.name; } } diff --git a/static/gsAdmin/components/customers/organizationStatus.tsx b/static/gsAdmin/components/customers/organizationStatus.tsx index 2a386841ed8468..edec561a505d62 100644 --- a/static/gsAdmin/components/customers/organizationStatus.tsx +++ b/static/gsAdmin/components/customers/organizationStatus.tsx @@ -24,8 +24,6 @@ export function OrganizationStatus({orgStatus}: Props) { case 'deletion_in_progress': message = 'This organization in the process of being deleted.'; break; - default: - break; } if (!message) { diff --git a/static/gsAdmin/components/relocationBadge.tsx b/static/gsAdmin/components/relocationBadge.tsx index 7343270de20858..a6a7abbb1adaeb 100644 --- a/static/gsAdmin/components/relocationBadge.tsx +++ b/static/gsAdmin/components/relocationBadge.tsx @@ -26,8 +26,6 @@ export function RelocationBadge({data}: Props) { text = 'Paused'; theme = 'warning'; break; - default: - break; } if ( diff --git a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py index cbd48aebe29d32..84ba5ddfc3323d 100644 --- a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py +++ b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py @@ -321,7 +321,12 @@ def test_post_existing_code_mapping(self) -> None: response = self.client.post(self.url, data=config_data, format="json") assert response.status_code == 201, response.content - new_code_mapping = RepositoryProjectPathConfig.objects.get( + # Both mappings should coexist: the original and the newly derived one + mappings = RepositoryProjectPathConfig.objects.filter( project=self.project, stack_root="/stack/root" ) - assert new_code_mapping.source_root == "/source/root" + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "/source/root/wrong", + "/source/root", + } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py index de62631476a848..6666b61887a7eb 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py @@ -329,7 +329,7 @@ def test_validate_path_conflict(self) -> None: assert response.status_code == 400 assert response.data == { "nonFieldErrors": [ - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ] } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py index ab668f5e84b74e..e69831f50091a3 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py @@ -96,7 +96,8 @@ def test_update_existing_mapping(self) -> None: project=self.project1, repo=self.repo1, stack_root="com/example/maps", - source_root="old/source/root", + source_root="modules/maps/src/main/java/com/example/maps", + default_branch="old-branch", ) response = self.make_post( @@ -114,16 +115,18 @@ def test_update_existing_mapping(self) -> None: assert response.data["updated"] == 1 config = RepositoryProjectPathConfig.objects.get( - project=self.project1, stack_root="com/example/maps" + project=self.project1, + stack_root="com/example/maps", + source_root="modules/maps/src/main/java/com/example/maps", ) - assert config.source_root == "modules/maps/src/main/java/com/example/maps" + assert config.default_branch == "main" def test_mixed_create_and_update(self) -> None: self.create_code_mapping( project=self.project1, repo=self.repo1, stack_root="com/example/existing", - source_root="old/path", + source_root="existing/path", ) response = self.make_post( @@ -131,7 +134,7 @@ def test_mixed_create_and_update(self) -> None: "mappings": [ { "stackRoot": "com/example/existing", - "sourceRoot": "new/path", + "sourceRoot": "existing/path", }, { "stackRoot": "com/example/new", @@ -440,7 +443,7 @@ def test_repo_from_other_org_returns_404(self) -> None: response = self.make_post({"repository": "other-org/other-repo"}) assert response.status_code == 404 - def test_duplicate_stack_roots_in_request_last_wins(self) -> None: + def test_same_stack_root_different_source_roots_creates_both(self) -> None: response = self.make_post( { "mappings": [ @@ -456,13 +459,17 @@ def test_duplicate_stack_roots_in_request_last_wins(self) -> None: } ) assert response.status_code == 200, response.content - assert response.data["created"] == 1 - assert response.data["updated"] == 1 + assert response.data["created"] == 2 + assert response.data["updated"] == 0 - config = RepositoryProjectPathConfig.objects.get( + configs = RepositoryProjectPathConfig.objects.filter( project=self.project1, stack_root="com/example/maps" ) - assert config.source_root == "second/source/root" + assert configs.count() == 2 + assert set(configs.values_list("source_root", flat=True)) == { + "first/source/root", + "second/source/root", + } def test_multiple_repos_same_name_returns_409(self) -> None: # Intentionally use Repository.objects.create since create_repo uses diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 9057a0c8298fa9..00b735cb361ace 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -429,6 +429,22 @@ def test_github_copilot_not_shown_without_feature_flag(self) -> None: assert len(integrations) == 0 +class OrganizationCodingAgentsPostCodingDisabledTest(BaseOrganizationCodingAgentsTest): + """Test that the endpoint returns 403 when code generation is disabled. + + The check lives in launch_coding_agents_for_run() which raises PermissionDenied. + """ + + def test_post_blocked_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + data = {"integration_id": str(self.integration.id), "run_id": 123} + response = self.get_error_response( + self.organization.slug, method="post", status_code=403, **data + ) + assert response.data["detail"] == "Code generation is disabled for this organization" + + class OrganizationCodingAgentsPostParameterValidationTest(BaseOrganizationCodingAgentsTest): """Test class for POST endpoint parameter validation.""" diff --git a/tests/sentry/issues/auto_source_code_config/test_process_event.py b/tests/sentry/issues/auto_source_code_config/test_process_event.py index 049a06c4f760d3..f6d2531b440484 100644 --- a/tests/sentry/issues/auto_source_code_config/test_process_event.py +++ b/tests/sentry/issues/auto_source_code_config/test_process_event.py @@ -177,10 +177,11 @@ def _process_and_assert_configuration_changes( ) for expected_cm in expected_new_code_mappings: code_mapping = current_code_mappings.get( - project_id=self.project.id, stack_root=expected_cm["stack_root"] + project_id=self.project.id, + stack_root=expected_cm["stack_root"], + source_root=expected_cm["source_root"], ) assert code_mapping is not None - assert code_mapping.source_root == expected_cm["source_root"] assert code_mapping.repository.name == expected_cm["repo_name"] else: assert current_code_mappings.count() == starting_code_mappings_count @@ -939,13 +940,26 @@ def test_prevent_creating_duplicate_rules(self) -> None: self.project.update_option("sentry:grouping_enhancements", "stack.module:foo.bar.** +app") # Manually created code mapping self.create_repo_and_code_mapping(REPO1, "foo/bar/", "src/foo/") - # We do not expect code mappings or in-app rules to be created since - # the developer already created the code mapping and in-app rule + # We do not expect in-app rules to be created since the developer + # already created the in-app rule. A new code mapping is created + # because the source_root differs (src/foo/ vs src/foo/bar/). self._process_and_assert_configuration_changes( repo_trees={REPO1: ["src/foo/bar/Baz.java"]}, frames=[self.frame_from_module("foo.bar.Baz", "Baz.java")], platform=self.platform, + expected_new_code_mappings=[ + self.code_mapping(stack_root="foo/bar/", source_root="src/foo/bar/"), + ], ) + # Both mappings should coexist: the manual one and the auto-created one + mappings = RepositoryProjectPathConfig.objects.filter( + project=self.project, stack_root="foo/bar/" + ) + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "src/foo/", + "src/foo/bar/", + } def test_basic_case(self) -> None: repo_trees = {REPO1: ["src/com/example/foo/Bar.kt"]} diff --git a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py index 08cc8cc5d2423c..bb2b420b456db6 100644 --- a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py +++ b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py @@ -1432,8 +1432,8 @@ def test_skipped_artifacts_not_included_in_status_check(self) -> None: assert "com.valid" in kwargs["summary"] assert "com.skipped" not in kwargs["summary"] - def test_all_skipped_artifacts_no_status_check(self) -> None: - """No status check created when all artifacts are SKIPPED.""" + def test_all_skipped_artifacts_shows_neutral_status(self) -> None: + """NEUTRAL status check posted when all artifacts are SKIPPED.""" artifact = self._create_preprod_artifact(state=PreprodArtifact.ArtifactState.PROCESSED) PreprodArtifactSizeMetrics.objects.create( preprod_artifact=artifact, @@ -1449,7 +1449,13 @@ def test_all_skipped_artifacts_no_status_check(self) -> None: with client_patch, provider_patch: with self.tasks(): create_preprod_status_check_task(artifact.id) - mock_provider.create_status_check.assert_not_called() + + mock_provider.create_status_check.assert_called_once() + kwargs = mock_provider.create_status_check.call_args.kwargs + assert kwargs["status"] == StatusCheckStatus.NEUTRAL + assert "skipped" in kwargs["subtitle"].lower() + assert "Configure" in kwargs["summary"] + assert kwargs["completed_at"] is not None def test_no_quota_shows_neutral_status(self) -> None: """NO_QUOTA artifacts trigger neutral status with quota exceeded message.""" diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index c2cfe8520bf475..fa4147bdfd69cb 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1519,6 +1519,48 @@ def test_update_autofix_success(self, mock_request): assert response.status_code == 200 assert response.data == mock_response.json.return_value + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request): + from sentry.seer.autofix.autofix import update_autofix + from sentry.seer.autofix.types import AutofixCreatePRPayload, AutofixSelectSolutionPayload + + self.organization.update_option("sentry:enable_seer_coding", False) + + payloads: list[AutofixSelectSolutionPayload | AutofixCreatePRPayload] = [ + AutofixSelectSolutionPayload(type="select_solution"), + AutofixCreatePRPayload(type="create_pr"), + ] + for payload in payloads: + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload=payload, + ) + + assert response.status_code == 403 + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_allows_select_root_cause_when_coding_disabled(self, mock_request): + from sentry.seer.autofix.autofix import update_autofix + + self.organization.update_option("sentry:enable_seer_coding", False) + mock_response = Mock() + mock_response.status = 200 + mock_response.json.return_value = {"run_id": self.run_id} + mock_request.return_value = mock_response + + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload={"type": "select_root_cause", "cause_id": 1}, + ) + + assert response.status_code == 200 + mock_request.assert_called_once() + class TestPreResolveStacktraceFrames(TestCase): def _make_serialized_event(self, frames, platform="python"): diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 16042285e06426..d966e571c417c5 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,5 +1,8 @@ from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.seer.autofix.autofix_agent import ( AutofixStep, build_step_prompt, @@ -774,6 +777,17 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match }, ) + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_coding_agent_handoff( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + integration_id=456, + ) + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") @@ -875,6 +889,16 @@ def setUp(self): super().setUp() self.group = self.create_group(project=self.project) + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + ) + @patch("sentry.seer.explorer.client.make_explorer_update_request") def test_passes_correct_pr_description_suffix(self, mock_post): """push_changes is called with pr_description_suffix matching the group's qualified short id.""" diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 5824e64120003e..793e239f2e9488 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -1,6 +1,9 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.integrations.claude_code.utils import ClaudeSessionEvent from sentry.integrations.cursor.integration import CursorAgentIntegration from sentry.integrations.github_copilot.models import ( @@ -1096,3 +1099,17 @@ def test_caches_client_for_same_integration( mock_integration_service.get_integration.assert_called_once() assert mock_client.list_session_events.call_count == 2 + + +class TestLaunchCodingAgentsForRunCodingDisabled(TestCase): + def test_raises_permission_denied_when_coding_disabled(self): + from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run + + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + launch_coding_agents_for_run( + organization_id=self.organization.id, + run_id=123, + integration_id=1, + ) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 2eb85b223fb2f9..e442c1c2a0bee2 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -1165,6 +1165,24 @@ def test_open_pr_permission_error(self, mock_explorer_state_request): assert response.status_code == 404, f"Failed for {flag}: {response.data}" + def test_open_pr_coding_disabled(self): + self.login_as(user=self.user) + group = self.create_group() + self.organization.update_option("sentry:enable_seer_coding", False) + + for flag in EXPLORER_FLAGS: + with self.feature(flag): + response = self.client.post( + self._get_url(group.id, mode="explorer"), + data={ + "step": "open_pr", + "run_id": 123, + }, + format="json", + ) + + assert response.status_code == 403, f"Failed for {flag}: {response.data}" + @with_feature("organizations:gen-ai-features") @with_feature("organizations:seer-explorer") diff --git a/tests/sentry/seer/endpoints/test_group_autofix_update.py b/tests/sentry/seer/endpoints/test_group_autofix_update.py index cbcc8bbf5ae13f..2f20a9346240c3 100644 --- a/tests/sentry/seer/endpoints/test_group_autofix_update.py +++ b/tests/sentry/seer/endpoints/test_group_autofix_update.py @@ -90,3 +90,40 @@ def test_autofix_update_updates_last_triggered_field(self, mock_request): self.group.refresh_from_db() assert isinstance(self.group.seer_autofix_last_triggered, datetime) + + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled(self, mock_request: MagicMock) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": payload_type}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_select_root_cause_allowed_when_coding_disabled(self, mock_request: MagicMock) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 202 + mock_request.return_value.json.return_value = {} + + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": "select_root_cause", "cause_id": 1}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once() diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py index a7d0f3206f14aa..796ac9d598d12d 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py @@ -140,3 +140,52 @@ def test_explorer_update_feature_flag_disabled(self, mock_has_access: MagicMock) assert response.status_code == status.HTTP_403_FORBIDDEN assert "Feature flag not enabled" in str(response.data) + + +@with_feature("organizations:seer-explorer") +@with_feature("organizations:gen-ai-features") +class TestOrganizationSeerExplorerUpdateCodingDisabled(APITestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.organization = self.create_organization(owner=self.user) + self.organization.flags.allow_joinleave = True + self.organization.save() + self.url = f"/api/0/organizations/{self.organization.slug}/seer/explorer-update/123/" + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post( + self.url, data={"payload": {"type": payload_type}}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_non_coding_payload_allowed_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {} + + response = self.client.post( + self.url, data={"payload": {"type": "interrupt"}}, format="json" + ) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once()