diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 763ed3b05da08e..283c1d98a01a0d 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -760,25 +760,6 @@ function buildRoutes(): RouteObject[] { path: 'issue-tracking/', redirectTo: '/settings/:orgId/:projectId/plugins/', }, - { - path: 'hooks/', - name: t('Service Hooks'), - component: make(() => import('sentry/views/settings/project/projectServiceHooks')), - }, - { - path: 'hooks/new/', - name: t('Create Service Hook'), - component: make( - () => import('sentry/views/settings/project/projectCreateServiceHook') - ), - }, - { - path: 'hooks/:hookId/', - name: t('Service Hook Details'), - component: make( - () => import('sentry/views/settings/project/projectServiceHookDetails') - ), - }, ]; const projectSettingsRoutes: SentryRouteObject = { path: 'projects/:projectId/', @@ -2885,10 +2866,6 @@ function buildRoutes(): RouteObject[] { path: 'filters/', redirectTo: '/settings/:orgId/projects/:projectId/filters/', }, - { - path: 'hooks/', - redirectTo: '/settings/:orgId/projects/:projectId/hooks/', - }, { path: 'keys/', redirectTo: '/settings/:orgId/projects/:projectId/keys/', diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 375a41f1b67014..1d4ef5f7d62f88 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -553,20 +553,8 @@ export type AppOrProviderOrPlugin = | PluginWithProjectList | DocIntegration; -/** - * Webhooks and servicehooks - */ export type WebhookEvent = 'issue' | 'error' | 'comment' | 'seer' | 'preprod_artifact'; -export type ServiceHook = { - dateCreated: string; - events: string[]; - id: string; - secret: string; - status: string; - url: string; -}; - /** * Codeowners and repository path mappings. */ diff --git a/static/app/views/settings/project/projectCreateServiceHook.tsx b/static/app/views/settings/project/projectCreateServiceHook.tsx deleted file mode 100644 index 03c2b072a8b71d..00000000000000 --- a/static/app/views/settings/project/projectCreateServiceHook.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {Fragment} from 'react'; - -import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {t} from 'sentry/locale'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; -import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout'; -import {ServiceHookSettingsForm} from 'sentry/views/settings/project/serviceHookSettingsForm'; - -export default function ProjectCreateServiceHook() { - const organization = useOrganization(); - const {project} = useProjectSettingsOutlet(); - const title = t('Create Service Hook'); - - return ( - - - - - - - ); -} diff --git a/static/app/views/settings/project/projectServiceHookDetails.tsx b/static/app/views/settings/project/projectServiceHookDetails.tsx deleted file mode 100644 index e50af26d21baa1..00000000000000 --- a/static/app/views/settings/project/projectServiceHookDetails.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import {Fragment, useState} from 'react'; -import {useMutation} from '@tanstack/react-query'; - -import {Button} from '@sentry/scraps/button'; - -import { - addErrorMessage, - addLoadingMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {MiniBarChart} from 'sentry/components/charts/miniBarChart'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelAlert} from 'sentry/components/panels/panelAlert'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {TextCopyInput} from 'sentry/components/textCopyInput'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useApi} from 'sentry/utils/useApi'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; -import {ServiceHookSettingsForm} from 'sentry/views/settings/project/serviceHookSettingsForm'; - -function HookStats() { - const organization = useOrganization(); - const {hookId, projectId} = useParams<{hookId: string; projectId: string}>(); - - const [until] = useState(() => Math.floor(Date.now() / 1000)); - const since = until - 3600 * 24 * 30; - - const { - data: stats, - isPending, - isError, - refetch, - } = useApiQuery>( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/$hookId/stats/', { - path: { - organizationIdOrSlug: organization.slug, - projectIdOrSlug: projectId, - hookId, - }, - }), - { - query: { - since, - until, - resolution: '1d', - }, - }, - ], - {staleTime: 0} - ); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - if (stats === null) { - return null; - } - let emptyStats = true; - - const series = { - seriesName: t('Events'), - data: stats.map(p => { - if (p.total) { - emptyStats = false; - } - return { - name: p.ts * 1000, - value: p.total, - }; - }), - }; - - return ( - - {t('Events in the last 30 days (by day)')} - - {emptyStats ? ( - - {t('Total webhooks fired for this configuration.')} - - ) : ( - - )} - - - ); -} - -export default function ProjectServiceHookDetails() { - const organization = useOrganization(); - const {hookId, projectId} = useParams<{hookId: string; projectId: string}>(); - const api = useApi({persistInFlight: true}); - const navigate = useNavigate(); - - const { - data: hook, - isPending, - isError, - refetch, - } = useApiQuery( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/$hookId/', { - path: { - organizationIdOrSlug: organization.slug, - projectIdOrSlug: projectId, - hookId, - }, - }), - ], - {staleTime: 0} - ); - - const deleteMutation = useMutation({ - mutationFn: () => { - return api.requestPromise( - `/projects/${organization.slug}/${projectId}/hooks/${hookId}/`, - { - method: 'DELETE', - } - ); - }, - onMutate: () => { - addLoadingMessage(t('Saving changes\u2026')); - }, - onSuccess: () => { - clearIndicators(); - navigate( - normalizeUrl(`/settings/${organization.slug}/projects/${projectId}/hooks/`) - ); - }, - onError: () => { - addErrorMessage(t('Unable to remove application. Please try again.')); - }, - }); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - if (!hook) { - return null; - } - - return ( - - - - - - - - - - {t('Event Validation')} - - - Sentry will send the X-ServiceHook-Signature header built using{' '} - HMAC(SHA256, [secret], [payload]). You should always verify this - signature before trusting the information provided in the webhook. - - - {hook.secret} - - - - - {t('Delete Hook')} - - -
- -
-
-
-
-
- ); -} diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx deleted file mode 100644 index c632999ba8b843..00000000000000 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import {Fragment} from 'react'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Link} from '@sentry/scraps/link'; -import {Switch} from '@sentry/scraps/switch'; - -import { - addErrorMessage, - addLoadingMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelAlert} from 'sentry/components/panels/panelAlert'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {Truncate} from 'sentry/components/truncate'; -import {IconAdd} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; - -type RowProps = { - hook: ServiceHook; - onToggleActive: () => void; - orgId: string; - projectId: string; -}; - -function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { - return ( - - - - } - help={ - - {hook.events && hook.events.length !== 0 ? ( - hook.events.join(', ') - ) : ( - {t('no events configured')} - )} - - } - > - - - ); -} - -export default function ProjectServiceHooks() { - const organization = useOrganization(); - const {projectId} = useParams<{projectId: string}>(); - const api = useApi({persistInFlight: true}); - const queryClient = useQueryClient(); - - const { - data: hookList, - isPending, - isError, - refetch, - } = useApiQuery( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - { - staleTime: 0, - } - ); - - const onToggleActiveMutation = useMutation({ - mutationFn: ({hook}: {hook: ServiceHook}) => { - return api.requestPromise( - `/projects/${organization.slug}/${projectId}/hooks/${hook.id}/`, - { - method: 'PUT', - data: { - isActive: hook.status !== 'active', - }, - } - ); - }, - onMutate: () => { - addLoadingMessage(t('Saving changes\u2026')); - }, - onSuccess: data => { - clearIndicators(); - setApiQueryData( - queryClient, - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - oldHookList => { - return oldHookList?.map(h => { - if (h.id === data.id) { - return { - ...h, - ...data, - }; - } - return h; - }); - } - ); - }, - onError: () => { - addErrorMessage(t('Unable to remove application. Please try again.')); - }, - }); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - const renderEmpty = () => { - return ( - - {t('There are no service hooks associated with this project.')} - - ); - }; - - const renderResults = () => { - return ( - - {t('Service Hook')} - - - {t( - 'Service Hooks are an early adopter preview feature and will change in the future.' - )} - - {hookList?.map(hook => ( - onToggleActiveMutation.mutate({hook})} - /> - ))} - - - ); - }; - - const body = hookList && hookList.length > 0 ? renderResults() : renderEmpty(); - - return ( - - } - > - {t('Create New Hook')} - - ) : null - } - /> - {body} - - ); -} diff --git a/static/app/views/settings/project/serviceHookSettingsForm.tsx b/static/app/views/settings/project/serviceHookSettingsForm.tsx deleted file mode 100644 index c6ffb10c49586c..00000000000000 --- a/static/app/views/settings/project/serviceHookSettingsForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {ApiForm} from 'sentry/components/forms/apiForm'; -import {MultipleCheckbox} from 'sentry/components/forms/controls/multipleCheckbox'; -import {BooleanField} from 'sentry/components/forms/fields/booleanField'; -import {TextField} from 'sentry/components/forms/fields/textField'; -import {FormField} from 'sentry/components/forms/formField'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; - -const EVENT_CHOICES = ['event.alert', 'event.created']; - -type Props = { - initialData: Partial & {isActive: boolean}; - organization: Organization; - projectId: string; - hookId?: string; -}; - -export function ServiceHookSettingsForm({ - initialData, - organization, - projectId, - hookId, -}: Props) { - const navigate = useNavigate(); - - const onSubmitSuccess = () => { - navigate(normalizeUrl(`/settings/${organization.slug}/projects/${projectId}/hooks/`)); - }; - - const endpoint = hookId - ? `/projects/${organization.slug}/${projectId}/hooks/${hookId}/` - : `/projects/${organization.slug}/${projectId}/hooks/`; - - return ( - - - {t('Hook Configuration')} - - - - - {({name, value, onChange}: any) => ( - - {EVENT_CHOICES.map(event => ( - - {event} - - ))} - - )} - - - - - ); -} diff --git a/tests/acceptance/test_project_servicehooks.py b/tests/acceptance/test_project_servicehooks.py deleted file mode 100644 index 24dc8b309db960..00000000000000 --- a/tests/acceptance/test_project_servicehooks.py +++ /dev/null @@ -1,48 +0,0 @@ -from sentry.sentry_apps.models.servicehook import ServiceHook -from sentry.testutils.cases import AcceptanceTestCase -from sentry.testutils.silo import no_silo_test - - -@no_silo_test -class ProjectServiceHooksTest(AcceptanceTestCase): - def setUp(self) -> None: - super().setUp() - self.user = self.create_user("foo@example.com") - self.org = self.create_organization(name="Rowdy Tiger", owner=None) - self.team = self.create_team(organization=self.org, name="Mariachi Band") - self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal") - self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) - - self.login_as(self.user) - self.list_hooks_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/" - self.new_hook_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/new/" - - def test_simple(self) -> None: - with self.feature("projects:servicehooks"): - self.browser.get(self.list_hooks_path) - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - # click "New" - self.browser.click('[data-test-id="new-service-hook"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == f"{self.browser.live_server_url}{self.new_hook_path}" - self.browser.element('input[name="url"]').send_keys("https://example.com/hook") - # click "Save Changes" - self.browser.click('form [data-test-id="form-submit"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert ( - self.browser.current_url == f"{self.browser.live_server_url}{self.list_hooks_path}" - ) - - hook = ServiceHook.objects.get(project_id=self.project.id) - assert hook.url == "https://example.com/hook" - assert not hook.events - - # hopefully click the first service hook - self.browser.click('[data-test-id="project-service-hook"]') - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == "{}{}".format( - self.browser.live_server_url, - f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/{hook.guid}/", - )