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" },