diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 7d976a98..cdd3bdfb 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -398,4 +398,35 @@ export const integrationsDiscoveryRouter = router({ }); } }), + + /** + * Verify a Sentry API token and organization slug. + * Used by the Integrations tab Alerting credential inputs. + * Accepts plaintext credentials from the form and calls the Sentry API to verify. + * The token is never stored by this endpoint. + */ + verifySentry: protectedProcedure + .input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifySentry called', { orgId: ctx.effectiveOrgId }); + return wrapIntegrationCall('Failed to verify Sentry credentials', async () => { + const url = `https://sentry.io/api/0/organizations/${encodeURIComponent(input.organizationSlug)}/`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${input.apiToken}` }, + }); + if (!response.ok) { + throw new Error(`Sentry API returned ${response.status}: ${response.statusText}`); + } + const data = (await response.json()) as { + id?: string; + name?: string; + slug?: string; + }; + return { + id: data.id ?? '', + name: data.name ?? '', + slug: data.slug ?? '', + }; + }); + }), }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 77f62db0..6a2f28f3 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -176,7 +176,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), provider: z.string().min(1), config: z.record(z.unknown()), triggers: z.record(z.boolean()).optional(), @@ -197,7 +197,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])), }), ) @@ -207,7 +207,7 @@ export const projectsRouter = router({ }), delete: protectedProcedure - .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'alerting']) })) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); await deleteProjectIntegration(input.projectId, input.category); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 6c8b1d02..6eab94b8 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -19,6 +19,7 @@ const { mockVerifyProjectOrgAccess, mockGetIntegrationCredentialOrNull, mockGetIntegrationByProjectAndCategory, + mockFetch, } = vi.hoisted(() => ({ mockTrelloGetMe: vi.fn(), mockTrelloGetBoards: vi.fn(), @@ -36,6 +37,7 @@ const { mockVerifyProjectOrgAccess: vi.fn(), mockGetIntegrationCredentialOrNull: vi.fn(), mockGetIntegrationByProjectAndCategory: vi.fn(), + mockFetch: vi.fn(), })); vi.mock('../../../../src/trello/client.js', () => ({ @@ -106,10 +108,14 @@ const jiraCredsInput = { baseUrl: 'https://myorg.atlassian.net', }; +// Assign global fetch mock +vi.stubGlobal('fetch', mockFetch); + describe('integrationsDiscoveryRouter', () => { beforeEach(() => { // Default: org access check passes mockVerifyProjectOrgAccess.mockResolvedValue(undefined); + mockFetch.mockReset(); }); // ── Auth ───────────────────────────────────────────────────────────── @@ -953,4 +959,103 @@ describe('integrationsDiscoveryRouter', () => { await expect(caller.verifyGithubToken({ token: '' })).rejects.toThrow(); }); }); + + // ── verifySentry ───────────────────────────────────────────────────── + + describe('verifySentry', () => { + it('returns org id, name, and slug on success', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 'org-123', name: 'My Org', slug: 'my-org' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: 'org-123', name: 'My Org', slug: 'my-org' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/my-org/', + expect.objectContaining({ + headers: { Authorization: 'Bearer sntrys_abc' }, + }), + ); + }); + + it('returns empty strings when Sentry response fields are missing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: '', name: '', slug: '' }); + }); + + it('wraps non-ok response in BAD_REQUEST', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'bad-token', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('wraps network failure in BAD_REQUEST', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('URL-encodes the organization slug', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: '1', name: 'Org', slug: 'org-with-slash' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifySentry({ apiToken: 'tok', organizationSlug: 'org/with/slash' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/org%2Fwith%2Fslash/', + expect.any(Object), + ); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.verifySentry({ apiToken: 'tok', organizationSlug: 'my-org' }), + 'UNAUTHORIZED', + ); + }); + + it('rejects empty apiToken', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: '', organizationSlug: 'my-org' }), + ).rejects.toThrow(); + }); + + it('rejects empty organizationSlug', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: '' }), + ).rejects.toThrow(); + }); + }); }); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index aca393dd..fde2b880 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -387,6 +387,40 @@ describe('projectsRouter', () => { undefined, ); }); + + it('upserts alerting integration with category alerting', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpsertProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.upsert({ + projectId: 'p1', + category: 'alerting', + provider: 'sentry', + config: { organizationSlug: 'my-org' }, + }); + + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( + 'p1', + 'alerting', + 'sentry', + { organizationSlug: 'my-org' }, + undefined, + ); + }); + + it('rejects unknown category', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.integrations.upsert({ + projectId: 'p1', + // @ts-expect-error testing invalid category + category: 'unknown', + provider: 'sentry', + config: {}, + }), + ).rejects.toThrow(); + }); }); describe('delete', () => { @@ -399,6 +433,16 @@ describe('projectsRouter', () => { expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm'); }); + + it('deletes alerting integration', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.delete({ projectId: 'p1', category: 'alerting' }); + + expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'alerting'); + }); }); }); diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 44d77551..8e520258 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -18,7 +18,7 @@ import { useEffect, useState } from 'react'; import { PMWizard } from './pm-wizard.js'; import { ProjectSecretField } from './project-secret-field.js'; -type IntegrationCategory = 'pm' | 'scm'; +type IntegrationCategory = 'pm' | 'scm' | 'alerting'; // ============================================================================ // GitHub Credential Slots (replaces the old CredentialSelector dropdowns) @@ -433,6 +433,192 @@ function SCMTab({ ); } +// ============================================================================ +// Alerting Tab (Sentry) +// ============================================================================ + +interface AlertingTabProps { + projectId: string; + alertingIntegration?: Record; +} + +function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { + const queryClient = useQueryClient(); + + const existingConfig = (alertingIntegration?.config as Record) ?? {}; + const [organizationSlug, setOrganizationSlug] = useState( + (existingConfig.organizationSlug as string) ?? '', + ); + + const [verifyResult, setVerifyResult] = useState<{ + id: string; + name: string; + slug: string; + } | null>(null); + const [verifyError, setVerifyError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const sentryWebhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/sentry/webhook/${projectId}` + : `/sentry/webhook/${projectId}`; + + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + const credentials = credentialsQuery.data ?? []; + const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); + const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); + + const handleVerify = async (rawToken: string) => { + if (!rawToken) { + setVerifyError('Enter the API token value to verify it'); + return; + } + if (!organizationSlug) { + setVerifyError('Enter the organization slug to verify it'); + return; + } + setIsVerifying(true); + setVerifyError(null); + setVerifyResult(null); + try { + const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ + apiToken: rawToken, + organizationSlug, + }); + setVerifyResult(result); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : String(err)); + } finally { + setIsVerifying(false); + } + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'alerting', + provider: 'sentry', + config: { organizationSlug }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.delete.mutate({ + projectId, + category: 'alerting', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+ {/* Organization Slug */} +
+ +

