diff --git a/package.json b/package.json index ff38e7f980ea..66c00ae0a974 100644 --- a/package.json +++ b/package.json @@ -248,8 +248,8 @@ "@types/d3-zoom": "^3.0.8", "@types/gettext-parser": "8.0.0", "@types/node": "^22.9.1", - "@typescript-eslint/rule-tester": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/rule-tester": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260112.1", "@volar/typescript": "^2.4.28", "babel-jest": "30.3.0", @@ -286,7 +286,7 @@ "stylelint": "16.10.0", "stylelint-config-recommended": "^14.0.1", "terser": "5.40.0", - "typescript-eslint": "8.56.1" + "typescript-eslint": "8.58.0" }, "optionalDependencies": { "fsevents": "^2.3.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4a3bc04787..7f58f5feb8ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,11 +602,11 @@ importers: specifier: ^22.9.1 version: 22.15.21 '@typescript-eslint/rule-tester': - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) '@typescript/native-preview': specifier: 7.0.0-dev.20260112.1 version: 7.0.0-dev.20260112.1 @@ -627,13 +627,13 @@ importers: version: 3.8.3(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-boundaries: specifier: 6.0.2 - version: 6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + version: 6.0.2(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-jest: specifier: 29.15.0 - version: 29.15.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.15.0(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3) eslint-plugin-jest-dom: specifier: ^5.5.0 version: 5.5.0(@testing-library/dom@10.4.1)(eslint@9.34.0(jiti@2.6.1)) @@ -663,7 +663,7 @@ importers: version: 7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-typescript-sort-keys: specifier: ^3.3.0 - version: 3.3.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + version: 3.3.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-unicorn: specifier: ^57.0.0 version: 57.0.0(eslint@9.34.0(jiti@2.6.1)) @@ -716,8 +716,8 @@ importers: specifier: 5.40.0 version: 5.40.0 typescript-eslint: - specifier: 8.56.1 - version: 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.0 + version: 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: fsevents: specifier: ^2.3.3 @@ -4152,13 +4152,13 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.58.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/experimental-utils@5.62.0': resolution: {integrity: sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==} @@ -4166,21 +4166,21 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/rule-tester@8.56.1': - resolution: {integrity: sha512-EWuV5Vq1EFYJEOVcILyWPO35PjnT0c6tv99PCpD12PgfZae5/Jo+F17hGjsEs2Moe+Dy1J7KIr8y037cK8+/rQ==} + '@typescript-eslint/rule-tester@8.58.0': + resolution: {integrity: sha512-a/J72Cxeo5ug5sbey7+Dcna6tMBc4Z4eYwBEKM6MVuBqbxnROpLm8yn/j00lPZc75joPZJVR5oiTZxbK95zp+w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4193,18 +4193,22 @@ packages: resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@5.62.0': resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} @@ -4214,6 +4218,10 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@5.62.0': resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4223,11 +4231,11 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@5.62.0': resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} @@ -4235,12 +4243,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@5.62.0': resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} @@ -4250,6 +4258,10 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260112.1': resolution: {integrity: sha512-FUOOGN0/9LF+AOX07SOqfX1hBQfP3rezMFCwDlwAVW52leJ2Fur8efrQR5oUNL8hDt/NMGJwsg3wreZGdYSqJg==} cpu: [arm64] @@ -9095,8 +9107,8 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -9196,12 +9208,12 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript-template-language-service-decorator@2.3.2: resolution: {integrity: sha512-hN0zNkr5luPCeXTlXKxsfBPlkAzx86ZRM1vPdL7DbEqqWoeXSxplACy98NpKpLmXsdq7iePUzAXloCAoPKBV6A==} @@ -10806,10 +10818,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': + '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': dependencies: eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 @@ -13405,7 +13417,7 @@ snapshots: '@tanstack/eslint-plugin-query@5.96.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 @@ -13963,18 +13975,18 @@ snapshots: dependencies: '@types/yargs-parser': 15.0.0 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 eslint: 9.34.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13987,32 +13999,32 @@ snapshots: - supports-color - typescript - '@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/rule-tester@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/rule-tester@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) ajv: 6.12.6 eslint: 9.34.0(jiti@2.6.1) json-stable-stringify-without-jsonify: 1.0.1 @@ -14032,18 +14044,23 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.34.0(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -14052,6 +14069,8 @@ snapshots: '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 5.62.0 @@ -14066,17 +14085,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.3 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -14096,12 +14115,12 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.34.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -14117,6 +14136,11 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260112.1': optional: true @@ -15685,7 +15709,7 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -15709,24 +15733,24 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: - '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) chalk: 4.1.2 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) handlebars: 4.7.9 micromatch: 4.0.8 transitivePeerDependencies: @@ -15735,7 +15759,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15746,7 +15770,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -15758,7 +15782,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -15772,12 +15796,12 @@ snapshots: optionalDependencies: '@testing-library/dom': 10.4.1 - eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3): + eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(jest@30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) jest: 30.3.0(@types/node@22.15.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.21)(typescript@5.9.3)) typescript: 5.9.3 transitivePeerDependencies: @@ -15861,16 +15885,16 @@ snapshots: eslint-plugin-testing-library@7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-typescript-sort-keys@3.3.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-typescript-sort-keys@3.3.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/experimental-utils': 5.62.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) json-schema: 0.4.0 natural-compare-lite: 1.4.0 @@ -20103,7 +20127,7 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -20220,12 +20244,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.34.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: diff --git a/pyproject.toml b/pyproject.toml index 4d323c498368..704559ec8e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "mmh3>=4.0.0", "msgspec>=0.19.0", "msgpack>=1.1.0", - "objectstore-client>=0.1.1", + "objectstore-client>=0.1.5", "openai>=1.3.5", "orjson>=3.10.10", "p4python>=2025.1.2767466", diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index 25b5cce08e9e..db0ad6d49ecd 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -1201,6 +1201,9 @@ def format_options(self, attrs: Mapping[str, Any]) -> dict[str, Any]: "sentry:preprod_distribution_pr_comments_enabled_by_customer": self.get_value_with_default( attrs, "sentry:preprod_distribution_pr_comments_enabled_by_customer" ), + "sentry:preprod_snapshot_pr_comments_enabled": self.get_value_with_default( + attrs, "sentry:preprod_snapshot_pr_comments_enabled" + ), } def get_value_with_default(self, attrs, key): diff --git a/src/sentry/backup/dependencies.py b/src/sentry/backup/dependencies.py index e7325c0d5547..3089a5821b0c 100644 --- a/src/sentry/backup/dependencies.py +++ b/src/sentry/backup/dependencies.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections import defaultdict +from collections import defaultdict, deque from dataclasses import dataclass from enum import Enum, auto, unique from functools import lru_cache @@ -667,41 +667,47 @@ def get_exportable_sentry_models() -> set[type[models.base.Model]]: ) -def dedupe_and_reassign_groupseen_in_org( - organization_id: int, from_user_id: int, to_user_id: int -) -> None: - """ - Dedupe GroupSeen rows inside an organization and reassign them to the new user. +def _get_org_scope_condition(model_relations: ModelRelations, organization_id: int) -> Q: """ - from sentry.models.groupseen import GroupSeen - - scoped = Q(group__project__organization_id=organization_id) - # Remove from_user rows that would collide with to_user for the same group - GroupSeen.objects.filter( - scoped, - user_id=from_user_id, - group_id__in=GroupSeen.objects.filter(scoped, user_id=to_user_id).values("group_id"), - ).delete() - GroupSeen.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id) + Finds a path from this model to Organization through FK relationships and returns a Q object + scoping the model to the given organization_id. Uses BFS to find the shortest path. + Only traverses real DB-level FK types (FlexibleForeignKey, DefaultForeignKey, OneToOneField + variants). HybridCloudForeignKey and ImplicitForeignKey are skipped because they don't support + Django ORM __ traversal. We skip over nullable relations to avoid generating conditions + that don't find any records. -def dedupe_and_reassign_groupsubscription_in_org( - organization_id: int, from_user_id: int, to_user_id: int -) -> None: - """ - Dedupe GroupSubscription rows inside an organization and reassign them to the new user. + Returns Q() if no path to Organization is found (caller's queries will be unscoped). """ - from sentry.models.groupsubscription import GroupSubscription + from sentry.models.organization import Organization + + traversable = { + ForeignFieldKind.FlexibleForeignKey, + ForeignFieldKind.DefaultForeignKey, + ForeignFieldKind.OneToOneCascadeDeletes, + ForeignFieldKind.DefaultOneToOneField, + } + all_deps = dependencies() + visited: set[NormalizedModelName] = {get_model_name(model_relations.model)} + queue: deque[tuple[ModelRelations, str]] = deque([(model_relations, "")]) + + while queue: + current, prefix = queue.popleft() + for field_name, fk in current.foreign_keys.items(): + if fk.model is Organization: + col = field_name if field_name.endswith("_id") else f"{field_name}_id" + return Q(**{f"{prefix}{col}": organization_id}) + if fk.kind not in traversable: + continue + if fk.nullable: + continue + related_name = get_model_name(fk.model) + if related_name not in visited and related_name in all_deps: + visited.add(related_name) + traversal = field_name[:-3] if field_name.endswith("_id") else field_name + queue.append((all_deps[related_name], f"{prefix}{traversal}__")) - scoped = Q(group__project__organization_id=organization_id) - GroupSubscription.objects.filter( - scoped, - user_id=from_user_id, - group_id__in=GroupSubscription.objects.filter(scoped, user_id=to_user_id).values( - "group_id" - ), - ).delete() - GroupSubscription.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id) + return Q() def merge_users_for_model_in_org( @@ -710,34 +716,46 @@ def merge_users_for_model_in_org( """ All instances of this model in a certain organization that reference both the organization and user in question will be pointed at the new user instead. - """ - from sentry.models.groupseen import GroupSeen - from sentry.models.groupsubscription import GroupSubscription - from sentry.models.organization import Organization + For models with unique constraints that include a user field, conflicting rows (where the + to_user already has a row matching the other unique fields) are deleted before the update to + avoid IntegrityErrors. + """ from sentry.users.models.user import User - # Special-case: GroupSeen has unique_together (user_id, group). Dedupe conflicts inside org - # then update remaining rows. - if model is GroupSeen: - dedupe_and_reassign_groupseen_in_org(organization_id, from_user_id, to_user_id) - return - - # Special-case: GroupSubscription has unique_together (group, user_id). Same pattern. - if model is GroupSubscription: - dedupe_and_reassign_groupsubscription_in_org(organization_id, from_user_id, to_user_id) - return - model_relations = dependencies()[get_model_name(model)] user_refs = {k for k, v in model_relations.foreign_keys.items() if v.model == User} - - org_refs = { - k if k.endswith("_id") else f"{k}_id" - for k, v in model_relations.foreign_keys.items() - if v.model == Organization - } - for_this_org = Q(**{field_name: organization_id for field_name in org_refs}) + for_this_org = _get_org_scope_condition(model_relations, organization_id) + + # model_relations.uniques only contains fields, and needs to be json encodable. + unique_constraints: list[tuple[frozenset[str], Q]] = [] + for unique_fields in model._meta.unique_together: + unique_constraints.append((frozenset(unique_fields), Q())) + for constraint in model._meta.constraints: + if not isinstance(constraint, UniqueConstraint): + continue + unique_constraints.append((frozenset(constraint.fields), constraint.condition or Q())) for user_ref in user_refs: - q = for_this_org & Q(**{user_ref: from_user_id}) - model.objects.filter(q).update(**{user_ref: to_user_id}) + # For any unique constraint that includes a user/user_id field, delete rows that would + # collide after reassignment before doing the update. + user_uniques = [u for u in unique_constraints if user_ref in u[0]] + for user_constraint in user_uniques: + other_fields = list(user_constraint[0] - {user_ref}) + if not other_fields: + # user_ref is unique on its own, delete from_user row so that + # updates of to_user -> from_user don't conflict. + model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: from_user_id} + ).delete() + else: + for matching in model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: to_user_id} + ).values(*other_fields): + model.objects.filter( + for_this_org, user_constraint[1], **{user_ref: from_user_id}, **matching + ).delete() + + model.objects.filter(for_this_org & Q(**{user_ref: from_user_id})).update( + **{user_ref: to_user_id} + ) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index de5184194252..2f8c70b2005b 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2225,6 +2225,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "sentry.integrations.bitbucket.integration.BitbucketIntegrationProvider", "sentry.integrations.bitbucket_server.integration.BitbucketServerIntegrationProvider", "sentry.integrations.slack.SlackIntegrationProvider", + "sentry.integrations.slack.staging.integration.SlackStagingIntegrationProvider", "sentry.integrations.github.integration.GitHubIntegrationProvider", "sentry.integrations.github_enterprise.integration.GitHubEnterpriseIntegrationProvider", "sentry.integrations.gitlab.integration.GitlabIntegrationProvider", diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index 48d9bfb77b6f..5b70d26b87dc 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -119,6 +119,7 @@ class ProjectMemberSerializer(serializers.Serializer): preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField( required=False, allow_null=True ) + preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True) preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True) preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True) @@ -167,6 +168,7 @@ class ProjectMemberSerializer(serializers.Serializer): "preprodSnapshotStatusChecksFailOnAdded", "preprodSnapshotStatusChecksFailOnRemoved", "preprodDistributionPrCommentsEnabledByCustomer", + "preprodSnapshotPrCommentsEnabled", ] ) class ProjectAdminSerializer(ProjectMemberSerializer): @@ -889,6 +891,14 @@ def put(self, request: Request, project) -> Response: changed_proj_settings[ "sentry:preprod_distribution_pr_comments_enabled_by_customer" ] = result["preprodDistributionPrCommentsEnabledByCustomer"] + if "preprodSnapshotPrCommentsEnabled" in result: + if project.update_option( + "sentry:preprod_snapshot_pr_comments_enabled", + result["preprodSnapshotPrCommentsEnabled"], + ): + changed_proj_settings["sentry:preprod_snapshot_pr_comments_enabled"] = result[ + "preprodSnapshotPrCommentsEnabled" + ] if "debugFilesRole" in result: if result["debugFilesRole"] is None: project.delete_option("sentry:debug_files_role") diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 16af60e86bff..865c7346a27f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -130,6 +130,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:increased-issue-owners-rate-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Starfish: extract metrics from the spans manager.add("organizations:indexed-spans-extraction", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) + # These flags follow the pattern expected by IntegrationProvider.requires_feature_flag's usage on the config endpoint # Enable integration functionality to work deployment integrations like Vercel manager.add("organizations:integrations-deployment", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True) manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -137,6 +138,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Project Management Integrations Feature Parity Flags manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -232,6 +234,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:preprod-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable preprod PR comments for build distribution manager.add("organizations:preprod-build-distribution-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable preprod PR comments for snapshots + manager.add("organizations:preprod-snapshot-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable enforcement of preprod size quota checks (when disabled, size quota checks always return True) manager.add("organizations:preprod-enforce-size-quota", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable enforcement of preprod distribution quota checks (when disabled, distribution quota checks always return True) @@ -311,8 +315,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable new compact issue alert UI in Slack manager.add("organizations:slack-compact-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable the Slack staging app - manager.add("organizations:slack-staging-app", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Explorer in Slack via @mentions manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable search query attribute validation @@ -420,6 +422,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:workflow-engine-metric-alert-dual-processing-logs", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Creation of Metric Alerts that use the `group_by` field in the workflow_engine manager.add("organizations:workflow-engine-metric-alert-group-by-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable caching for workflow action filters + manager.add("organizations:workflow-engine-action-filters-cache", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable ingestion through trusted relays only manager.add("organizations:ingest-through-trusted-relays-only", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable metric issue UI for issue alerts @@ -459,6 +463,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:ourlogs-stats", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable overlaying charts in logs manager.add("organizations:ourlogs-overlay-charts-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable the expand/collapse table height toggle in the logs UI + manager.add("organizations:ourlogs-table-expando", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable alerting on trace metrics manager.add("organizations:tracemetrics-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable attributes dropdown side panel in trace metrics diff --git a/src/sentry/hybridcloud/tasks/backfill_outboxes.py b/src/sentry/hybridcloud/tasks/backfill_outboxes.py index 463b48c09919..642462366dcf 100644 --- a/src/sentry/hybridcloud/tasks/backfill_outboxes.py +++ b/src/sentry/hybridcloud/tasks/backfill_outboxes.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from django.apps import apps @@ -21,6 +22,8 @@ from sentry.users.models.user import User from sentry.utils import json, metrics, redis +logger = logging.getLogger(__name__) + def _get_redis_client() -> RedisCluster[str] | StrictRedis[str]: return redis.redis_clusters.get(settings.SENTRY_HYBRIDCLOUD_BACKFILL_OUTBOXES_REDIS_CLUSTER) @@ -138,8 +141,19 @@ def process_outbox_backfill_batch( model, batch_size=batch_size, force_synchronous=force_synchronous ) if not processing_state: + logger.info("processing_state.missing", extra={"model": model.__name__}) return None + logger.info( + "processing_state.current", + extra={ + "model": model.__name__, + "batch_low": processing_state.low, + "batch_up": processing_state.up, + "version": processing_state.version, + }, + ) + for inst in model.objects.filter(id__gte=processing_state.low, id__lte=processing_state.up): with outbox_context(transaction.atomic(router.db_for_write(model)), flush=False): if isinstance(inst, CellOutboxProducingModel): diff --git a/src/sentry/identity/__init__.py b/src/sentry/identity/__init__.py index 4cf95538cfeb..193da131e7ba 100644 --- a/src/sentry/identity/__init__.py +++ b/src/sentry/identity/__init__.py @@ -16,7 +16,7 @@ def _register_providers() -> None: from .github_enterprise.provider import GitHubEnterpriseIdentityProvider from .gitlab.provider import GitlabIdentityProvider from .google.provider import GoogleIdentityProvider - from .slack.provider import SlackIdentityProvider + from .slack.provider import SlackIdentityProvider, SlackStagingIdentityProvider from .vercel.provider import VercelIdentityProvider from .vsts.provider import VSTSIdentityProvider, VSTSNewIdentityProvider from .vsts_extension.provider import VstsExtensionIdentityProvider @@ -24,6 +24,7 @@ def _register_providers() -> None: # TODO(epurkhiser): Should this be moved into it's own plugin, it should be # initialized there. register(SlackIdentityProvider) + register(SlackStagingIdentityProvider) register(GitHubIdentityProvider) register(GitHubEnterpriseIdentityProvider) register(VSTSNewIdentityProvider) diff --git a/src/sentry/identity/slack/provider.py b/src/sentry/identity/slack/provider.py index d8cd87778ca2..76cfe73fc371 100644 --- a/src/sentry/identity/slack/provider.py +++ b/src/sentry/identity/slack/provider.py @@ -71,6 +71,22 @@ def build_identity(self, data): } +class SlackStagingIdentityProvider(SlackIdentityProvider): + key = IntegrationProviderSlug.SLACK_STAGING.value + name = "Slack (Staging)" + + def get_oauth_client_id(self): + return options.get("slack-staging.client-id") + + def get_oauth_client_secret(self): + return options.get("slack-staging.client-secret") + + def build_identity(self, data): + production_identity = super().build_identity(data) + production_identity["type"] = IntegrationProviderSlug.SLACK_STAGING.value + return production_identity + + class SlackOAuth2LoginView(OAuth2LoginView): """ We need to customize the OAuth2LoginView in order to support passing through diff --git a/src/sentry/integrations/api/bases/external_actor.py b/src/sentry/integrations/api/bases/external_actor.py index ba32088a09fa..ee4197cd2733 100644 --- a/src/sentry/integrations/api/bases/external_actor.py +++ b/src/sentry/integrations/api/bases/external_actor.py @@ -32,6 +32,7 @@ ExternalProviders.GITHUB_ENTERPRISE, ExternalProviders.GITLAB, ExternalProviders.SLACK, + ExternalProviders.SLACK_STAGING, ExternalProviders.MSTEAMS, ExternalProviders.JIRA_SERVER, ExternalProviders.PERFORCE, diff --git a/src/sentry/integrations/api/endpoints/organization_config_integrations.py b/src/sentry/integrations/api/endpoints/organization_config_integrations.py index 7cdf2abc4ac2..4da55bd6d0f1 100644 --- a/src/sentry/integrations/api/endpoints/organization_config_integrations.py +++ b/src/sentry/integrations/api/endpoints/organization_config_integrations.py @@ -4,7 +4,6 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -18,6 +17,7 @@ IntegrationProviderResponse, IntegrationProviderSerializer, ) +from sentry.integrations.base import is_provider_enabled from sentry.integrations.manager import default_manager as integrations from sentry.models.organization import Organization @@ -54,21 +54,14 @@ def get(self, request: Request, organization: Organization) -> Response: Get integration provider information about all available integrations for an organization. """ - def is_provider_enabled(provider): - if not provider.requires_feature_flag: - return True - flag = ( - provider.feature_flag_name - or "organizations:integrations-%s" % provider.key.replace("_", "-") - ) - return features.has(flag, organization, actor=request.user) - providers = list(integrations.all()) provider_key = request.GET.get("provider_key") or request.GET.get("providerKey") if provider_key: providers = [p for p in providers if p.key == provider_key] - providers = list(filter(is_provider_enabled, providers)) + providers = list( + filter(lambda p: is_provider_enabled(p, organization, actor=request.user), providers) + ) providers.sort(key=lambda i: i.key) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 197f79b69ddf..1d3f4de6a278 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -46,11 +46,15 @@ from sentry.utils.audit import create_audit_entry if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser from django.utils.functional import _StrPromise from sentry.integrations.pipeline import IntegrationPipeline # noqa: F401 from sentry.integrations.services.integration import RpcOrganizationIntegration from sentry.integrations.services.integration.model import RpcIntegration + from sentry.models.organization import Organization + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser logger = logging.getLogger(__name__) @@ -586,3 +590,18 @@ def get_integration_types(provider: str) -> list[IntegrationDomain]: if provider in providers: types.append(integration_type) return types + + +def is_provider_enabled( + provider: IntegrationProvider, + organization: Organization | RpcOrganization, + actor: User | RpcUser | AnonymousUser | None = None, +) -> bool: + from sentry import features + + if not provider.requires_feature_flag: + return True + flag = provider.feature_flag_name or "organizations:integrations-%s" % provider.key.replace( + "_", "-" + ) + return features.has(flag, organization, actor=actor) diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index 7fd4cdc32b76..b53dcef29583 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -18,7 +18,12 @@ from sentry.auth.superuser import superuser_has_permission from sentry.constants import ObjectStatus from sentry.features.exceptions import FeatureNotRegistered -from sentry.integrations.base import IntegrationData, IntegrationDomain, IntegrationProvider +from sentry.integrations.base import ( + IntegrationData, + IntegrationDomain, + IntegrationProvider, + is_provider_enabled, +) from sentry.integrations.manager import default_manager from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration @@ -74,6 +79,11 @@ def initialize_integration_pipeline( assert isinstance(pipeline.provider, IntegrationProvider) + if not is_provider_enabled(pipeline.provider, organization): + raise IntegrationPipelineError( + "This integration is not available for your organization.", not_found=True + ) + is_feature_enabled: dict[str, bool] = {} for feature in pipeline.provider.features: feature_flag_name = "organizations:integrations-%s" % feature.value diff --git a/src/sentry/integrations/slack/__init__.py b/src/sentry/integrations/slack/__init__.py index 3ce06d897a57..74da44c00f3c 100644 --- a/src/sentry/integrations/slack/__init__.py +++ b/src/sentry/integrations/slack/__init__.py @@ -1,4 +1,5 @@ from sentry.integrations.slack.spec import SlackMessagingSpec +from sentry.integrations.slack.staging.spec import SlackStagingMessagingSpec from .actions.form import * # noqa: F401,F403 from .actions.notification import * # noqa: F401,F403 @@ -42,3 +43,4 @@ from .webhooks.event import * # noqa: F401,F403 SlackMessagingSpec().initialize() +SlackStagingMessagingSpec().initialize() diff --git a/src/sentry/integrations/slack/handlers/__init__.py b/src/sentry/integrations/slack/handlers/__init__.py index a227b3a76808..9ecdc9c689ce 100644 --- a/src/sentry/integrations/slack/handlers/__init__.py +++ b/src/sentry/integrations/slack/handlers/__init__.py @@ -1,3 +1,3 @@ -__all__ = ["SlackActionHandler"] +__all__ = ["SlackActionHandler", "SlackStagingActionHandler"] -from .slack_action_handler import SlackActionHandler +from .slack_action_handler import SlackActionHandler, SlackStagingActionHandler diff --git a/src/sentry/integrations/slack/handlers/slack_action_handler.py b/src/sentry/integrations/slack/handlers/slack_action_handler.py index 2e90d4a15d54..0b9f81301203 100644 --- a/src/sentry/integrations/slack/handlers/slack_action_handler.py +++ b/src/sentry/integrations/slack/handlers/slack_action_handler.py @@ -43,3 +43,8 @@ def execute(invocation: ActionInvocation) -> None: from sentry.notifications.notification_action.utils import execute_via_group_type_registry execute_via_group_type_registry(invocation) + + +@action_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingActionHandler(SlackActionHandler): + provider_slug = IntegrationProviderSlug.SLACK_STAGING diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 81a1106abc23..5a3adf7d141d 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -9,7 +9,6 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -from sentry import options from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.base import ( FeatureDescription, @@ -299,7 +298,7 @@ class SlackIntegrationProvider(IntegrationProvider): ] ) # Extended scopes that require Slack marketplace approval - # Gated by slack.extended-scopes-enabled option + # Used by SlackStagingIntegrationProvider extended_oauth_scopes = frozenset( [ SlackScope.REACTIONS_WRITE, @@ -319,10 +318,7 @@ class SlackIntegrationProvider(IntegrationProvider): def _get_oauth_scopes(self) -> frozenset[str]: """ Returns the OAuth scopes to request during installation. - Extended scopes are included when slack.extended-scopes-enabled is True. """ - if options.get("slack.extended-scopes-enabled"): - return self.identity_oauth_scopes | self.extended_oauth_scopes return self.identity_oauth_scopes setup_dialog_config = {"width": 600, "height": 900} diff --git a/src/sentry/integrations/slack/notifications.py b/src/sentry/integrations/slack/notifications.py index bb5397ecf859..894697d74ae7 100644 --- a/src/sentry/integrations/slack/notifications.py +++ b/src/sentry/integrations/slack/notifications.py @@ -18,12 +18,12 @@ logger = logging.getLogger("sentry.notifications") -@register_notification_provider(ExternalProviders.SLACK) -def send_notification_as_slack( +def _send_slack_notification( notification: BaseNotification, recipients: Iterable[Actor | User], shared_context: Mapping[str, Any], extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, + provider: ExternalProviders, ) -> None: """Send an "activity" or "alert rule" notification to a Slack user or team, but NOT to a channel directly. Sending Slack notifications to a channel is in integrations/slack/actions/notification.py""" @@ -31,7 +31,7 @@ def send_notification_as_slack( service = SlackService.default() with sentry_sdk.start_span(op="notification.send_slack", name="gen_channel_integration_map"): data = get_integrations_by_channel_by_recipient( - notification.organization, recipients, ExternalProviders.SLACK + notification.organization, recipients, provider ) for recipient, integrations_by_channel in data.items(): @@ -59,3 +59,31 @@ def send_notification_as_slack( instance=f"slack.{notification.metrics_key}.notification", skip_internal=False, ) + + +@register_notification_provider(ExternalProviders.SLACK) +def send_notification_as_slack( + notification: BaseNotification, + recipients: Iterable[Actor | User], + shared_context: Mapping[str, Any], + extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, +) -> None: + _send_slack_notification( + notification, recipients, shared_context, extra_context_by_actor, ExternalProviders.SLACK + ) + + +@register_notification_provider(ExternalProviders.SLACK_STAGING) +def send_notification_as_slack_staging( + notification: BaseNotification, + recipients: Iterable[Actor | User], + shared_context: Mapping[str, Any], + extra_context_by_actor: Mapping[Actor, Mapping[str, Any]] | None, +) -> None: + _send_slack_notification( + notification, + recipients, + shared_context, + extra_context_by_actor, + ExternalProviders.SLACK_STAGING, + ) diff --git a/src/sentry/integrations/slack/requests/base.py b/src/sentry/integrations/slack/requests/base.py index 4be0e1874c1f..e8267f76c0e6 100644 --- a/src/sentry/integrations/slack/requests/base.py +++ b/src/sentry/integrations/slack/requests/base.py @@ -70,6 +70,18 @@ def __init__(self, request: Request) -> None: self._user: RpcUser | None = None self._data: MutableMapping[str, Any] = {} + def _is_staging_request(self) -> bool: + path = self.request.path + return isinstance(path, str) and "/extensions/slack-staging/" in path + + @property + def _provider_slug(self) -> str: + return ( + IntegrationProviderSlug.SLACK_STAGING.value + if self._is_staging_request() + else IntegrationProviderSlug.SLACK.value + ) + def validate(self) -> None: """ Ensure everything is present to properly process this request @@ -100,7 +112,7 @@ def _get_context(self) -> None: except Exception: pass context = integration_service.get_integration_identity_context( - integration_provider=IntegrationProviderSlug.SLACK.value, + integration_provider=self._provider_slug, integration_external_id=team_id, identity_external_id=user_id, identity_provider_external_id=team_id, @@ -163,7 +175,7 @@ def get_identity(self) -> RpcIdentity | None: if self._provider is None: self._provider = identity_service.get_provider( - provider_type=IntegrationProviderSlug.SLACK.value, provider_ext_id=self.team_id + provider_type=self._provider_slug, provider_ext_id=self.team_id ) if self._provider is not None: @@ -197,8 +209,12 @@ def authorize(self) -> None: # XXX(meredith): Signing secrets are the preferred way # but self-hosted could still have an older slack bot # app that just has the verification token. - signing_secret = options.get("slack.signing-secret") - verification_token = options.get("slack.verification-token") + if self._is_staging_request(): + signing_secret = options.get("slack-staging.signing-secret") + verification_token = None + else: + signing_secret = options.get("slack.signing-secret") + verification_token = options.get("slack.verification-token") if signing_secret: if self._check_signing_secret(signing_secret): @@ -226,7 +242,7 @@ def _check_verification_token(self, verification_token: str) -> bool: def validate_integration(self) -> None: if not self._integration: self._integration = integration_service.get_integration( - provider=IntegrationProviderSlug.SLACK.value, + provider=self._provider_slug, external_id=self.team_id, status=ObjectStatus.ACTIVE, ) diff --git a/src/sentry/integrations/slack/staging/__init__.py b/src/sentry/integrations/slack/staging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/integrations/slack/staging/integration.py b/src/sentry/integrations/slack/staging/integration.py new file mode 100644 index 000000000000..a0914bd9827b --- /dev/null +++ b/src/sentry/integrations/slack/staging/integration.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from sentry.identity.pipeline import IdentityPipeline +from sentry.integrations.base import ( + IntegrationData, +) +from sentry.integrations.pipeline import IntegrationPipeline +from sentry.integrations.slack.integration import SlackIntegrationProvider +from sentry.integrations.types import IntegrationProviderSlug +from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.views.nested import NestedPipelineView +from sentry.utils.http import absolute_uri + +_logger = logging.getLogger("sentry.integrations.slack") + + +class SlackStagingIntegrationProvider(SlackIntegrationProvider): + key = IntegrationProviderSlug.SLACK_STAGING.value + name = "Slack (Staging)" + requires_feature_flag = True + + def _get_oauth_scopes(self) -> frozenset[str]: + return self.identity_oauth_scopes | self.extended_oauth_scopes + + def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]: + return NestedPipelineView( + bind_key="identity", + provider_key=IntegrationProviderSlug.SLACK_STAGING.value, + pipeline_cls=IdentityPipeline, + config={ + "oauth_scopes": self._get_oauth_scopes(), + "user_scopes": self.user_scopes, + "redirect_url": absolute_uri("/extensions/slack-staging/setup/"), + }, + ) + + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: + production_integration = super().build_integration(state=state) + production_integration["user_identity"]["type"] = ( + IntegrationProviderSlug.SLACK_STAGING.value + ) + return production_integration diff --git a/src/sentry/integrations/slack/staging/notification.py b/src/sentry/integrations/slack/staging/notification.py new file mode 100644 index 000000000000..1562db8354fb --- /dev/null +++ b/src/sentry/integrations/slack/staging/notification.py @@ -0,0 +1,9 @@ +from sentry.integrations.slack.actions.notification import SlackNotifyServiceAction +from sentry.integrations.types import IntegrationProviderSlug + + +class SlackStagingNotifyServiceAction(SlackNotifyServiceAction): + id = "sentry.integrations.slack.staging.notify_action.SlackStagingNotifyServiceAction" + prompt = "Send a Slack (Staging) notification" + provider = IntegrationProviderSlug.SLACK_STAGING.value + label = "Send a notification from the Staging app to the {workspace} Slack workspace to {channel} (optionally, an ID: {channel_id}) and show tags {tags} and notes {notes} in notification" diff --git a/src/sentry/integrations/slack/staging/spec.py b/src/sentry/integrations/slack/staging/spec.py new file mode 100644 index 000000000000..53e045990761 --- /dev/null +++ b/src/sentry/integrations/slack/staging/spec.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from sentry.integrations.base import IntegrationProvider +from sentry.integrations.slack.spec import SlackMessagingSpec +from sentry.integrations.types import IntegrationProviderSlug +from sentry.notifications.models.notificationaction import ActionService +from sentry.rules.actions import IntegrationEventAction + + +class SlackStagingMessagingSpec(SlackMessagingSpec): + @property + def provider_slug(self) -> str: + return IntegrationProviderSlug.SLACK_STAGING.value + + @property + def action_service(self) -> ActionService: + return ActionService.SLACK_STAGING + + @property + def integration_provider(self) -> type[IntegrationProvider]: + from sentry.integrations.slack.staging.integration import SlackStagingIntegrationProvider + + return SlackStagingIntegrationProvider + + @property + def notify_service_action(self) -> type[IntegrationEventAction] | None: + from sentry.integrations.slack.staging.notification import ( + SlackStagingNotifyServiceAction, + ) + + return SlackStagingNotifyServiceAction diff --git a/src/sentry/integrations/slack/staging/urls.py b/src/sentry/integrations/slack/staging/urls.py new file mode 100644 index 000000000000..4b458b27a402 --- /dev/null +++ b/src/sentry/integrations/slack/staging/urls.py @@ -0,0 +1,29 @@ +from django.urls import re_path + +from sentry.integrations.slack.webhooks.action import SlackActionEndpoint +from sentry.integrations.slack.webhooks.command import SlackCommandsEndpoint +from sentry.integrations.slack.webhooks.event import SlackEventEndpoint +from sentry.integrations.slack.webhooks.options_load import SlackOptionsLoadEndpoint + +urlpatterns = [ + re_path( + r"^action/$", + SlackActionEndpoint.as_view(), + name="sentry-integration-slack-staging-action", + ), + re_path( + r"^commands/$", + SlackCommandsEndpoint.as_view(), + name="sentry-integration-slack-staging-commands", + ), + re_path( + r"^event/$", + SlackEventEndpoint.as_view(), + name="sentry-integration-slack-staging-event", + ), + re_path( + r"^options-load/$", + SlackOptionsLoadEndpoint.as_view(), + name="sentry-integration-slack-staging-options-load", + ), +] diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index b6b347df46b8..b5f1adc94d37 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -176,6 +176,7 @@ def _get_unfurlable_links( ) -> dict[LinkType, list[UnfurlableUrl]]: matches: dict[LinkType, list[UnfurlableUrl]] = defaultdict(list) links_seen = set() + link_types: set[str] = set() for item in data.get("links", []): with MessagingInteractionEvent( @@ -223,6 +224,10 @@ def _get_unfurlable_links( links_seen.add(seen_marker) matches[link_type].append(UnfurlableUrl(url=url, args=args)) + link_types.add(getattr(link_type, "value", str(link_type))) + + if len(link_types) > 0: + sentry_sdk.set_tag("slack.link_type", ",".join(sorted(link_types))) return matches @@ -267,6 +272,12 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo ) organization = organization_context.organization if organization_context else None + if organization: + sentry_sdk.set_tag("organization.slug", organization.slug) + identity_user = slack_request.get_identity_user() + if identity_user: + sentry_sdk.set_tag("user.email", identity_user.email) + logger_params = { "integration_id": slack_request.integration.id, "team_id": slack_request.team_id, @@ -427,6 +438,9 @@ def post(self, request: Request) -> Response: if slack_request.is_challenge(): return self.on_url_verification(request, slack_request.data) + + sentry_sdk.set_tag("slack.event_type", slack_request.type) + if slack_request.type == "link_shared": if self.on_link_shared(request, slack_request): return self.respond() diff --git a/src/sentry/integrations/types.py b/src/sentry/integrations/types.py index be119b0a36be..a9d168718091 100644 --- a/src/sentry/integrations/types.py +++ b/src/sentry/integrations/types.py @@ -11,6 +11,7 @@ class ExternalProviders(ValueEqualityEnum): EMAIL = 100 SLACK = 110 + SLACK_STAGING = 111 MSTEAMS = 120 PAGERDUTY = 130 DISCORD = 140 @@ -31,6 +32,7 @@ def name(self) -> str: class IntegrationProviderSlug(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" DISCORD = "discord" MSTEAMS = "msteams" JIRA = "jira" @@ -56,6 +58,7 @@ class ExternalProviderEnum(StrEnum): EMAIL = "email" CUSTOM = "custom_scm" SLACK = IntegrationProviderSlug.SLACK + SLACK_STAGING = IntegrationProviderSlug.SLACK_STAGING MSTEAMS = IntegrationProviderSlug.MSTEAMS PAGERDUTY = IntegrationProviderSlug.PAGERDUTY DISCORD = IntegrationProviderSlug.DISCORD @@ -70,6 +73,7 @@ class ExternalProviderEnum(StrEnum): EXTERNAL_PROVIDERS_REVERSE = { ExternalProviderEnum.EMAIL: ExternalProviders.EMAIL, ExternalProviderEnum.SLACK: ExternalProviders.SLACK, + ExternalProviderEnum.SLACK_STAGING: ExternalProviders.SLACK_STAGING, ExternalProviderEnum.MSTEAMS: ExternalProviders.MSTEAMS, ExternalProviderEnum.PAGERDUTY: ExternalProviders.PAGERDUTY, ExternalProviderEnum.DISCORD: ExternalProviders.DISCORD, @@ -86,6 +90,7 @@ class ExternalProviderEnum(StrEnum): EXTERNAL_PROVIDERS = { ExternalProviders.EMAIL: ExternalProviderEnum.EMAIL.value, ExternalProviders.SLACK: ExternalProviderEnum.SLACK.value, + ExternalProviders.SLACK_STAGING: ExternalProviderEnum.SLACK_STAGING.value, ExternalProviders.MSTEAMS: ExternalProviderEnum.MSTEAMS.value, ExternalProviders.PAGERDUTY: ExternalProviderEnum.PAGERDUTY.value, ExternalProviders.DISCORD: ExternalProviderEnum.DISCORD.value, @@ -101,6 +106,7 @@ class ExternalProviderEnum(StrEnum): PERSONAL_NOTIFICATION_PROVIDERS = [ ExternalProviderEnum.EMAIL.value, ExternalProviderEnum.SLACK.value, + ExternalProviderEnum.SLACK_STAGING.value, ExternalProviderEnum.MSTEAMS.value, ] diff --git a/src/sentry/integrations/utils/identities.py b/src/sentry/integrations/utils/identities.py index e42eb8e09434..a41d1dcadcf7 100644 --- a/src/sentry/integrations/utils/identities.py +++ b/src/sentry/integrations/utils/identities.py @@ -41,7 +41,7 @@ def get_identity_or_404( raise Http404 idp = IdentityProvider.objects.filter( - external_id=integration.external_id, type=EXTERNAL_PROVIDERS[provider] + external_id=integration.external_id, type=integration.provider ).first() logger_metadata["external_id"] = integration.external_id if idp is None: diff --git a/src/sentry/lang/native/symbolicator.py b/src/sentry/lang/native/symbolicator.py index 9e2d701837d5..5d788dd104b1 100644 --- a/src/sentry/lang/native/symbolicator.py +++ b/src/sentry/lang/native/symbolicator.py @@ -114,11 +114,21 @@ def __init__( self.project = project self.event_id = event_id - def _process(self, task_name: str, path: str, **kwargs): + def _process( + self, + task_name: str, + path: str, + kwargs_cb: Callable[[], dict[str, Any]] | None = None, + **kwargs: Any, + ) -> Any: """ This function will submit a symbolication task to a Symbolicator and handle polling it using the `SymbolicatorSession`. It will also correctly handle `TaskIdNotFound` and `ServiceUnavailable` errors. + + `kwargs_cb`, if provided, is called on every new task submission and its result + is merged over `kwargs`. Use this for values that must be fresh on each + (re)submission, such as expiring tokens. """ session = SymbolicatorSession( url=self.base_url, @@ -137,7 +147,8 @@ def _process(self, task_name: str, path: str, **kwargs): try: if not task_id: # We are submitting a new task to Symbolicator - json_response = session.create_task(path, **kwargs) + create_kwargs = {**kwargs, **(kwargs_cb() if kwargs_cb else {})} + json_response = session.create_task(path, **create_kwargs) else: # The task has already been submitted to Symbolicator and we are polling json_response = session.query_task(task_id) @@ -201,7 +212,12 @@ def process_minidump( "rewrite_first_module": rewrite_first_module, }, } - res = self._process("process_minidump", "symbolicate-any", json=json) + + def cb() -> dict[str, Any]: + json["symbolicate"]["storage_token"] = session.mint_token() + return {"json": json} + + res = self._process("process_minidump", "symbolicate-any", kwargs_cb=cb) return process_response(res) data = { @@ -233,7 +249,12 @@ def process_applecrashreport(self, platform: str, report: CachedAttachment): "storage_url": storage_url, }, } - res = self._process("process_applecrashreport", "symbolicate-any", json=json) + + def cb() -> dict[str, Any]: + json["symbolicate"]["storage_token"] = session.mint_token() + return {"json": json} + + res = self._process("process_applecrashreport", "symbolicate-any", kwargs_cb=cb) return process_response(res) data = { diff --git a/src/sentry/middleware/integrations/classifications.py b/src/sentry/middleware/integrations/classifications.py index 5200e0b492ec..67096eb0d994 100644 --- a/src/sentry/middleware/integrations/classifications.py +++ b/src/sentry/middleware/integrations/classifications.py @@ -73,6 +73,7 @@ def integration_parsers(self) -> Mapping[str, type[BaseRequestParser]]: JiraServerRequestParser, MsTeamsRequestParser, SlackRequestParser, + SlackStagingRequestParser, VercelRequestParser, VstsRequestParser, ) @@ -89,6 +90,7 @@ def integration_parsers(self) -> Mapping[str, type[BaseRequestParser]]: JiraServerRequestParser, MsTeamsRequestParser, SlackRequestParser, + SlackStagingRequestParser, VercelRequestParser, VstsRequestParser, ] diff --git a/src/sentry/middleware/integrations/parsers/__init__.py b/src/sentry/middleware/integrations/parsers/__init__.py index 3f86f633a3fc..3503207a9428 100644 --- a/src/sentry/middleware/integrations/parsers/__init__.py +++ b/src/sentry/middleware/integrations/parsers/__init__.py @@ -10,6 +10,7 @@ from .msteams import MsTeamsRequestParser from .plugin import PluginRequestParser from .slack import SlackRequestParser +from .slack_staging import SlackStagingRequestParser from .vercel import VercelRequestParser from .vsts import VstsRequestParser @@ -26,6 +27,7 @@ "MsTeamsRequestParser", "PluginRequestParser", "SlackRequestParser", + "SlackStagingRequestParser", "VercelRequestParser", "VstsRequestParser", ) diff --git a/src/sentry/middleware/integrations/parsers/slack_staging.py b/src/sentry/middleware/integrations/parsers/slack_staging.py new file mode 100644 index 000000000000..8732e52a5988 --- /dev/null +++ b/src/sentry/middleware/integrations/parsers/slack_staging.py @@ -0,0 +1,6 @@ +from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders +from sentry.middleware.integrations.parsers.slack import SlackRequestParser + + +class SlackStagingRequestParser(SlackRequestParser): + provider = EXTERNAL_PROVIDERS[ExternalProviders.SLACK_STAGING] diff --git a/src/sentry/models/options/project_option.py b/src/sentry/models/options/project_option.py index c5f3690a65b7..47bb0a9aef69 100644 --- a/src/sentry/models/options/project_option.py +++ b/src/sentry/models/options/project_option.py @@ -78,6 +78,7 @@ "sentry:preprod_snapshot_status_checks_fail_on_added", "sentry:preprod_snapshot_status_checks_fail_on_removed", "sentry:preprod_distribution_pr_comments_enabled_by_customer", + "sentry:preprod_snapshot_pr_comments_enabled", "sentry:scm_source_context_enabled", "quotas:spike-protection-disabled", "feedback:branding", diff --git a/src/sentry/notifications/additional_attachment_manager.py b/src/sentry/notifications/additional_attachment_manager.py index 26c5dba58967..f7e1f702a128 100644 --- a/src/sentry/notifications/additional_attachment_manager.py +++ b/src/sentry/notifications/additional_attachment_manager.py @@ -30,7 +30,9 @@ def get_additional_attachment( organization: Organization | RpcOrganization, ) -> list[SlackBlock] | None: # look up the generator by the provider but only accepting slack for now - provider = validate_provider(integration.provider, {ExternalProviders.SLACK}) + provider = validate_provider( + integration.provider, {ExternalProviders.SLACK, ExternalProviders.SLACK_STAGING} + ) attachment_generator = self.attachment_generators.get(provider) if attachment_generator is None: return None diff --git a/src/sentry/notifications/api/serializers/notification_action_request.py b/src/sentry/notifications/api/serializers/notification_action_request.py index 17c16b0b84f0..3a672d32c07b 100644 --- a/src/sentry/notifications/api/serializers/notification_action_request.py +++ b/src/sentry/notifications/api/serializers/notification_action_request.py @@ -28,6 +28,7 @@ def format_choices_text(choices: Sequence[tuple[int, str]]): INTEGRATION_SERVICES = { ActionService.PAGERDUTY.value, ActionService.SLACK.value, + ActionService.SLACK_STAGING.value, ActionService.MSTEAMS.value, ActionService.OPSGENIE.value, } @@ -208,7 +209,8 @@ def validate_slack_channel( NOTE: Reaches out to via slack integration to verify channel """ if ( - data["service_type"] != ActionService.SLACK.value + data["service_type"] + not in (ActionService.SLACK.value, ActionService.SLACK_STAGING.value) or data["target_type"] != ActionTarget.SPECIFIC.value ): return data diff --git a/src/sentry/notifications/models/notificationaction.py b/src/sentry/notifications/models/notificationaction.py index 0f52e7e06875..c54b87658274 100644 --- a/src/sentry/notifications/models/notificationaction.py +++ b/src/sentry/notifications/models/notificationaction.py @@ -52,12 +52,14 @@ class ActionService(FlexibleIntEnum): SENTRY_NOTIFICATION = 5 # Use personal notification platform (src/sentry/notifications) OPSGENIE = 6 DISCORD = 7 + SLACK_STAGING = 8 @classmethod def as_choices(cls) -> tuple[tuple[int, str], ...]: assert ExternalProviders.EMAIL.name is not None assert ExternalProviders.PAGERDUTY.name is not None assert ExternalProviders.SLACK.name is not None + assert ExternalProviders.SLACK_STAGING.name is not None assert ExternalProviders.MSTEAMS.name is not None assert ExternalProviders.OPSGENIE.name is not None assert ExternalProviders.DISCORD.name is not None @@ -65,6 +67,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: (cls.EMAIL.value, ExternalProviders.EMAIL.name), (cls.PAGERDUTY.value, ExternalProviders.PAGERDUTY.name), (cls.SLACK.value, ExternalProviders.SLACK.name), + (cls.SLACK_STAGING.value, ExternalProviders.SLACK_STAGING.name), (cls.MSTEAMS.value, ExternalProviders.MSTEAMS.name), (cls.SENTRY_APP.value, "sentry_app"), (cls.SENTRY_NOTIFICATION.value, "sentry_notification"), diff --git a/src/sentry/notifications/notification_action/__init__.py b/src/sentry/notifications/notification_action/__init__.py index e004c751ead4..a29df41e104a 100644 --- a/src/sentry/notifications/notification_action/__init__.py +++ b/src/sentry/notifications/notification_action/__init__.py @@ -13,6 +13,7 @@ "PagerDutyIssueAlertHandler", "PluginIssueAlertHandler", "SlackIssueAlertHandler", + "SlackStagingIssueAlertHandler", "WebhookIssueAlertHandler", "DiscordMetricAlertHandler", "MSTeamsMetricAlertHandler", @@ -20,12 +21,14 @@ "PagerDutyMetricAlertHandler", "SentryAppMetricAlertHandler", "SlackMetricAlertHandler", + "SlackStagingMetricAlertHandler", "EmailMetricAlertHandler", "PluginActionHandler", "WebhookActionHandler", "SentryAppActionHandler", "SendTestNotification", "SlackActionValidatorHandler", + "SlackStagingActionValidatorHandler", "MSTeamsActionValidatorHandler", "DiscordActionValidatorHandler", "JiraActionValidatorHandler", @@ -56,6 +59,7 @@ PagerdutyActionValidatorHandler, SentryAppActionValidatorHandler, SlackActionValidatorHandler, + SlackStagingActionValidatorHandler, WebhookActionValidatorHandler, ) from .group_type_notification_registry import IssueAlertRegistryHandler, MetricAlertRegistryHandler @@ -73,6 +77,7 @@ PagerDutyIssueAlertHandler, PluginIssueAlertHandler, SlackIssueAlertHandler, + SlackStagingIssueAlertHandler, WebhookIssueAlertHandler, ) from .metric_alert_registry import ( @@ -83,4 +88,5 @@ PagerDutyMetricAlertHandler, SentryAppMetricAlertHandler, SlackMetricAlertHandler, + SlackStagingMetricAlertHandler, ) diff --git a/src/sentry/notifications/notification_action/action_validation.py b/src/sentry/notifications/notification_action/action_validation.py index c7a362f19fa9..894fd040b54b 100644 --- a/src/sentry/notifications/notification_action/action_validation.py +++ b/src/sentry/notifications/notification_action/action_validation.py @@ -47,6 +47,11 @@ def update_action_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: return self.validated_data +@action_validator_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingActionValidatorHandler(SlackActionValidatorHandler): + provider = Action.Type.SLACK_STAGING + + @action_validator_registry.register(Action.Type.MSTEAMS) class MSTeamsActionValidatorHandler(BaseActionValidatorHandler): provider = Action.Type.MSTEAMS diff --git a/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py b/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py index 6686926cfcdc..bd720774ce78 100644 --- a/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py +++ b/src/sentry/notifications/notification_action/issue_alert_registry/__init__.py @@ -12,6 +12,7 @@ "PluginIssueAlertHandler", "SentryAppIssueAlertHandler", "SlackIssueAlertHandler", + "SlackStagingIssueAlertHandler", "WebhookIssueAlertHandler", "PagerDutyIssueAlertHandler", ] @@ -28,5 +29,8 @@ from .handlers.pagerduty_issue_alert_handler import PagerDutyIssueAlertHandler from .handlers.plugin_issue_alert_handler import PluginIssueAlertHandler from .handlers.sentry_app_issue_alert_handler import SentryAppIssueAlertHandler -from .handlers.slack_issue_alert_handler import SlackIssueAlertHandler +from .handlers.slack_issue_alert_handler import ( + SlackIssueAlertHandler, + SlackStagingIssueAlertHandler, +) from .handlers.webhook_issue_alert_handler import WebhookIssueAlertHandler diff --git a/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py b/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py index 27440c8e6276..b305402155e7 100644 --- a/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py +++ b/src/sentry/notifications/notification_action/issue_alert_registry/handlers/slack_issue_alert_handler.py @@ -49,3 +49,8 @@ def render_label(cls, organization_id: int, blob: dict[str, Any]) -> str: label += " in notification" return label + + +@issue_alert_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingIssueAlertHandler(SlackIssueAlertHandler): + pass diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py b/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py index 6d90a5c5ebf1..bb2d99ea6754 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/__init__.py @@ -4,6 +4,7 @@ "MSTeamsMetricAlertHandler", "DiscordMetricAlertHandler", "SlackMetricAlertHandler", + "SlackStagingMetricAlertHandler", "SentryAppMetricAlertHandler", "EmailMetricAlertHandler", ] @@ -14,4 +15,7 @@ from .handlers.opsgenie_metric_alert_handler import OpsgenieMetricAlertHandler from .handlers.pagerduty_metric_alert_handler import PagerDutyMetricAlertHandler from .handlers.sentry_app_metric_alert_handler import SentryAppMetricAlertHandler -from .handlers.slack_metric_alert_handler import SlackMetricAlertHandler +from .handlers.slack_metric_alert_handler import ( + SlackMetricAlertHandler, + SlackStagingMetricAlertHandler, +) diff --git a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py index e322ce16f612..04cd547b9d76 100644 --- a/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py +++ b/src/sentry/notifications/notification_action/metric_alert_registry/handlers/slack_metric_alert_handler.py @@ -69,3 +69,8 @@ def send_alert( incident_serialized_response=incident_serialized_response, detector_serialized_response=detector_serialized_response, ) + + +@metric_alert_handler_registry.register(Action.Type.SLACK_STAGING) +class SlackStagingMetricAlertHandler(SlackMetricAlertHandler): + pass diff --git a/src/sentry/notifications/notificationcontroller.py b/src/sentry/notifications/notificationcontroller.py index c25a69ce99c4..f11ed1aaba38 100644 --- a/src/sentry/notifications/notificationcontroller.py +++ b/src/sentry/notifications/notificationcontroller.py @@ -32,7 +32,7 @@ from sentry.users.services.user.model import RpcUser Recipient = Union[Actor, Team, RpcUser, User] -TEAM_NOTIFICATION_PROVIDERS = [ExternalProviderEnum.SLACK] +TEAM_NOTIFICATION_PROVIDERS = [ExternalProviderEnum.SLACK, ExternalProviderEnum.SLACK_STAGING] def sort_settings_by_scope(setting: NotificationSettingOption | NotificationSettingProvider) -> int: diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 8ae2d6418249..d51c7eee27cb 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -206,3 +206,8 @@ def _send_with_threading( return SendSuccessResult(provider_message_id=response.get("ts"), is_threaded=True) except IntegrationError as e: return integration_error_result(e, is_threaded=True) + + +@provider_registry.register(NotificationProviderKey.SLACK_STAGING) +class SlackStagingNotificationProvider(SlackNotificationProvider): + key = NotificationProviderKey.SLACK_STAGING diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index b3b6765173b4..78c717dbb676 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -114,6 +114,7 @@ class NotificationProviderKey(StrEnum): EMAIL = ExternalProviderEnum.EMAIL SLACK = ExternalProviderEnum.SLACK + SLACK_STAGING = ExternalProviderEnum.SLACK_STAGING MSTEAMS = ExternalProviderEnum.MSTEAMS DISCORD = ExternalProviderEnum.DISCORD diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 63979642b702..3e9788bdbb0e 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -687,10 +687,6 @@ register("slack.debug-channel", flags=FLAG_AUTOMATOR_MODIFIABLE) # Log unfurl payloads for debugging register("slack.log-unfurl-payload", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) -# When enabled, new Slack installations will request extended scopes -# (reactions:write, channels:history, groups:history, app_mentions:read) -# Existing installations must re-authorize to get these scopes. -register("slack.extended-scopes-enabled", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) # Slack Staging App register("slack-staging.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 6a29b4add734..79098a2222d5 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -211,5 +211,8 @@ # Boolean to enable/disable build distribution PR comments for this project. register(key="sentry:preprod_distribution_pr_comments_enabled_by_customer", default=True) +# Boolean to enable/disable snapshot PR comments for this project. +register(key="sentry:preprod_snapshot_pr_comments_enabled", default=True) + # Whether to enable on-demand source context fetching from SCM integrations register(key="sentry:scm_source_context_enabled", default=False) diff --git a/src/sentry/runner/commands/notifications.py b/src/sentry/runner/commands/notifications.py index 0a57d03ffdeb..6254e49f6d5e 100644 --- a/src/sentry/runner/commands/notifications.py +++ b/src/sentry/runner/commands/notifications.py @@ -66,39 +66,24 @@ def send_email(source: str, email: str) -> None: click.echo(f"Example '{source}' email sent to {email}.") -@send_cmd.command("slack") -@click.option( - "-s", - "--source", - help="Registered template source (see `sentry notifications list registry`)", - default="error-alert-service", -) -@click.option("-o", "--organization_slug", help="Organization slug") -@click.option("-i", "--integration_name", help="Slack integration name", default=None) -@click.option("-c", "--channel_name", help="Slack channel name", default=None) -def send_slack( - source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +def _send_slack_notification( + source: str, + organization_slug: str, + integration_name: str | None, + channel_name: str | None, + provider_slug: str, + provider_key: Any, + provider_label: str, ) -> None: - """ - Send a Slack notification. - """ from sentry import options - from sentry.runner import configure - - configure() - from sentry.constants import ObjectStatus from sentry.integrations.models.integration import Integration from sentry.integrations.slack.utils.channel import get_channel_id - from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organizationmapping import OrganizationMapping from sentry.notifications.platform.registry import template_registry from sentry.notifications.platform.service import NotificationService from sentry.notifications.platform.target import IntegrationNotificationTarget - from sentry.notifications.platform.types import ( - NotificationProviderKey, - NotificationTargetResourceType, - ) + from sentry.notifications.platform.types import NotificationTargetResourceType try: organization_mapping = OrganizationMapping.objects.get(slug=organization_slug) @@ -109,7 +94,7 @@ def send_slack( integration_name = integration_name or options.get("slack.debug-workspace") if integration_name is None or integration_name == "": click.echo( - "\nThis command requires a slack integration name." + f"\nThis command requires a {provider_label} integration name." "\nProvide it with the `-i` flag or by setting `slack.debug-workspace` in .sentry/config.yml." f"\nBrowse the local integrations with `sentry notifications list integrations -o {organization_slug}`." ) @@ -117,12 +102,12 @@ def send_slack( try: integration = Integration.objects.get( - provider=IntegrationProviderSlug.SLACK.value, + provider=provider_slug, name=integration_name, status=ObjectStatus.ACTIVE, ) except Integration.DoesNotExist: - click.echo(f"Slack integration '{integration_name}' not found!") + click.echo(f"{provider_label} integration '{integration_name}' not found!") return channel_name = channel_name or options.get("slack.debug-channel") @@ -144,7 +129,7 @@ def send_slack( return slack_target = IntegrationNotificationTarget( - provider_key=NotificationProviderKey.SLACK, + provider_key=provider_key, resource_type=NotificationTargetResourceType.CHANNEL, integration_id=integration.id, resource_id=channel_data.channel_id, @@ -153,7 +138,77 @@ def send_slack( template_cls = template_registry.get(source) NotificationService(data=template_cls.example_data).notify_sync(targets=[slack_target]) - click.echo(f"Example '{source}' slack message sent to {integration.name}.") + click.echo(f"Example '{source}' {provider_label} message sent to {integration.name}.") + + +@send_cmd.command("slack") +@click.option( + "-s", + "--source", + help="Registered template source (see `sentry notifications list registry`)", + default="error-alert-service", +) +@click.option("-o", "--organization_slug", help="Organization slug") +@click.option("-i", "--integration_name", help="Slack integration name", default=None) +@click.option("-c", "--channel_name", help="Slack channel name", default=None) +def send_slack( + source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +) -> None: + """ + Send a Slack notification. + """ + + from sentry.runner import configure + + configure() + + from sentry.integrations.types import IntegrationProviderSlug + from sentry.notifications.platform.types import NotificationProviderKey + + _send_slack_notification( + source=source, + organization_slug=organization_slug, + integration_name=integration_name, + channel_name=channel_name, + provider_slug=IntegrationProviderSlug.SLACK.value, + provider_key=NotificationProviderKey.SLACK, + provider_label="Slack", + ) + + +@send_cmd.command("slack-staging") +@click.option( + "-s", + "--source", + help="Registered template source (see `sentry notifications list registry`)", + default="error-alert-service", +) +@click.option("-o", "--organization_slug", help="Organization slug") +@click.option("-i", "--integration_name", help="Slack (Staging) integration name", default=None) +@click.option("-c", "--channel_name", help="Slack channel name", default=None) +def send_slack_staging( + source: str, organization_slug: str, integration_name: str | None, channel_name: str | None +) -> None: + """ + Send a Slack (Staging) notification. + """ + + from sentry.runner import configure + + configure() + + from sentry.integrations.types import IntegrationProviderSlug + from sentry.notifications.platform.types import NotificationProviderKey + + _send_slack_notification( + source=source, + organization_slug=organization_slug, + integration_name=integration_name, + channel_name=channel_name, + provider_slug=IntegrationProviderSlug.SLACK_STAGING.value, + provider_key=NotificationProviderKey.SLACK_STAGING, + provider_label="Slack (Staging)", + ) @send_cmd.command("msteams") diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index f51dabaa6746..1e84f2b031a0 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -349,12 +349,10 @@ def __init__( ): from sentry.integrations.services.integration import integration_service from sentry.integrations.slack.integration import SlackIntegration - from sentry.integrations.types import IntegrationProviderSlug integration = integration_service.get_integration( integration_id=integration_id, organization_id=organization_id, - provider=IntegrationProviderSlug.SLACK.value, status=ObjectStatus.ACTIVE, ) if not integration: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 83296b6785d4..5ed6c48d2c19 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -10,7 +10,6 @@ from sentry.constants import ObjectStatus from sentry.integrations.services.integration.service import integration_service -from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.platform.registry import provider_registry, template_registry from sentry.notifications.platform.service import ( NotificationService, @@ -125,7 +124,6 @@ def process_thread_update( integration = integration_service.get_integration( integration_id=integration_id, organization_id=organization_id, - provider=IntegrationProviderSlug.SLACK.value, status=ObjectStatus.ACTIVE, ) if not integration: diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index 9151568e9fc6..fd30fd0b2265 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -6,8 +6,8 @@ from taskbroker_client.retry import Retry from sentry.identity.services.identity import identity_service +from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.seer.entrypoints.metrics import ( @@ -98,7 +98,7 @@ def process_mention_for_slack( return user = _resolve_user( - integration_external_id=entrypoint.integration.external_id, + integration=entrypoint.integration, slack_user_id=slack_user_id, ) if not user: @@ -152,13 +152,13 @@ def process_mention_for_slack( def _resolve_user( *, - integration_external_id: str, + integration: RpcIntegration, slack_user_id: str, ) -> RpcUser | None: """Resolve the Sentry user from a Slack user ID via linked identity.""" provider = identity_service.get_provider( - provider_type=IntegrationProviderSlug.SLACK.value, - provider_ext_id=integration_external_id, + provider_type=integration.provider, + provider_ext_id=integration.external_id, ) if not provider: return None diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index cbc834ca9e56..3790497227bd 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -1297,6 +1297,10 @@ r"^slack/", include("sentry.integrations.slack.urls"), ), + re_path( + r"^slack-staging/", + include("sentry.integrations.slack.staging.urls"), + ), re_path( r"^github/", include("sentry.integrations.github.urls"), diff --git a/src/sentry/workflow_engine/models/action.py b/src/sentry/workflow_engine/models/action.py index 840121b25dba..df76a491d996 100644 --- a/src/sentry/workflow_engine/models/action.py +++ b/src/sentry/workflow_engine/models/action.py @@ -55,6 +55,7 @@ class Action(DefaultFieldsModel, JSONConfigBase): class Type(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" MSTEAMS = "msteams" DISCORD = "discord" diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index 002a39d64bcc..a4f0ad1ef229 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -12,6 +12,7 @@ from sentry.models.environment import Environment from sentry.services.eventstore.models import GroupEvent from sentry.workflow_engine.buffer.batch_client import DelayedWorkflowClient, DelayedWorkflowItem +from sentry.workflow_engine.caches.action_filters import get_action_filters_by_workflows from sentry.workflow_engine.caches.workflow import get_workflows_by_detectors from sentry.workflow_engine.models import DataConditionGroup, Detector, DetectorWorkflow, Workflow from sentry.workflow_engine.models.data_condition import DataCondition @@ -269,12 +270,24 @@ def evaluate_workflows_action_filters( queue_items_by_workflow.keys() ) - action_conditions_to_workflow: dict[DataConditionGroup, Workflow] = { - wdcg.condition_group: wdcg.workflow - for wdcg in WorkflowDataConditionGroup.objects.select_related( - "workflow", "condition_group" - ).filter(workflow__in=all_workflows) - } + organization = event_data.event.project.organization + + action_conditions_to_workflow: dict[DataConditionGroup, Workflow] = {} + + if features.has("organizations:workflow-engine-action-filters-cache", organization): + all_workflows_lookup: dict[int, Workflow] = {w.id: w for w in all_workflows} + action_filters_by_workflows = get_action_filters_by_workflows(all_workflows) + + for workflow_id, dcgs in action_filters_by_workflows.items(): + for dcg in dcgs: + action_conditions_to_workflow[dcg] = all_workflows_lookup[workflow_id] + else: + action_conditions_to_workflow = { + wdcg.condition_group: wdcg.workflow + for wdcg in WorkflowDataConditionGroup.objects.select_related( + "workflow", "condition_group" + ).filter(workflow__in=all_workflows) + } filtered_action_groups: set[DataConditionGroup] = set() diff --git a/src/sentry/workflow_engine/typings/notification_action.py b/src/sentry/workflow_engine/typings/notification_action.py index 2c488c40221a..69318dce88ff 100644 --- a/src/sentry/workflow_engine/typings/notification_action.py +++ b/src/sentry/workflow_engine/typings/notification_action.py @@ -41,6 +41,7 @@ class FallthroughChoiceType(Enum): class ActionType(StrEnum): SLACK = "slack" + SLACK_STAGING = "slack_staging" MSTEAMS = "msteams" DISCORD = "discord" @@ -115,6 +116,12 @@ class ActionFieldMapping(TypedDict): target_identifier_key="channel_id", target_display_key="channel", ), + ActionType.SLACK_STAGING: ActionFieldMapping( + id="sentry.integrations.slack.staging.notify_action.SlackStagingNotifyServiceAction", + integration_id_key="workspace", + target_identifier_key="channel_id", + target_display_key="channel", + ), ActionType.DISCORD: ActionFieldMapping( id="sentry.integrations.discord.notify_action.DiscordNotifyServiceAction", integration_id_key="server", @@ -299,13 +306,13 @@ def action_type(self) -> ActionType: @property def required_fields(self) -> list[str]: return [ - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.INTEGRATION_ID_KEY.value ], - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.TARGET_IDENTIFIER_KEY.value ], - ACTION_FIELD_MAPPINGS[ActionType.SLACK][ + ACTION_FIELD_MAPPINGS[self.action_type][ ActionFieldMappingKeys.TARGET_DISPLAY_KEY.value ], ] @@ -319,6 +326,12 @@ def blob_type(self) -> type[DataBlob]: return SlackDataBlob +class SlackStagingActionTranslator(SlackActionTranslator): + @property + def action_type(self) -> ActionType: + return ActionType.SLACK_STAGING + + class DiscordActionTranslator(BaseActionTranslator): @property def action_type(self) -> ActionType: @@ -766,6 +779,7 @@ class EmailDataBlob(DataBlob): issue_alert_action_translator_mapping: dict[str, type[BaseActionTranslator]] = { ACTION_FIELD_MAPPINGS[ActionType.SLACK]["id"]: SlackActionTranslator, + ACTION_FIELD_MAPPINGS[ActionType.SLACK_STAGING]["id"]: SlackStagingActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.DISCORD]["id"]: DiscordActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.MSTEAMS]["id"]: MSTeamsActionTranslator, ACTION_FIELD_MAPPINGS[ActionType.PAGERDUTY]["id"]: PagerDutyActionTranslator, diff --git a/static/app/components/events/autofix/autofixSolutionEventItem.tsx b/static/app/components/events/autofix/autofixSolutionEventItem.tsx index 246b1aed9c05..26a63e4aa8be 100644 --- a/static/app/components/events/autofix/autofixSolutionEventItem.tsx +++ b/static/app/components/events/autofix/autofixSolutionEventItem.tsx @@ -187,7 +187,7 @@ export function SolutionEventItem({ > - {event.relevant_code_file && event.relevant_code_file.url && ( + {event.relevant_code_file?.url && ( diff --git a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx index bc9823575a2f..a8c2ceafc146 100644 --- a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx +++ b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx @@ -143,8 +143,7 @@ function AddToDashboardModal({ const widgetTemplates = getTopNConvertedDefaultWidgets(organization); const widgetTemplate = widgetTemplates.find(w => w.displayType === widget.displayType); const shouldOpenWidgetLibrary = - !isWidgetEditable(widget.displayType) || - (widgetTemplate && widgetTemplate.isCustomizable === false); + !isWidgetEditable(widget.displayType) || widgetTemplate?.isCustomizable === false; const handleWidgetTableSort = (sort: Sort) => { const newOrderBy = `${sort.kind === 'desc' ? '-' : ''}${sort.field}`; diff --git a/static/app/components/searchSyntax/mutableSearch.tsx b/static/app/components/searchSyntax/mutableSearch.tsx index be4fd712a30c..ddf6cce29ac5 100644 --- a/static/app/components/searchSyntax/mutableSearch.tsx +++ b/static/app/components/searchSyntax/mutableSearch.tsx @@ -188,10 +188,10 @@ function parseToFlatTokens(query: string): Token[] { let rawVal: string; let valueWasQuoted = false; let listValues: string[] | undefined; - if (t.value && t.value.type === ParserToken.VALUE_TEXT) { + if (t.value?.type === ParserToken.VALUE_TEXT) { rawVal = t.value.value; valueWasQuoted = t.value.quoted; - } else if (t.value && t.value.type === ParserToken.VALUE_TEXT_LIST) { + } else if (t.value?.type === ParserToken.VALUE_TEXT_LIST) { // Extract individual list items from the AST listValues = t.value.items .map(item => item.value?.value ?? '') diff --git a/static/app/plugins/components/pluginIcon.tsx b/static/app/plugins/components/pluginIcon.tsx index e18ea7faa60a..8facbad4276c 100644 --- a/static/app/plugins/components/pluginIcon.tsx +++ b/static/app/plugins/components/pluginIcon.tsx @@ -69,6 +69,7 @@ const PLUGIN_ICONS = { redmine, segment, slack, + slack_staging: slack, splunk, trello, twilio, diff --git a/static/app/stores/guideStore.tsx b/static/app/stores/guideStore.tsx index fc1928562cb3..4911a3fba0c6 100644 --- a/static/app/stores/guideStore.tsx +++ b/static/app/stores/guideStore.tsx @@ -235,7 +235,7 @@ const storeConfig: GuideStoreDefinition = { return; } - if (!prevGuide || prevGuide.guide !== nextGuide.guide) { + if (prevGuide?.guide !== nextGuide.guide) { this.recordCue(nextGuide.guide); this.state = {...this.state, prevGuide: nextGuide}; } diff --git a/static/app/stories/index.tsx b/static/app/stories/index.tsx index c463a3d1d6cb..ef46fd3fe3bd 100644 --- a/static/app/stories/index.tsx +++ b/static/app/stories/index.tsx @@ -1,4 +1,3 @@ -export {APIReference} from './apiReference'; export {ColorReference} from './colorReference'; export {Demo} from './demo'; export {JSXNode, JSXProperty} from './jsx'; @@ -8,6 +7,5 @@ export {Section} from './layout'; export {SideBySide} from './layout'; export {SizingWindow, Grid} from './layout'; export {story} from './storybook'; -export {ThemeSwitcher} from './theme'; export {TokenReference} from './tokenReference'; export {StoryTable as Table} from './table'; diff --git a/static/app/stories/storybook.tsx b/static/app/stories/storybook.tsx index 972303bd69b2..39f989d75e16 100644 --- a/static/app/stories/storybook.tsx +++ b/static/app/stories/storybook.tsx @@ -1,14 +1,19 @@ import type {ReactNode} from 'react'; -import {Children, Fragment, useEffect} from 'react'; +import {Children, Fragment, Suspense, lazy, useEffect} from 'react'; import {Container} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; -import {StoryHeading} from 'sentry/stories/view/storyHeading'; - -import {APIReference} from './apiReference'; import {Section, SideBySide} from './layout'; +// Lazy-loaded to bypass circular dependencies on Button +const StoryHeading = lazy(() => + import('sentry/stories/view/storyHeading').then(m => ({default: m.StoryHeading})) +); +const APIReference = lazy(() => + import('./apiReference').then(m => ({default: m.APIReference})) +); + function makeStorybookDocumentTitle(title: string | undefined): string { return title ? `${title} — Scraps` : 'Scraps'; } @@ -51,7 +56,9 @@ export function story(title: string, setup: SetupFunction): StoryRenderFunction ))} {APIDocumentation.map((documentation, i) => ( - + + + ))} ); @@ -65,9 +72,11 @@ function Story(props: {name: string; render: StoryRenderFunction}) { return (
- - {props.name} - + {props.name}}> + + {props.name} + + {isOneChild ? children : {children}}
diff --git a/static/app/stories/view/storyExports.tsx b/static/app/stories/view/storyExports.tsx index 6b9ed03caefa..0b874637af5d 100644 --- a/static/app/stories/view/storyExports.tsx +++ b/static/app/stories/view/storyExports.tsx @@ -13,6 +13,7 @@ import {Heading, Text} from '@sentry/scraps/text'; import {t} from 'sentry/locale'; import * as Storybook from 'sentry/stories'; +import {APIReference} from 'sentry/stories/apiReference'; import {useQuery} from 'sentry/utils/queryClient'; import {StoryFooter} from './storyFooter'; @@ -250,7 +251,7 @@ function StoryAPI(props: {documentation: TypeLoader.TypeLoaderResult | undefined return ( {Object.entries(props.documentation.props ?? {}).map(([key, value]) => { - return ; + return ; })} ); diff --git a/static/app/stories/view/storyHeader.tsx b/static/app/stories/view/storyHeader.tsx index 530b9817d1aa..66094fd1937e 100644 --- a/static/app/stories/view/storyHeader.tsx +++ b/static/app/stories/view/storyHeader.tsx @@ -6,7 +6,7 @@ import {Link} from '@sentry/scraps/link'; import {Heading} from '@sentry/scraps/text'; import {IconGithub, IconLink} from 'sentry/icons'; -import * as Storybook from 'sentry/stories'; +import {ThemeSwitcher} from 'sentry/stories/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -57,7 +57,7 @@ export function StoryHeader() { sentry.io - + ); diff --git a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx index b0543f23035d..b778469fec4b 100644 --- a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx +++ b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx @@ -62,7 +62,7 @@ async function extractDiffMutations({ }, onVisitFrame: (frame, collection, replayer) => { const mirror = replayer.getMirror(); - if (lastFrame && lastFrame.type === EventType.FullSnapshot) { + if (lastFrame?.type === EventType.FullSnapshot) { const node = mirror.getNode(lastFrame.data.node.id) as Document | null; const item = collection.get(lastFrame); if (node && item) { @@ -79,8 +79,7 @@ async function extractDiffMutations({ }; } } else if ( - lastFrame && - lastFrame.type === EventType.IncrementalSnapshot && + lastFrame?.type === EventType.IncrementalSnapshot && 'source' in lastFrame.data && lastFrame.data.source === IncrementalSource.Mutation ) { diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index 7e9bdbcaeb6f..bc601484cc9d 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -864,7 +864,7 @@ function findCanvasInMutation(event: incrementalSnapshotEvent) { } return event.data.adds.find( - add => add.node && add.node.type === 2 && add.node.tagName === 'canvas' + add => add.node?.type === 2 && add.node.tagName === 'canvas' ); } diff --git a/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx b/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx index b25b2abe77e0..a52f3af39069 100644 --- a/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx +++ b/static/app/views/dashboards/utils/getWidgetExploreUrl.tsx @@ -89,6 +89,7 @@ const WIDGET_TRACE_ITEM_TO_URL_FUNCTION: Record< [TraceItemDataset.PREPROD]: undefined, [TraceItemDataset.REPLAYS]: undefined, [TraceItemDataset.PROCESSING_ERRORS]: undefined, + [TraceItemDataset.ERRORS]: undefined, }; export function getWidgetExploreUrl( diff --git a/static/app/views/explore/errors/content.spec.tsx b/static/app/views/explore/errors/content.spec.tsx index 80133e7ceb75..d4198cf7f3ad 100644 --- a/static/app/views/explore/errors/content.spec.tsx +++ b/static/app/views/explore/errors/content.spec.tsx @@ -1,13 +1,61 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; + import ErrorsContent from './content'; describe('ErrorsContent', () => { - it('renders the Errors page title', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState({ + projects: [], + environments: [], + datetime: {period: '14d', start: null, end: null, utc: false}, + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + method: 'GET', + body: [ProjectFixture()], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + method: 'GET', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/trace-items/attributes/', + method: 'GET', + body: [], + }); + }); + + it('renders the Errors page title', async () => { const organization = OrganizationFixture(); render(, {organization}); - expect(screen.getByText('Errors')).toBeInTheDocument(); + expect(await screen.findByText('Errors')).toBeInTheDocument(); + }); + + it('renders page filter bar with project, environment, and date filters', async () => { + const organization = OrganizationFixture(); + render(, {organization}); + + expect(await screen.findByTestId('page-filter-project-selector')).toBeInTheDocument(); + expect(screen.getByTestId('page-filter-environment-selector')).toBeInTheDocument(); + expect(screen.getByTestId('page-filter-timerange-selector')).toBeInTheDocument(); + }); + + it('renders the search query builder', async () => { + const organization = OrganizationFixture(); + render(, {organization}); + + expect( + await screen.findByRole('combobox', {name: /add a search term/i}) + ).toBeInTheDocument(); }); }); diff --git a/static/app/views/explore/errors/content.tsx b/static/app/views/explore/errors/content.tsx index bce9acfec0a0..2d3fb49ad0fb 100644 --- a/static/app/views/explore/errors/content.tsx +++ b/static/app/views/explore/errors/content.tsx @@ -1,15 +1,27 @@ +import {FeatureBadge} from '@sentry/scraps/badge'; + +import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; 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'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {ExploreBodySearch} from 'sentry/views/explore/components/styles'; +import {ErrorsFilterSection} from 'sentry/views/explore/errors/filterContent'; export default function ErrorsContent() { const organization = useOrganization(); + // TODO: max pickable days logic for error occurences return ( + + + + + ); @@ -19,9 +31,13 @@ function ErrorsHeader() { return ( - {t('Errors')} + + {t('Errors')} + - + + + ); } diff --git a/static/app/views/explore/errors/filterContent.tsx b/static/app/views/explore/errors/filterContent.tsx new file mode 100644 index 000000000000..2e0240d87e6a --- /dev/null +++ b/static/app/views/explore/errors/filterContent.tsx @@ -0,0 +1,46 @@ +import {Grid} from '@sentry/scraps/layout'; + +import * as Layout from 'sentry/components/layouts/thirds'; +import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter'; +import {EnvironmentPageFilter} from 'sentry/components/pageFilters/environment/environmentPageFilter'; +import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {SearchQueryBuilderProvider} from 'sentry/components/searchQueryBuilder/context'; +import {t} from 'sentry/locale'; +import {TraceItemSearchQueryBuilder} from 'sentry/views/explore/components/traceItemSearchQueryBuilder'; +import {StyledPageFilterBar} from 'sentry/views/explore/spans/spansTabSearchSection'; +import {TraceItemDataset} from 'sentry/views/explore/types'; + +export function ErrorsFilterSection() { + return ( + + Promise.resolve([])} + initialQuery="" + searchSource="errors-filter" + placeholder={t('Search for errors, users, tags, and more')} + > + {/* TODO: add in min-content column for cross event querying when that's implemented */} + + + + + + + + + + + + ); +} diff --git a/static/app/views/explore/spans/spansTabSearchSection.tsx b/static/app/views/explore/spans/spansTabSearchSection.tsx index 37c1d1fcff9b..31880086f743 100644 --- a/static/app/views/explore/spans/spansTabSearchSection.tsx +++ b/static/app/views/explore/spans/spansTabSearchSection.tsx @@ -506,6 +506,6 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection ); } -const StyledPageFilterBar = styled(PageFilterBar)` +export const StyledPageFilterBar = styled(PageFilterBar)` width: auto; `; diff --git a/static/app/views/explore/types.tsx b/static/app/views/explore/types.tsx index b25570a25bab..ea87fca37886 100644 --- a/static/app/views/explore/types.tsx +++ b/static/app/views/explore/types.tsx @@ -8,6 +8,7 @@ export enum TraceItemDataset { PREPROD = 'preprod', REPLAYS = 'replays', PROCESSING_ERRORS = 'processing_errors', + ERRORS = 'errors', } export interface UseTraceItemAttributeBaseProps { diff --git a/static/app/views/explore/utils.tsx b/static/app/views/explore/utils.tsx index 6c984930ea2d..cd4a259243f1 100644 --- a/static/app/views/explore/utils.tsx +++ b/static/app/views/explore/utils.tsx @@ -713,6 +713,7 @@ const TRACE_ITEM_TO_URL_FUNCTION: Record< [TraceItemDataset.PREPROD]: undefined, [TraceItemDataset.REPLAYS]: getReplayUrlFromSavedQueryUrl, [TraceItemDataset.PROCESSING_ERRORS]: undefined, + [TraceItemDataset.ERRORS]: undefined, }; /** diff --git a/static/app/views/insights/database/components/tables/queriesTable.tsx b/static/app/views/insights/database/components/tables/queriesTable.tsx index 765b97a62750..de655ebed312 100644 --- a/static/app/views/insights/database/components/tables/queriesTable.tsx +++ b/static/app/views/insights/database/components/tables/queriesTable.tsx @@ -171,7 +171,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx b/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx index 33444265689e..d66f3355106e 100644 --- a/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx +++ b/static/app/views/insights/database/components/tables/queryTransactionsTable.tsx @@ -177,7 +177,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/mobile/common/components/tables/screensTable.tsx b/static/app/views/insights/mobile/common/components/tables/screensTable.tsx index 632df623ea7f..3c71dbf926c8 100644 --- a/static/app/views/insights/mobile/common/components/tables/screensTable.tsx +++ b/static/app/views/insights/mobile/common/components/tables/screensTable.tsx @@ -77,7 +77,7 @@ export function ScreensTable({ column: GridColumn, row: TableDataRow ): React.ReactNode { - if (!data?.meta || !data?.meta.fields) { + if (!data?.meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx b/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx index f51c2dc3f189..4147100c30cc 100644 --- a/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx +++ b/static/app/views/insights/mobile/screenload/components/tables/eventSamplesTable.tsx @@ -70,7 +70,7 @@ export function EventSamplesTable({ const eventViewColumns = eventView.getColumns(); function renderBodyCell(column: any, row: any): React.ReactNode { - if (!data?.meta || !data?.meta.fields) { + if (!data?.meta?.fields) { return row[column.key]; } diff --git a/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts b/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts index 354df3eae35c..5f4e48f1cea9 100644 --- a/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/insights/pages/conversations/utils/conversationMessages.spec.ts @@ -252,6 +252,16 @@ describe('conversationMessages utilities', () => { const node = createMockNode({id: 'node-1'}); expect(parseUserContent(node as any)).toBeNull(); }); + + it('returns [Filtered] when input messages are scrubbed', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: '[Filtered]', + }, + }); + expect(parseUserContent(node as any)).toBe('[Filtered]'); + }); }); describe('parseAssistantContent', () => { @@ -304,6 +314,16 @@ describe('conversationMessages utilities', () => { const node = createMockNode({id: 'node-1'}); expect(parseAssistantContent(node as any)).toBeNull(); }); + + it('returns [Filtered] when output messages are scrubbed', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_OUTPUT_MESSAGES]: '[Filtered]', + }, + }); + expect(parseAssistantContent(node as any)).toBe('[Filtered]'); + }); }); describe('partitionSpansByType', () => { @@ -613,6 +633,39 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(1); }); + it('does not deduplicate [Filtered] messages across turns', () => { + const turns = [ + { + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + userContent: '[Filtered]', + assistantContent: '[Filtered]', + toolCalls: [], + userEmail: undefined, + }, + { + generation: { + id: 'gen-2', + value: {start_timestamp: 2000, end_timestamp: 2100}, + } as any, + userContent: '[Filtered]', + assistantContent: '[Filtered]', + toolCalls: [], + userEmail: undefined, + }, + ]; + + const messages = turnsToMessages(turns); + + const userMessages = messages.filter(m => m.role === 'user'); + const assistantMessages = messages.filter(m => m.role === 'assistant'); + + expect(userMessages).toHaveLength(2); + expect(assistantMessages).toHaveLength(2); + }); + it('attaches tool calls to assistant messages', () => { const turns = [ { diff --git a/static/app/views/insights/pages/conversations/utils/conversationMessages.ts b/static/app/views/insights/pages/conversations/utils/conversationMessages.ts index d21ac56a53db..86e60d42c08e 100644 --- a/static/app/views/insights/pages/conversations/utils/conversationMessages.ts +++ b/static/app/views/insights/pages/conversations/utils/conversationMessages.ts @@ -9,6 +9,8 @@ import { import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; import {SpanFields} from 'sentry/views/insights/types'; +const FILTERED = '[Filtered]'; + export interface ToolCall { hasError: boolean; name: string; @@ -152,7 +154,10 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ for (const turn of turns) { const timestamp = getNodeTimestamp(turn.generation); - if (turn.userContent && !seenUserContent.has(turn.userContent)) { + if ( + turn.userContent && + (turn.userContent === FILTERED || !seenUserContent.has(turn.userContent)) + ) { seenUserContent.add(turn.userContent); messages.push({ id: `user-${turn.generation.id}`, @@ -164,7 +169,11 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ }); } - if (turn.assistantContent && !seenAssistantContent.has(turn.assistantContent)) { + if ( + turn.assistantContent && + (turn.assistantContent === FILTERED || + !seenAssistantContent.has(turn.assistantContent)) + ) { seenAssistantContent.add(turn.assistantContent); // Duration: from start of generation span to end of last span (generation or tool) @@ -215,6 +224,10 @@ export function parseUserContent(node: AITraceSpanNode): string | null { return null; } + if (requestMessages === FILTERED) { + return FILTERED; + } + try { const messagesArray: RequestMessage[] = JSON.parse(requestMessages); const userMessage = messagesArray.findLast( @@ -233,6 +246,10 @@ export function parseAssistantContent(node: AITraceSpanNode): string | null { const outputMessages = getStringAttr(node, SpanFields.GEN_AI_OUTPUT_MESSAGES); if (outputMessages) { + if (outputMessages === FILTERED) { + return FILTERED; + } + try { const messagesArray: RequestMessage[] = JSON.parse(outputMessages); const assistantMessage = messagesArray.findLast( diff --git a/static/app/views/performance/eap/overviewSpansTable.tsx b/static/app/views/performance/eap/overviewSpansTable.tsx index 94db3235e325..b79f1d263812 100644 --- a/static/app/views/performance/eap/overviewSpansTable.tsx +++ b/static/app/views/performance/eap/overviewSpansTable.tsx @@ -200,7 +200,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/performance/eap/segmentSpansTable.tsx b/static/app/views/performance/eap/segmentSpansTable.tsx index 2afc650f11e1..2f12c3b65a92 100644 --- a/static/app/views/performance/eap/segmentSpansTable.tsx +++ b/static/app/views/performance/eap/segmentSpansTable.tsx @@ -228,7 +228,7 @@ function renderBodyCell( ); } - if (!meta || !meta?.fields) { + if (!meta?.fields) { return row[column.key]; } diff --git a/static/app/views/performance/newTraceDetails/traceConfigurations.tsx b/static/app/views/performance/newTraceDetails/traceConfigurations.tsx index b2ffe3ca9d89..6b45d3f4b678 100644 --- a/static/app/views/performance/newTraceDetails/traceConfigurations.tsx +++ b/static/app/views/performance/newTraceDetails/traceConfigurations.tsx @@ -37,7 +37,7 @@ function parsePlatform(platform: string): ParsedPlatform { export function getCustomInstrumentationLink(project: Project | undefined): string { // Default to JavaScript guide if project or platform is not available - if (!project || !project.platform) { + if (!project?.platform) { return `https://docs.sentry.io/platforms/javascript/tracing/instrumentation/custom-instrumentation/`; } diff --git a/static/app/views/settings/account/notifications/constants.tsx b/static/app/views/settings/account/notifications/constants.tsx index e91a4335ac1c..e143d852fce3 100644 --- a/static/app/views/settings/account/notifications/constants.tsx +++ b/static/app/views/settings/account/notifications/constants.tsx @@ -1,4 +1,9 @@ -export const SUPPORTED_PROVIDERS = ['email', 'slack', 'msteams'] as const; +export const SUPPORTED_PROVIDERS = [ + 'email', + 'slack', + 'slack_staging', + 'msteams', +] as const; export type SupportedProviders = (typeof SUPPORTED_PROVIDERS)[number]; type ProviderValue = 'always' | 'never'; diff --git a/static/app/views/settings/account/notifications/fields.tsx b/static/app/views/settings/account/notifications/fields.tsx index 6ea6d58b738b..4c4cc91838eb 100644 --- a/static/app/views/settings/account/notifications/fields.tsx +++ b/static/app/views/settings/account/notifications/fields.tsx @@ -159,6 +159,7 @@ export const NOTIFICATION_SETTING_FIELDS = { choices: [ ['email', t('Email')], ['slack', t('Slack')], + ['slack_staging', t('Slack (Staging)')], ['msteams', t('Microsoft Teams')], ], help: t('Where personal notifications will be sent.'), diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index bc4dc9fb7d52..6f5270697b63 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -46,6 +46,9 @@ const typeMappedChildren: Record = { quota: QUOTA_FIELDS.map(field => field.name), }; +// Ideally, we could just use SUPPORTED_PROVIDERS here, but 'msteams' is not widely tested. +const ALLOWED_PROVIDERS = new Set(SUPPORTED_PROVIDERS.filter(p => p.includes('slack'))); + const getQueryParams = (notificationType: string) => { // if we need multiple settings on this page // then omit the type so we can load all settings @@ -86,21 +89,22 @@ export function NotificationSettingsByType({notificationType}: Props) { ], {staleTime: 30_000} ); - const {data: identities = [], status: identitiesStatus} = useApiQuery( - [ - getApiUrl('/users/$userId/identities/', {path: {userId: 'me'}}), - {query: {provider: 'slack'}}, - ], + const {data: allIdentities = [], status: identitiesStatus} = useApiQuery( + [getApiUrl('/users/$userId/identities/', {path: {userId: 'me'}})], {staleTime: 30_000} ); - const {data: organizationIntegrations = [], status: organizationIntegrationStatus} = + const identities = allIdentities.filter(identity => + ALLOWED_PROVIDERS.has(identity?.identityProvider?.type as SupportedProviders) + ); + + const {data: allOrgIntegrations = [], status: organizationIntegrationStatus} = useApiQuery( - [ - getApiUrl('/users/$userId/organization-integrations/', {path: {userId: 'me'}}), - {query: {provider: 'slack'}}, - ], + [getApiUrl('/users/$userId/organization-integrations/', {path: {userId: 'me'}})], {staleTime: 30_000} ); + const organizationIntegrations = allOrgIntegrations.filter(orgIntegration => + ALLOWED_PROVIDERS.has(orgIntegration.provider.key as SupportedProviders) + ); const {data: defaultSettings, status: defaultSettingsStatus} = useApiQuery([getApiUrl('/notification-defaults/')], { staleTime: 30_000, @@ -353,6 +357,7 @@ export function NotificationSettingsByType({notificationType}: Props) { }); const unlinkedSlackOrgs = getUnlinkedOrgs('slack'); + const unlinkedSlackStagingOrgs = getUnlinkedOrgs('slack_staging'); let notificationDetails = ACCOUNT_NOTIFICATION_FIELDS[notificationType]!; if ( notificationType === 'quota' && @@ -509,6 +514,10 @@ export function NotificationSettingsByType({notificationType}: Props) { unlinkedSlackOrgs.length > 0 ? ( ) : null} + {(field.state.value ?? initialProviders).includes('slack_staging') && + unlinkedSlackStagingOrgs.length > 0 ? ( + + ) : null} ; diff --git a/static/app/views/settings/organizationIntegrations/addIntegration.tsx b/static/app/views/settings/organizationIntegrations/addIntegration.tsx index 0a24a42d47dd..7f632f732bfb 100644 --- a/static/app/views/settings/organizationIntegrations/addIntegration.tsx +++ b/static/app/views/settings/organizationIntegrations/addIntegration.tsx @@ -77,9 +77,7 @@ export class AddIntegration extends Component { organization, ...analyticsParams, }); - const name = modalParams?.use_staging - ? 'sentryAddStagingIntegration' - : 'sentryAddIntegration'; + const name = 'sentryAddIntegration'; const {url, width, height} = provider.setupDialog; const {left, top} = this.computeCenteredWindow(width, height); diff --git a/static/app/views/settings/organizationIntegrations/constants.tsx b/static/app/views/settings/organizationIntegrations/constants.tsx index 80d217b1046d..ae64e97fb6b6 100644 --- a/static/app/views/settings/organizationIntegrations/constants.tsx +++ b/static/app/views/settings/organizationIntegrations/constants.tsx @@ -13,6 +13,7 @@ export const PENDING_DELETION = 'Pending Deletion'; export const POPULARITY_WEIGHT: Record = { // First-party-integrations slack: 50, + slack_staging: 49, github: 20, jira: 15, bitbucket: 10, diff --git a/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx b/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx index a7fedeef2f7a..b324bb10f3af 100644 --- a/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationDetailedView.tsx @@ -328,46 +328,8 @@ export default function IntegrationDetailedView() { return null; } - const showStagingButton = - integrationSlug === 'slack' && - organization.features.includes('slack-staging-app'); - return ( - {showStagingButton && ( - - { - trackIntegrationAnalytics('integrations.installation_start', { - view: 'integrations_directory_integration_detail', - integration: integrationSlug, - integration_type: integrationType, - already_installed: installationStatus !== 'Not Installed', - organization, - }); - }} - buttonProps={{ - ...buttonProps, - 'data-test-id': 'install-staging-button', - buttonText: t('Add %s to Staging', provider.metadata.noun), - }} - /> - - )} {onDemandPeriod} diff --git a/tests/sentry/api/endpoints/test_project_rules_configuration.py b/tests/sentry/api/endpoints/test_project_rules_configuration.py index c57f28d3b670..0e587f6879e0 100644 --- a/tests/sentry/api/endpoints/test_project_rules_configuration.py +++ b/tests/sentry/api/endpoints/test_project_rules_configuration.py @@ -33,7 +33,7 @@ def test_simple(self) -> None: self.create_project(teams=[team], name="baz") response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 9 assert len(response.data["filters"]) == 10 @@ -135,7 +135,7 @@ def test_sentry_app_alertable_webhook(self) -> None: response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 13 + assert len(response.data["actions"]) == 14 assert { "id": "sentry.rules.actions.notify_event_service.NotifyEventServiceAction", "label": "Send a notification via {service}", @@ -165,7 +165,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer: Magic ) response = self.get_success_response(self.organization.slug, project1.slug) - assert len(response.data["actions"]) == 13 + assert len(response.data["actions"]) == 14 assert { "id": SENTRY_APP_ALERT_ACTION, "service": sentry_app.slug, @@ -181,7 +181,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer: Magic def test_issue_type_and_category_filter_feature(self) -> None: response = self.get_success_response(self.organization.slug, self.project.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 9 assert len(response.data["filters"]) == 10 @@ -204,7 +204,7 @@ def test_issue_type_and_category_filter_feature(self) -> None: @with_feature("organizations:event-unique-user-frequency-condition-with-conditions") def test_issue_type_and_category_filter_feature_with_conditions(self) -> None: response = self.get_success_response(self.organization.slug, self.project.slug) - assert len(response.data["actions"]) == 12 + assert len(response.data["actions"]) == 13 assert len(response.data["conditions"]) == 10 assert len(response.data["filters"]) == 10 diff --git a/tests/sentry/core/endpoints/test_project_details.py b/tests/sentry/core/endpoints/test_project_details.py index a583e095d941..c38dac58c9f8 100644 --- a/tests/sentry/core/endpoints/test_project_details.py +++ b/tests/sentry/core/endpoints/test_project_details.py @@ -810,6 +810,13 @@ def test_options(self) -> None: ], ) + def test_preprod_snapshot_pr_comments_option(self) -> None: + self.get_success_response( + self.org_slug, self.proj_slug, preprodSnapshotPrCommentsEnabled=False + ) + project = Project.objects.get(id=self.project.id) + assert project.get_option("sentry:preprod_snapshot_pr_comments_enabled") is False + def test_bookmarks(self) -> None: self.get_success_response(self.org_slug, self.proj_slug, isBookmarked="false") assert not ProjectBookmark.objects.filter( diff --git a/tests/sentry/integrations/slack/staging/__init__.py b/tests/sentry/integrations/slack/staging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/integrations/slack/staging/test_feature_flag.py b/tests/sentry/integrations/slack/staging/test_feature_flag.py new file mode 100644 index 000000000000..969d1beb01c9 --- /dev/null +++ b/tests/sentry/integrations/slack/staging/test_feature_flag.py @@ -0,0 +1,45 @@ +from sentry.testutils.cases import APITestCase, TestCase +from sentry.testutils.silo import control_silo_test + + +class SlackStagingConfigVisibilityTest(APITestCase): + """Test that the slack-staging provider is only visible to orgs with the feature flag.""" + + endpoint = "sentry-api-0-organization-config-integrations" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def test_hidden_without_flag(self) -> None: + response = self.get_success_response(self.organization.slug) + provider_keys = [p["key"] for p in response.data["providers"]] + assert "slack_staging" not in provider_keys + + def test_visible_with_flag(self) -> None: + with self.feature("organizations:integrations-slack-staging"): + response = self.get_success_response(self.organization.slug) + provider_keys = [p["key"] for p in response.data["providers"]] + assert "slack_staging" in provider_keys + + +@control_silo_test +class SlackStagingSetupAccessTest(TestCase): + """Test that the slack-staging install flow is gated by the feature flag.""" + + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(name="test", owner=self.user) + self.login_as(self.user) + self.path = f"/organizations/{self.organization.slug}/integrations/slack_staging/setup/" + + def test_setup_blocked_without_flag(self) -> None: + resp = self.client.get(self.path) + assert resp.status_code == 404 + + def test_setup_allowed_with_flag(self) -> None: + with self.feature("organizations:integrations-slack-staging"): + resp = self.client.get(self.path) + # 302 to Slack OAuth is the expected behavior for a valid setup flow + assert resp.status_code == 302 + assert "slack.com/oauth" in resp["Location"] diff --git a/tests/sentry/integrations/web/test_organization_integration_setup.py b/tests/sentry/integrations/web/test_organization_integration_setup.py index 6f9bdbde7fae..d14a03cabe7c 100644 --- a/tests/sentry/integrations/web/test_organization_integration_setup.py +++ b/tests/sentry/integrations/web/test_organization_integration_setup.py @@ -84,3 +84,14 @@ def test_disallow_integration_with_all_features_disabled(self) -> None: b"At least one feature from this list has to be enabled in order to setup the integration" in resp.content ) + + def test_requires_feature_flag_provider_blocked_without_flag(self) -> None: + """Providers with requires_feature_flag=True return 404 without the flag.""" + self.path = f"/organizations/{self.organization.slug}/integrations/slack_staging/setup/" + resp = self.client.get(self.path) + assert resp.status_code == 404 + + def test_regular_provider_unaffected_by_requires_feature_flag_check(self) -> None: + """Providers without requires_feature_flag still work through the pipeline.""" + resp = self.client.get(self.path) + assert resp.status_code == 200 diff --git a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py index b97a8507ae3b..451d3ec41206 100644 --- a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py +++ b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_providers.py @@ -126,7 +126,7 @@ def test_simple(self) -> None: value=NotificationSettingsOptionEnum.ALWAYS.value, provider=ExternalProviderEnum.SLACK.value, ).exists() - assert len(response.data) == 3 + assert len(response.data) == 4 def test_invalid_scope_type(self) -> None: response = self.get_error_response( diff --git a/tests/sentry/notifications/platform/test_registry.py b/tests/sentry/notifications/platform/test_registry.py index ebf3cac5a9c7..ae4cdf6e88f9 100644 --- a/tests/sentry/notifications/platform/test_registry.py +++ b/tests/sentry/notifications/platform/test_registry.py @@ -2,7 +2,10 @@ from sentry.notifications.platform.email.provider import EmailNotificationProvider from sentry.notifications.platform.msteams.provider import MSTeamsNotificationProvider from sentry.notifications.platform.registry import provider_registry, template_registry -from sentry.notifications.platform.slack.provider import SlackNotificationProvider +from sentry.notifications.platform.slack.provider import ( + SlackNotificationProvider, + SlackStagingNotificationProvider, +) from sentry.testutils.cases import TestCase @@ -12,6 +15,7 @@ def test_get_all(self) -> None: expected_providers = [ EmailNotificationProvider, SlackNotificationProvider, + SlackStagingNotificationProvider, MSTeamsNotificationProvider, DiscordNotificationProvider, ] diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 347ad9919cde..578d88721373 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -15,16 +15,18 @@ def test_registers_legacy_providers(self) -> None: """ from sentry.notifications.notify import registry - assert len(registry) == 3 + assert len(registry) == 4 assert registry[ExternalProviders.EMAIL] is not None assert registry[ExternalProviders.SLACK] is not None + assert registry[ExternalProviders.SLACK_STAGING] is not None assert registry[ExternalProviders.MSTEAMS] is not None def test_registers_platform_providers(self) -> None: from sentry.notifications.platform.registry import provider_registry - assert len(provider_registry.registrations) == 4 + assert len(provider_registry.registrations) == 5 assert provider_registry.get(NotificationProviderKey.DISCORD) is not None assert provider_registry.get(NotificationProviderKey.EMAIL) is not None assert provider_registry.get(NotificationProviderKey.MSTEAMS) is not None assert provider_registry.get(NotificationProviderKey.SLACK) is not None + assert provider_registry.get(NotificationProviderKey.SLACK_STAGING) is not None diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index cd02dcf9a8e4..238dafb0925d 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -5,7 +5,11 @@ from django.db.models import Q import sentry.hybridcloud.rpc.caching as caching_module -from sentry.backup.dependencies import NormalizedModelName, dependencies, get_model_name +from sentry.backup.dependencies import ( + NormalizedModelName, + dependencies, + get_model_name, +) from sentry.db.models.base import Model from sentry.deletions.tasks.hybrid_cloud import schedule_hybrid_cloud_foreign_key_jobs from sentry.incidents.models.alert_rule import AlertRule, AlertRuleActivity @@ -31,7 +35,8 @@ from sentry.models.recentsearch import RecentSearch from sentry.models.rule import Rule, RuleActivity from sentry.models.rulesnooze import RuleSnooze -from sentry.models.savedsearch import SavedSearch +from sentry.models.savedsearch import SavedSearch, Visibility +from sentry.models.search_common import SearchType from sentry.models.tombstone import CellTombstone from sentry.monitors.models import Monitor from sentry.silo.base import SiloMode @@ -261,10 +266,8 @@ def test_merge_handles_groupseen_conflicts(self) -> None: from_user = self.create_user("from-user@example.com") to_user = self.create_user("to-user@example.com") org = self.create_organization(name="conflict-org") - - with outbox_runner(): - with assume_test_silo_mode(SiloMode.CELL): - self.create_member(user=from_user, organization=org) + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) with assume_test_silo_mode(SiloMode.CELL): project = self.create_project(organization=org) @@ -307,6 +310,117 @@ def test_merge_handles_groupsubscription_conflicts(self) -> None: assert not GroupSubscription.objects.filter(group=group, user_id=from_user.id).exists() assert GroupSubscription.objects.filter(group=group, user_id=to_user.id).count() == 1 + def test_merge_to_users_in_same_org_recentsearch_no_collision(self) -> None: + # from_user and to_user have different queries — no unique constraint conflict. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_search = RecentSearch.objects.create( + organization=org, + user_id=from_user.id, + type=SearchType.ISSUE.value, + query="from user query", + ) + to_search = RecentSearch.objects.create( + organization=org, + user_id=to_user.id, + type=SearchType.ISSUE.value, + query="to user query", + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert RecentSearch.objects.filter(id=from_search.id, user_id=to_user.id).exists() + assert RecentSearch.objects.filter(id=to_search.id, user_id=to_user.id).exists() + assert not RecentSearch.objects.filter(user_id=from_user.id).exists() + + def test_merge_recentsearch_collision_deletes_from_user_row(self) -> None: + # from_user and to_user have the same (org, type, query) — the from_user row must be + # deleted before the update to avoid violating the unique_together constraint. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_search = RecentSearch.objects.create( + organization=org, + user_id=from_user.id, + type=SearchType.ISSUE.value, + query="duplicate query", + ) + to_search = RecentSearch.objects.create( + organization=org, + user_id=to_user.id, + type=SearchType.ISSUE.value, + query="duplicate query", + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert not RecentSearch.objects.filter(id=from_search.id).exists() + assert RecentSearch.objects.filter(id=to_search.id, user_id=to_user.id).exists() + assert not RecentSearch.objects.filter(user_id=from_user.id).exists() + + def test_merge_savedsearch_unique_condition_preserved(self) -> None: + # SharedSearch has a conditional unique constraint. + # from_user and to_user both have saved searches that don't meet that condition, + # and both should be preserved, while the searches matching the condition should only + # have one retained. + from_user = self.create_user("from@example.com") + to_user = self.create_user("to@example.com") + org = self.create_organization() + self.create_member(user=from_user, organization=org) + self.create_member(user=to_user, organization=org) + + with assume_test_silo_mode(SiloMode.CELL): + from_user_org = SavedSearch.objects.create( + organization=org, + owner_id=from_user.id, + type=SearchType.ISSUE.value, + query="duplicate query should be retained", + visibility=Visibility.ORGANIZATION, + ) + from_user_pinned = SavedSearch.objects.create( + organization=org, + owner_id=from_user.id, + type=SearchType.ISSUE.value, + query="should be deleted because of visiblilty", + visibility=Visibility.OWNER_PINNED, + ) + to_user_org = SavedSearch.objects.create( + organization=org, + owner_id=to_user.id, + type=SearchType.ISSUE.value, + query="duplicate query should be retained", + visibility=Visibility.ORGANIZATION, + ) + to_user_pinned = SavedSearch.objects.create( + organization=org, + owner_id=to_user.id, + type=SearchType.ISSUE.value, + query="should be retained", + visibility=Visibility.OWNER_PINNED, + ) + + with outbox_runner(): + from_user.merge_to(to_user) + + with assume_test_silo_mode(SiloMode.CELL): + assert SavedSearch.objects.filter(id=from_user_org.id, owner_id=to_user.id).exists() + assert SavedSearch.objects.filter(id=to_user_org.id, owner_id=to_user.id).exists() + assert SavedSearch.objects.filter(id=to_user_pinned.id, owner_id=to_user.id).exists() + assert not SavedSearch.objects.filter(id=from_user_pinned.id).exists() + @expect_models( ORG_MEMBER_MERGE_TESTED, OrgAuthToken, diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index 2c0631c0a16a..07818e0ee035 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -742,6 +742,16 @@ def test_evaluation_stats_add(self) -> None: b = EvaluationStats(tainted=3, untainted=4) assert a + b == EvaluationStats(tainted=4, untainted=6) + # Temporary test to exercise all evaluate_workflows_action_filters paths + # with caching enabled + def test_action_filter_stats_excludes_delayed_workflows__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_action_filter_stats_excludes_delayed_workflows() + + def test_action_filter_stats_from_trigger_result__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_action_filter_stats_from_trigger_result() + @freeze_time(FROZEN_TIME) class TestWorkflowEnqueuing(BaseWorkflowTest): @@ -1127,6 +1137,32 @@ def test_enqueues_when_slow_conditions(self) -> None: ) assert list(project_ids.keys()) == [self.project.id] + # Temporary tests to exercise all evaluate_workflows_action_filters paths + # with caching enabled + def test_activity__with_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_activity__with_slow_conditions() + + def test_enqueues_when_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_enqueues_when_slow_conditions() + + def test_with_slow_conditions__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_with_slow_conditions() + + def test_basic__with_filter__filtered__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__with_filter__filtered() + + def test_basic__with_filter__passes__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__with_filter__passes() + + def test_basic__no_filter__with_cache(self) -> None: + with self.feature("organizations:workflow-engine-action-filters-cache"): + self.test_basic__no_filter() + class TestEnqueueWorkflows(BaseWorkflowTest): def setUp(self) -> None: diff --git a/uv.lock b/uv.lock index cb70c3108333..d4c5e7b677ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1322,7 +1322,7 @@ wheels = [ [[package]] name = "objectstore-client" -version = "0.1.1" +version = "0.1.5" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -1332,7 +1332,7 @@ dependencies = [ { name = "zstandard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/objectstore_client-0.1.1-py3-none-any.whl", hash = "sha256:fb63e88b6db101440b45f951810a35df41ade599f90791a1068e4da4e8e10691" }, + { url = "https://pypi.devinfra.sentry.io/wheels/objectstore_client-0.1.5-py3-none-any.whl", hash = "sha256:19ffcef5e33070d418268067e424fcb8de0739bfd2d310bc2de95a6791845857" }, ] [[package]] @@ -2337,7 +2337,7 @@ requires-dist = [ { name = "mmh3", specifier = ">=4.0.0" }, { name = "msgpack", specifier = ">=1.1.0" }, { name = "msgspec", specifier = ">=0.19.0" }, - { name = "objectstore-client", specifier = ">=0.1.1" }, + { name = "objectstore-client", specifier = ">=0.1.5" }, { name = "openai", specifier = ">=1.3.5" }, { name = "orjson", specifier = ">=3.10.10" }, { name = "p4python", specifier = ">=2025.1.2767466" },