+ Your Sentry organization slug (found in your Sentry URL:{' '} + sentry.io/organizations/<slug>/). +

+ setOrganizationSlug(e.target.value)} + placeholder="my-organization" + /> +
+ +
+ + {/* Credentials */} +
+ + + +
+ +
+ + {/* Sentry Webhook URL */} +
+ +

+ Configure this URL in your Sentry project's webhook settings to receive alerts. +

+
+ {sentryWebhookUrl} + +
+
+ +
+ + {/* Save / Delete */} +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} + {alertingIntegration && ( + + )} + {deleteMutation.isError && ( + {deleteMutation.error.message} + )} +
+
+ ); +} + // ============================================================================ // Helpers // ============================================================================ @@ -489,6 +675,7 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const integrations = integrationsQuery.data ?? []; const pmIntegration = findIntegrationByCategory(integrations, 'pm'); const pmProvider = (pmIntegration?.provider as string) ?? 'trello'; + const alertingIntegration = findIntegrationByCategory(integrations, 'alerting'); return (
@@ -505,6 +692,12 @@ export function IntegrationForm({ projectId }: { projectId: string }) { activeTab={activeTab} onClick={() => setActiveTab('scm')} /> + setActiveTab('alerting')} + />
{activeTab === 'pm' && ( @@ -516,6 +709,10 @@ export function IntegrationForm({ projectId }: { projectId: string }) { )} {activeTab === 'scm' && } + + {activeTab === 'alerting' && ( + + )} ); }