From 7b601879ab26be545056b45ca9edae540345257f Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Mar 2026 16:46:06 +0000 Subject: [PATCH] feat(alerting): add Sentry alerting integration with provider-agnostic interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Sentry as the first alerting provider under a new 'alerting' integration category, using provider-agnostic naming throughout so that adding a second provider (PagerDuty, Datadog, etc.) requires no prompt or interface changes. ## Provider-agnostic naming (the PM analogy) Like how Trello "card" + JIRA "issue" are abstracted to "work item" in the PM category, Sentry-specific language is hidden behind the alerting namespace at every agent-facing layer: - AgentInput fields: `alertIssueId`, `alertOrgId`, `alertIssueUrl` - Gadgets: `GetAlertingIssue`, `GetAlertingEventDetail`, `ListAlertingEvents` - CLI tools: `get-alerting-issue`, `get-alerting-event`, `list-alerting-events` - Context step: `alertingIssue` (was `sentryIssue`) - Agent YAML prompts: no Sentry-specific language Sentry internals (client, types, HMAC verification) remain Sentry-named as the abstraction boundary stops at the agent-facing interface. ## New capabilities - `alerting:read` capability with all three gadgets (GetAlertingIssue, GetAlertingEventDetail, ListAlertingEvents) — ListAlertingEvents was previously missing despite having a CLI tool and core function - Sentry webhook handler via `createWebhookHandler` factory, gaining webhook logging to `webhook_logs` and opt-in HMAC-SHA256 signature verification (`Sentry-Hook-Signature` header, raw hex format) - `verifySentrySignature` in signatureVerification.ts (timing-safe) - `verifySentryWebhookSignature` in webhookVerification.ts reads `webhook_secret` from the alerting integration credential ## Fixes - `formatSentryEvent` cognitive complexity reduced from 34 to ~8 by extracting five section helpers (appendEventMeta, appendEventTags, appendEventRequest, appendEventUser, appendEventStacktrace) - Removed dead `_client` caching variable from sentry/client.ts - Removed Sentry branding from formatted output strings ## Tests - 41 new/updated tests: verifySentrySignature (9), SentryIssueAlertTrigger (15), SentryMetricAlertTrigger (12), builtins registration (1 new + mock) Co-Authored-By: Claude Sonnet 4.6 --- src/agents/capabilities/registry.ts | 18 +- src/agents/capabilities/resolver.ts | 25 +- src/agents/definitions/alerting.yaml | 71 ++++ src/agents/definitions/contextSteps.ts | 57 +++ src/agents/definitions/schema.ts | 11 +- src/agents/definitions/strategies.ts | 2 + src/cli/alerting/get-alerting-event.ts | 11 + src/cli/alerting/get-alerting-issue.ts | 7 + src/cli/alerting/list-alerting-events.ts | 11 + src/config/integrationRoles.ts | 14 +- src/config/provider.ts | 13 +- src/gadgets/sentry/GetAlertingEventDetail.ts | 14 + src/gadgets/sentry/GetAlertingIssue.ts | 7 + src/gadgets/sentry/ListAlertingEvents.ts | 11 + src/gadgets/sentry/core/format.ts | 234 ++++++++++++ .../sentry/core/getSentryEventDetail.ts | 12 + src/gadgets/sentry/core/getSentryIssue.ts | 8 + src/gadgets/sentry/core/listSentryEvents.ts | 12 + src/gadgets/sentry/definitions.ts | 100 +++++ src/gadgets/sentry/index.ts | 3 + src/router/adapters/sentry.ts | 130 +++++++ src/router/index.ts | 24 ++ src/router/platformClients/credentials.ts | 5 +- src/router/queue.ts | 13 +- src/router/webhookVerification.ts | 33 ++ src/sentry/client.ts | 111 ++++++ src/sentry/integration.ts | 48 +++ src/sentry/types.ts | 211 ++++++++++ src/triggers/builtins.ts | 2 + src/triggers/sentry/alerting-issue.ts | 85 +++++ src/triggers/sentry/alerting-metric.ts | 88 +++++ src/triggers/sentry/register.ts | 8 + src/types/index.ts | 6 + src/webhook/signatureVerification.ts | 27 ++ src/webhook/webhookHandlers.ts | 7 +- src/webhook/webhookParsers.ts | 37 ++ .../agents/definitions/async-resolver.test.ts | 1 + tests/unit/agents/definitions/loader.test.ts | 7 +- .../agents/definitions/strategies.test.ts | 2 + tests/unit/config/integrationRoles.test.ts | 4 +- tests/unit/triggers/builtins.test.ts | 21 +- tests/unit/triggers/sentry-alerting.test.ts | 360 ++++++++++++++++++ .../webhook/signatureVerification.test.ts | 56 +++ 43 files changed, 1894 insertions(+), 33 deletions(-) create mode 100644 src/agents/definitions/alerting.yaml create mode 100644 src/cli/alerting/get-alerting-event.ts create mode 100644 src/cli/alerting/get-alerting-issue.ts create mode 100644 src/cli/alerting/list-alerting-events.ts create mode 100644 src/gadgets/sentry/GetAlertingEventDetail.ts create mode 100644 src/gadgets/sentry/GetAlertingIssue.ts create mode 100644 src/gadgets/sentry/ListAlertingEvents.ts create mode 100644 src/gadgets/sentry/core/format.ts create mode 100644 src/gadgets/sentry/core/getSentryEventDetail.ts create mode 100644 src/gadgets/sentry/core/getSentryIssue.ts create mode 100644 src/gadgets/sentry/core/listSentryEvents.ts create mode 100644 src/gadgets/sentry/definitions.ts create mode 100644 src/gadgets/sentry/index.ts create mode 100644 src/router/adapters/sentry.ts create mode 100644 src/sentry/client.ts create mode 100644 src/sentry/integration.ts create mode 100644 src/sentry/types.ts create mode 100644 src/triggers/sentry/alerting-issue.ts create mode 100644 src/triggers/sentry/alerting-metric.ts create mode 100644 src/triggers/sentry/register.ts create mode 100644 tests/unit/triggers/sentry-alerting.test.ts diff --git a/src/agents/capabilities/registry.ts b/src/agents/capabilities/registry.ts index ab2d88e7..36d196d9 100644 --- a/src/agents/capabilities/registry.ts +++ b/src/agents/capabilities/registry.ts @@ -20,7 +20,7 @@ import type { IntegrationCategory } from '../definitions/schema.js'; * * Format: {source}:{action} * - Built-in sources: fs (filesystem), shell, session - * - Integration sources: pm, scm, email + * - Integration sources: pm, scm, alerting */ export const CAPABILITIES = [ // Built-in capabilities (always available, no integration required) @@ -40,6 +40,9 @@ export const CAPABILITIES = [ 'scm:comment', 'scm:review', 'scm:pr', + + // Alerting integration capabilities + 'alerting:read', ] as const; export type Capability = (typeof CAPABILITIES)[number]; @@ -183,6 +186,18 @@ export const CAPABILITY_REGISTRY: Record = { sdkToolNames: [], cliToolNames: [], }, + + // ------------------------------------------------------------------------- + // Alerting integration capabilities + // ------------------------------------------------------------------------- + + 'alerting:read': { + integration: 'alerting', + description: 'Read issue and event data from alerting tools', + gadgetNames: ['GetAlertingIssue', 'GetAlertingEventDetail', 'ListAlertingEvents'], + sdkToolNames: [], + cliToolNames: [], + }, }; // ============================================================================ @@ -200,6 +215,7 @@ export function getCapabilitiesByIntegration(): Record< builtin: [], pm: [], scm: [], + alerting: [], }; for (const cap of CAPABILITIES) { diff --git a/src/agents/capabilities/resolver.ts b/src/agents/capabilities/resolver.ts index 18ed96e4..077907c7 100644 --- a/src/agents/capabilities/resolver.ts +++ b/src/agents/capabilities/resolver.ts @@ -37,6 +37,11 @@ import { ReadWorkItem, UpdateWorkItem, } from '../../gadgets/pm/index.js'; +import { + GetAlertingEventDetail, + GetAlertingIssue, + ListAlertingEvents, +} from '../../gadgets/sentry/index.js'; import { Tmux } from '../../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../../gadgets/todo/index.js'; import type { ToolManifest } from '../contracts/index.js'; @@ -124,6 +129,11 @@ const GADGET_CONSTRUCTORS: Record any> = { // scm:pr CreatePR, + + // alerting:read + GetAlertingIssue, + GetAlertingEventDetail, + ListAlertingEvents, }; // ============================================================================ @@ -345,6 +355,7 @@ export function generateUnavailableCapabilitiesNote(unavailableCaps: Capability[ const integrationLabels: Record = { pm: 'PM integration (Trello/JIRA)', scm: 'SCM integration (GitHub)', + alerting: 'Alerting integration', }; for (const [integration, gadgetNames] of byIntegration) { @@ -370,21 +381,25 @@ export function generateUnavailableCapabilitiesNote(unavailableCaps: Capability[ */ export async function createIntegrationChecker(projectId: string): Promise { // Import integration checking functions dynamically to avoid circular deps - const [{ hasPmIntegration }, { hasScmIntegration }] = await Promise.all([ - import('../../pm/integration.js'), - import('../../github/integration.js'), - ]); + const [{ hasPmIntegration }, { hasScmIntegration }, { hasAlertingIntegration }] = + await Promise.all([ + import('../../pm/integration.js'), + import('../../github/integration.js'), + import('../../sentry/integration.js'), + ]); // Pre-fetch all integration statuses in parallel - const [hasPm, hasScm] = await Promise.all([ + const [hasPm, hasScm, hasAlerting] = await Promise.all([ hasPmIntegration(projectId), hasScmIntegration(projectId), + hasAlertingIntegration(projectId), ]); // Return synchronous checker const availableIntegrations: Record = { pm: hasPm, scm: hasScm, + alerting: hasAlerting, }; return (category: IntegrationCategory) => availableIntegrations[category] ?? false; diff --git a/src/agents/definitions/alerting.yaml b/src/agents/definitions/alerting.yaml new file mode 100644 index 00000000..7cc638aa --- /dev/null +++ b/src/agents/definitions/alerting.yaml @@ -0,0 +1,71 @@ +identity: + emoji: "\U0001F6A8" + label: Alert Investigator + roleHint: Investigates alerts from configured alerting providers, identifies root cause, and creates bug fix work items + initialMessage: "**\U0001F6A8 Investigating alert** — Analyzing stacktrace and root cause..." + +# Alerting integration required; PM is optional for creating backlog work items. +integrations: + required: [alerting] + optional: [pm] + +# Read source code and shell for log analysis. Alerting tools for event data. +# Optional PM write to create backlog bug work item if PM integration is configured. +capabilities: + required: + - fs:read + - shell:exec + - session:ctrl + - alerting:read + optional: + - pm:read + - pm:write + +# Supported triggers for this agent +triggers: + - event: alerting:issue-alert + label: New Issue Alert + description: Trigger when an issue alert fires (exception, error rate, etc.) + defaultEnabled: true + providers: [sentry] + contextPipeline: [alertingIssue, directoryListing, contextFiles] + - event: alerting:metric-alert + label: Metric Alert (Critical/Warning) + description: Trigger when a metric alert enters critical or warning state + defaultEnabled: false + providers: [sentry] + parameters: + - name: severity + type: select + label: Minimum Severity + description: Only trigger for alerts at or above this severity + options: [critical, warning] + defaultValue: critical + contextPipeline: [directoryListing, contextFiles] + +strategies: {} + +prompts: + taskPrompt: | + An alert has been triggered: "<%= it.alertTitle %>" + + <% if (it.alertIssueUrl) { %>Issue: <%= it.alertIssueUrl %><% } %> + + The full event details (stacktrace, breadcrumbs, tags) have been pre-loaded above. + + Your task: + 1. Review the pre-loaded event data — identify the error type, failing function, and line number + 2. Read the relevant source files to understand context and confirm the root cause + 3. Summarize: what failed, why, and which code path is responsible + <% if (it.backlogListId) { %> + 4. Create a bug fix work item in the backlog (list/status ID: <%= it.backlogListId %>) with: + - Title: short, actionable description (e.g. "Fix: NullPointerException in PaymentService.charge") + - Description: root cause, error details, affected file/function, link to the alert issue + <% } %> + 5. Call Finish when done + +hint: | + Start with the stacktrace in the pre-loaded event and work backwards through the call chain. + Focus on frames from application code (not third-party library frames). + Check the event timeline/breadcrumbs for the action or request that preceded the failure. + Keep the bug work item description concise and actionable. diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index 78e9623a..ba74debe 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'; import { ListDirectory } from '../../gadgets/ListDirectory.js'; import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js'; import { readWorkItem, readWorkItemWithMedia } from '../../gadgets/pm/core/readWorkItem.js'; +import { formatSentryEvent } from '../../gadgets/sentry/core/format.js'; import { formatTodoList, getNextId, @@ -20,6 +21,7 @@ import type { Todo } from '../../gadgets/todo/storage.js'; import { githubClient } from '../../github/client.js'; import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; import { MAX_IMAGES_PER_WORK_ITEM, getPMProviderOrNull } from '../../pm/index.js'; +import { getSentryClient } from '../../sentry/client.js'; import type { AgentInput, ProjectConfig } from '../../types/index.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { resolveSquintDbPath } from '../../utils/squintDb.js'; @@ -554,3 +556,58 @@ export async function fetchPipelineSnapshotStep( }, ]; } + +// ============================================================================ +// Sentry Issue Step +// ============================================================================ + +/** + * Pre-fetch the latest alerting event (with full stacktrace and breadcrumbs) + * so the agent starts with the error context already loaded. + * + * Reads alertIssueId and alertOrgId from AgentInput. + * Silently skips if credentials or config are missing. + */ +export async function fetchAlertingIssueStep( + params: FetchContextParams, +): Promise { + const { alertIssueId, alertOrgId } = params.input; + if (!alertIssueId || typeof alertIssueId !== 'string') return []; + if (!alertOrgId || typeof alertOrgId !== 'string') return []; + + try { + params.logWriter('INFO', 'fetchAlertingIssueStep: fetching latest alerting event', { + issueId: alertIssueId, + orgId: alertOrgId, + }); + + const client = getSentryClient(); + const event = await client.getIssueEvent(alertOrgId, alertIssueId, 'latest'); + const result = formatSentryEvent(event); + + params.logWriter('INFO', 'fetchAlertingIssueStep: fetched alerting event successfully', { + issueId: alertIssueId, + eventId: event.event_id, + }); + + return [ + { + toolName: 'GetAlertingEventDetail', + params: { + organizationId: alertOrgId, + issueId: alertIssueId, + eventId: 'latest', + }, + result, + description: 'Pre-fetched alerting event with stacktrace and breadcrumbs', + }, + ]; + } catch (error) { + params.logWriter('WARN', 'fetchAlertingIssueStep: failed to fetch alerting event', { + issueId: alertIssueId, + orgId: alertOrgId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 16492314..8fb41579 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -6,18 +6,18 @@ import { CAPABILITIES } from '../capabilities/registry.js'; // ============================================================================ // Integration categories (aligned with integrationRoles.ts) -export const IntegrationCategorySchema = z.enum(['pm', 'scm']); +export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'alerting']); // Known providers for validation -export const KnownProviderSchema = z.enum(['trello', 'jira', 'github']); +export const KnownProviderSchema = z.enum(['trello', 'jira', 'github', 'sentry']); // Trigger event format validation: {category}:{event-name} -// Categories: pm, scm (integration-bound), internal (orchestration chaining) +// Categories: pm, scm (integration-bound), alerting (monitoring), internal (orchestration chaining) const TriggerEventSchema = z .string() .regex( - /^(pm|scm|internal):[a-z][a-z0-9-]*$/, - 'Event must be in format {category}:{event-name} (e.g., pm:status-changed, scm:check-suite-success)', + /^(pm|scm|alerting|internal):[a-z][a-z0-9-]*$/, + 'Event must be in format {category}:{event-name} (e.g., pm:status-changed, scm:check-suite-success, alerting:issue-alert)', ); // ============================================================================ @@ -81,6 +81,7 @@ export const CONTEXT_STEP_NAMES = [ 'prContext', 'prConversation', 'pipelineSnapshot', + 'alertingIssue', ] as const; /** Context step name schema for use in triggers */ diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts index 67930205..14276d89 100644 --- a/src/agents/definitions/strategies.ts +++ b/src/agents/definitions/strategies.ts @@ -11,6 +11,7 @@ import type { ContextInjection } from '../contracts/index.js'; import { type FetchContextParams, + fetchAlertingIssueStep, fetchContextFilesStep, fetchDirectoryListingStep, fetchPRContextStep, @@ -37,4 +38,5 @@ export const CONTEXT_STEP_REGISTRY: Record< prContext: fetchPRContextStep, prConversation: fetchPRConversationStep, pipelineSnapshot: fetchPipelineSnapshotStep, + alertingIssue: fetchAlertingIssueStep, }; diff --git a/src/cli/alerting/get-alerting-event.ts b/src/cli/alerting/get-alerting-event.ts new file mode 100644 index 00000000..e8d0e0ca --- /dev/null +++ b/src/cli/alerting/get-alerting-event.ts @@ -0,0 +1,11 @@ +import { getSentryEventDetail } from '../../gadgets/sentry/core/getSentryEventDetail.js'; +import { getAlertingEventDetailDef } from '../../gadgets/sentry/definitions.js'; +import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; + +export default createCLICommand(getAlertingEventDetailDef, async (params) => { + return getSentryEventDetail( + params.organizationId as string, + params.issueId as string, + params.eventId as string | undefined, + ); +}); diff --git a/src/cli/alerting/get-alerting-issue.ts b/src/cli/alerting/get-alerting-issue.ts new file mode 100644 index 00000000..a043c9d2 --- /dev/null +++ b/src/cli/alerting/get-alerting-issue.ts @@ -0,0 +1,7 @@ +import { getSentryIssue } from '../../gadgets/sentry/core/getSentryIssue.js'; +import { getAlertingIssueDef } from '../../gadgets/sentry/definitions.js'; +import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; + +export default createCLICommand(getAlertingIssueDef, async (params) => { + return getSentryIssue(params.organizationId as string, params.issueId as string); +}); diff --git a/src/cli/alerting/list-alerting-events.ts b/src/cli/alerting/list-alerting-events.ts new file mode 100644 index 00000000..6a2c33d2 --- /dev/null +++ b/src/cli/alerting/list-alerting-events.ts @@ -0,0 +1,11 @@ +import { listSentryEvents } from '../../gadgets/sentry/core/listSentryEvents.js'; +import { listAlertingEventsDef } from '../../gadgets/sentry/definitions.js'; +import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; + +export default createCLICommand(listAlertingEventsDef, async (params) => { + return listSentryEvents( + params.organizationId as string, + params.issueId as string, + params.limit as number | undefined, + ); +}); diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index b8b88508..a69babaa 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -1,10 +1,11 @@ -export type IntegrationCategory = 'pm' | 'scm'; -export type IntegrationProvider = 'trello' | 'jira' | 'github'; +export type IntegrationCategory = 'pm' | 'scm' | 'alerting'; +export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'sentry'; export const PROVIDER_CATEGORY: Record = { trello: 'pm', jira: 'pm', github: 'scm', + sentry: 'alerting', }; export interface CredentialRoleDef { @@ -45,4 +46,13 @@ export const PROVIDER_CREDENTIAL_ROLES: Record { const cached = configCache.getConfig(); @@ -210,15 +210,8 @@ export function invalidateConfigCache(): void { function roleToEnvVarKey(category: string, role: string): string | undefined { // Look through all providers in the category to find the role for (const [provider, roles] of Object.entries(PROVIDER_CREDENTIAL_ROLES)) { - let providerCategory: string; - if (provider === 'trello' || provider === 'jira') { - providerCategory = 'pm'; - } else if (provider === 'github') { - providerCategory = 'scm'; - } else { - continue; - } - if (providerCategory !== category) continue; + const providerCategory = PROVIDER_CATEGORY[provider as keyof typeof PROVIDER_CATEGORY]; + if (!providerCategory || providerCategory !== category) continue; const roleDef = roles.find((r) => r.role === role); if (roleDef) return roleDef.envVarKey; } diff --git a/src/gadgets/sentry/GetAlertingEventDetail.ts b/src/gadgets/sentry/GetAlertingEventDetail.ts new file mode 100644 index 00000000..76ad06a1 --- /dev/null +++ b/src/gadgets/sentry/GetAlertingEventDetail.ts @@ -0,0 +1,14 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getSentryEventDetail } from './core/getSentryEventDetail.js'; +import { getAlertingEventDetailDef } from './definitions.js'; + +export const GetAlertingEventDetail = createGadgetClass( + getAlertingEventDetailDef, + async (params) => { + return getSentryEventDetail( + params.organizationId as string, + params.issueId as string, + params.eventId as string | undefined, + ); + }, +); diff --git a/src/gadgets/sentry/GetAlertingIssue.ts b/src/gadgets/sentry/GetAlertingIssue.ts new file mode 100644 index 00000000..394a7fba --- /dev/null +++ b/src/gadgets/sentry/GetAlertingIssue.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getSentryIssue } from './core/getSentryIssue.js'; +import { getAlertingIssueDef } from './definitions.js'; + +export const GetAlertingIssue = createGadgetClass(getAlertingIssueDef, async (params) => { + return getSentryIssue(params.organizationId as string, params.issueId as string); +}); diff --git a/src/gadgets/sentry/ListAlertingEvents.ts b/src/gadgets/sentry/ListAlertingEvents.ts new file mode 100644 index 00000000..e167f70e --- /dev/null +++ b/src/gadgets/sentry/ListAlertingEvents.ts @@ -0,0 +1,11 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { listSentryEvents } from './core/listSentryEvents.js'; +import { listAlertingEventsDef } from './definitions.js'; + +export const ListAlertingEvents = createGadgetClass(listAlertingEventsDef, async (params) => { + return listSentryEvents( + params.organizationId as string, + params.issueId as string, + params.limit as number | undefined, + ); +}); diff --git a/src/gadgets/sentry/core/format.ts b/src/gadgets/sentry/core/format.ts new file mode 100644 index 00000000..4a2b8cb4 --- /dev/null +++ b/src/gadgets/sentry/core/format.ts @@ -0,0 +1,234 @@ +/** + * Formatting helpers for Sentry API responses. + * Converts raw API data into human-readable text for agents. + */ + +import type { + SentryBreadcrumb, + SentryEvent, + SentryException, + SentryIssue, + SentryStackFrame, +} from '../../../sentry/types.js'; + +// ============================================================================ +// Issue formatting +// ============================================================================ + +export function formatSentryIssue(issue: SentryIssue): string { + const lines: string[] = []; + + lines.push(`# Issue: ${issue.title}`); + lines.push(''); + + if (issue.shortId) lines.push(`Short ID: ${issue.shortId}`); + lines.push(`Issue ID: ${issue.id}`); + if (issue.culprit) lines.push(`Culprit: ${issue.culprit}`); + if (issue.status) + lines.push(`Status: ${issue.status}${issue.substatus ? ` (${issue.substatus})` : ''}`); + if (issue.priority) lines.push(`Priority: ${issue.priority}`); + if (issue.count) lines.push(`Occurrences: ${issue.count}`); + if (issue.userCount !== undefined) lines.push(`Affected Users: ${issue.userCount}`); + if (issue.firstSeen) lines.push(`First Seen: ${issue.firstSeen}`); + if (issue.lastSeen) lines.push(`Last Seen: ${issue.lastSeen}`); + if (issue.project) lines.push(`Project: ${issue.project.name} (${issue.project.slug})`); + if (issue.assignedTo) { + const a = issue.assignedTo; + lines.push(`Assigned To: ${a.name ?? a.email ?? 'Unknown'}`); + } + if (issue.isUnhandled !== undefined) lines.push(`Unhandled: ${issue.isUnhandled}`); + if (issue.permalink) { + lines.push(''); + lines.push(`URL: ${issue.permalink}`); + } + + return lines.join('\n'); +} + +// ============================================================================ +// Stack frame formatting +// ============================================================================ + +function formatStackFrame(frame: SentryStackFrame, index: number): string { + const lines: string[] = []; + const location = [frame.filename ?? frame.abs_path, frame.lineno].filter(Boolean).join(':'); + const fn = frame.function ?? ''; + const inApp = frame.in_app ? ' [in_app]' : ''; + + lines.push(` Frame ${index}: ${fn}${inApp}`); + if (location) lines.push(` at ${location}`); + + // Source context + if (frame.pre_context?.length) { + for (const line of frame.pre_context) { + lines.push(` | ${line}`); + } + } + if (frame.context_line !== undefined) { + lines.push(` > | ${frame.context_line} ← error here`); + } + if (frame.post_context?.length) { + for (const line of frame.post_context) { + lines.push(` | ${line}`); + } + } + + // Local variables (if present and non-empty) + if (frame.vars && Object.keys(frame.vars).length > 0) { + lines.push(` Variables: ${JSON.stringify(frame.vars, null, 0).slice(0, 200)}`); + } + + return lines.join('\n'); +} + +function formatException(exc: SentryException): string { + const lines: string[] = []; + const header = [exc.type, exc.value].filter(Boolean).join(': '); + if (header) lines.push(`Exception: ${header}`); + + if (exc.mechanism) { + const handledStr = exc.mechanism.handled === false ? ' (unhandled)' : ' (handled)'; + lines.push(`Mechanism: ${exc.mechanism.type ?? 'generic'}${handledStr}`); + } + + const frames = exc.stacktrace?.frames; + if (frames?.length) { + lines.push(''); + lines.push('Stacktrace (innermost first):'); + // Show frames in reverse (innermost = last frame = most relevant) + const reversed = [...frames].reverse(); + for (let i = 0; i < reversed.length; i++) { + lines.push(formatStackFrame(reversed[i], i)); + } + } + + return lines.join('\n'); +} + +// ============================================================================ +// Breadcrumbs formatting +// ============================================================================ + +function formatBreadcrumbs(breadcrumbs: SentryBreadcrumb[]): string { + const lines: string[] = ['Breadcrumbs (most recent last):']; + // Show last 20 breadcrumbs to avoid overwhelming output + const recent = breadcrumbs.slice(-20); + for (const b of recent) { + const ts = b.timestamp ? (b.timestamp.split('T')[1]?.slice(0, 8) ?? b.timestamp) : ''; + const level = b.level ? `[${b.level}]` : ''; + const cat = b.category ? `(${b.category})` : ''; + const msg = b.message ?? (b.data ? JSON.stringify(b.data).slice(0, 100) : ''); + lines.push(` ${ts} ${level} ${cat} ${msg}`.trimEnd()); + } + return lines.join('\n'); +} + +// ============================================================================ +// Full event formatting — broken into section helpers to stay readable +// ============================================================================ + +function appendEventMeta(lines: string[], event: SentryEvent): void { + if (event.event_id) lines.push(`Event ID: ${event.event_id}`); + if (event.timestamp) lines.push(`Timestamp: ${event.timestamp}`); + if (event.environment) lines.push(`Environment: ${event.environment}`); + if (event.release) lines.push(`Release: ${event.release}`); + if (event.platform) lines.push(`Platform: ${event.platform}`); + if (event.transaction) lines.push(`Transaction: ${event.transaction}`); + if (event.level) lines.push(`Level: ${event.level}`); +} + +function appendEventTags(lines: string[], event: SentryEvent): void { + const tags = event.tags; + if (!tags) return; + const tagPairs = Array.isArray(tags) + ? tags.map(([k, v]) => `${k}=${v}`) + : Object.entries(tags).map(([k, v]) => `${k}=${v}`); + if (tagPairs.length > 0) { + lines.push(`Tags: ${tagPairs.join(', ')}`); + } +} + +function appendEventRequest(lines: string[], event: SentryEvent): void { + if (!event.request?.url) return; + lines.push(''); + lines.push('## Request'); + lines.push(`${event.request.method ?? 'GET'} ${event.request.url}`); + if (event.request.query_string) lines.push(`Query: ${event.request.query_string}`); +} + +function appendEventUser(lines: string[], event: SentryEvent): void { + if (!event.user) return; + lines.push(''); + lines.push('## User'); + const u = event.user; + if (u.id) lines.push(`ID: ${u.id}`); + if (u.email) lines.push(`Email: ${u.email}`); + if (u.username) lines.push(`Username: ${u.username}`); + if (u.ip_address) lines.push(`IP: ${u.ip_address}`); +} + +function appendEventStacktrace(lines: string[], event: SentryEvent): void { + const exceptions = event.exception?.values; + if (exceptions?.length) { + lines.push(''); + lines.push('## Exception'); + for (const exc of exceptions) { + lines.push(formatException(exc)); + } + return; + } + // Top-level stacktrace (no exception wrapper) + if (event.stacktrace?.frames?.length) { + lines.push(''); + lines.push('## Stacktrace'); + const frames = [...event.stacktrace.frames].reverse(); + for (let i = 0; i < frames.length; i++) { + lines.push(formatStackFrame(frames[i], i)); + } + } +} + +export function formatSentryEvent(event: SentryEvent): string { + const lines: string[] = []; + + const title = event.title ?? event.message ?? '(no title)'; + lines.push(`# Alert Event: ${title}`); + lines.push(''); + + appendEventMeta(lines, event); + appendEventTags(lines, event); + appendEventRequest(lines, event); + appendEventUser(lines, event); + appendEventStacktrace(lines, event); + + const breadcrumbs = event.breadcrumbs?.values; + if (breadcrumbs?.length) { + lines.push(''); + lines.push('## Breadcrumbs'); + lines.push(formatBreadcrumbs(breadcrumbs)); + } + + if (event.web_url) { + lines.push(''); + lines.push(`URL: ${event.web_url}`); + } + + return lines.join('\n'); +} + +// ============================================================================ +// Event list formatting +// ============================================================================ + +export function formatSentryEventList(events: SentryEvent[]): string { + if (events.length === 0) return 'No events found.'; + + const lines: string[] = [`${events.length} event(s):`]; + for (const e of events) { + const ts = e.timestamp ?? e.received ?? '(unknown time)'; + const id = e.event_id ? e.event_id.slice(0, 8) : '(no id)'; + const tx = e.transaction ? ` — ${e.transaction}` : ''; + lines.push(` [${id}] ${ts}${tx}`); + } + return lines.join('\n'); +} diff --git a/src/gadgets/sentry/core/getSentryEventDetail.ts b/src/gadgets/sentry/core/getSentryEventDetail.ts new file mode 100644 index 00000000..67cd7c3f --- /dev/null +++ b/src/gadgets/sentry/core/getSentryEventDetail.ts @@ -0,0 +1,12 @@ +import { getSentryClient } from '../../../sentry/client.js'; +import { formatSentryEvent } from './format.js'; + +export async function getSentryEventDetail( + organizationId: string, + issueId: string, + eventId = 'latest', +): Promise { + const client = getSentryClient(); + const event = await client.getIssueEvent(organizationId, issueId, eventId); + return formatSentryEvent(event); +} diff --git a/src/gadgets/sentry/core/getSentryIssue.ts b/src/gadgets/sentry/core/getSentryIssue.ts new file mode 100644 index 00000000..2b066cd1 --- /dev/null +++ b/src/gadgets/sentry/core/getSentryIssue.ts @@ -0,0 +1,8 @@ +import { getSentryClient } from '../../../sentry/client.js'; +import { formatSentryIssue } from './format.js'; + +export async function getSentryIssue(organizationId: string, issueId: string): Promise { + const client = getSentryClient(); + const issue = await client.getIssue(organizationId, issueId); + return formatSentryIssue(issue); +} diff --git a/src/gadgets/sentry/core/listSentryEvents.ts b/src/gadgets/sentry/core/listSentryEvents.ts new file mode 100644 index 00000000..017cd567 --- /dev/null +++ b/src/gadgets/sentry/core/listSentryEvents.ts @@ -0,0 +1,12 @@ +import { getSentryClient } from '../../../sentry/client.js'; +import { formatSentryEventList } from './format.js'; + +export async function listSentryEvents( + organizationId: string, + issueId: string, + limit = 5, +): Promise { + const client = getSentryClient(); + const events = await client.listIssueEvents(organizationId, issueId, { limit }); + return formatSentryEventList(events); +} diff --git a/src/gadgets/sentry/definitions.ts b/src/gadgets/sentry/definitions.ts new file mode 100644 index 00000000..1d8d258f --- /dev/null +++ b/src/gadgets/sentry/definitions.ts @@ -0,0 +1,100 @@ +/** + * Tool definitions for alerting tools. + * + * Single source of truth for both CLI commands and llmist gadgets. + * Named generically so adding a second alerting provider requires no renames. + */ + +import type { ToolDefinition } from '../shared/toolDefinition.js'; + +export const getAlertingIssueDef: ToolDefinition = { + name: 'GetAlertingIssue', + description: + 'Retrieve metadata for an alerting issue: title, culprit, status, occurrence count, firstSeen, lastSeen, and permalink. Use this to get an overview before diving into event details.', + timeoutMs: 15000, + parameters: { + organizationId: { + type: 'string', + describe: 'The organization identifier (e.g. "my-company")', + required: true, + }, + issueId: { + type: 'string', + describe: 'The issue ID', + required: true, + }, + }, + examples: [ + { + params: { organizationId: 'acme', issueId: '123456' }, + comment: 'Get metadata for issue 123456 in the acme organization', + }, + ], +}; + +export const getAlertingEventDetailDef: ToolDefinition = { + name: 'GetAlertingEventDetail', + description: + 'Retrieve full details for an alerting event: exception type/value, complete stacktrace with source context (5 lines before/after each frame), breadcrumbs, tags, environment, release, and request context. Use this to understand exactly what happened and where in the code.', + timeoutMs: 15000, + parameters: { + organizationId: { + type: 'string', + describe: 'The organization identifier (e.g. "my-company")', + required: true, + }, + issueId: { + type: 'string', + describe: 'The issue ID', + required: true, + }, + eventId: { + type: 'string', + describe: + 'The event ID to retrieve. Use "latest" (default), "oldest", or "recommended" for convenience, or a specific event ID.', + optional: true, + default: 'latest', + }, + }, + examples: [ + { + params: { organizationId: 'acme', issueId: '123456', eventId: 'latest' }, + comment: 'Get the latest event with full stacktrace for issue 123456', + }, + { + params: { organizationId: 'acme', issueId: '123456', eventId: 'abc123def456' }, + comment: 'Get a specific event by ID', + }, + ], +}; + +export const listAlertingEventsDef: ToolDefinition = { + name: 'ListAlertingEvents', + description: + 'List recent events for an alerting issue. Returns timestamps, event IDs, and transaction names. Useful for understanding how often the issue occurs and whether it is recent.', + timeoutMs: 15000, + parameters: { + organizationId: { + type: 'string', + describe: 'The organization identifier (e.g. "my-company")', + required: true, + }, + issueId: { + type: 'string', + describe: 'The issue ID', + required: true, + }, + limit: { + type: 'number', + describe: 'Maximum number of events to return (default: 5)', + optional: true, + default: 5, + }, + }, + examples: [ + { + params: { organizationId: 'acme', issueId: '123456', limit: 5 }, + comment: 'List the 5 most recent events for issue 123456', + }, + ], +}; diff --git a/src/gadgets/sentry/index.ts b/src/gadgets/sentry/index.ts new file mode 100644 index 00000000..1106f329 --- /dev/null +++ b/src/gadgets/sentry/index.ts @@ -0,0 +1,3 @@ +export { GetAlertingIssue } from './GetAlertingIssue.js'; +export { GetAlertingEventDetail } from './GetAlertingEventDetail.js'; +export { ListAlertingEvents } from './ListAlertingEvents.js'; diff --git a/src/router/adapters/sentry.ts b/src/router/adapters/sentry.ts new file mode 100644 index 00000000..a49f2a65 --- /dev/null +++ b/src/router/adapters/sentry.ts @@ -0,0 +1,130 @@ +/** + * SentryRouterAdapter — platform-specific logic for the router-side + * Sentry webhook processing pipeline. + * + * Uses URL-based routing: each CASCADE project gets a unique webhook URL + * (POST /sentry/webhook/:projectId). The project ID is injected into the + * augmented payload by the router before the adapter processes it. + */ + +import type { SentryAugmentedPayload } from '../../sentry/types.js'; +import type { TriggerRegistry } from '../../triggers/registry.js'; +import type { TriggerContext, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; +import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; +import type { CascadeJob, SentryJob } from '../queue.js'; + +// ============================================================================ +// Processable resource types +// ============================================================================ + +const PROCESSABLE_RESOURCES = ['event_alert', 'metric_alert'] as const; + +// ============================================================================ +// Extended parsed event +// ============================================================================ + +interface SentryParsedEvent extends ParsedWebhookEvent { + cascadeProjectId: string; + resource: string; +} + +// ============================================================================ +// Adapter +// ============================================================================ + +export class SentryRouterAdapter implements RouterPlatformAdapter { + readonly type = 'sentry' as const; + + async parseWebhook(payload: unknown): Promise { + const p = payload as SentryAugmentedPayload; + + if (!p.cascadeProjectId || !p.resource || !p.payload) { + logger.warn('SentryRouterAdapter: missing required augmented fields', { payload }); + return null; + } + + if (!PROCESSABLE_RESOURCES.includes(p.resource as (typeof PROCESSABLE_RESOURCES)[number])) { + logger.debug('SentryRouterAdapter: ignoring non-processable resource', { + resource: p.resource, + }); + return null; + } + + return { + projectIdentifier: p.cascadeProjectId, + eventType: p.resource, + workItemId: undefined, + isCommentEvent: false, + cascadeProjectId: p.cascadeProjectId, + resource: p.resource, + }; + } + + isProcessableEvent(event: ParsedWebhookEvent): boolean { + return PROCESSABLE_RESOURCES.includes( + event.eventType as (typeof PROCESSABLE_RESOURCES)[number], + ); + } + + async isSelfAuthored(_event: ParsedWebhookEvent, _payload: unknown): Promise { + // Sentry has no CASCADE bot — alerts are never self-authored + return false; + } + + sendReaction(_event: ParsedWebhookEvent, _payload: unknown): void { + // No reaction mechanism for Sentry alerts + } + + async resolveProject(event: ParsedWebhookEvent): Promise { + const sentryEvent = event as SentryParsedEvent; + const config = await loadProjectConfig(); + return config.projects.find((p) => p.id === sentryEvent.cascadeProjectId) ?? null; + } + + async dispatchWithCredentials( + _event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + triggerRegistry: TriggerRegistry, + ): Promise { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (!fullProject) { + logger.info('SentryRouterAdapter: no full project config found', { projectId: project.id }); + return null; + } + + const ctx: TriggerContext = { project: fullProject, source: 'sentry', payload }; + return triggerRegistry.dispatch(ctx); + } + + async postAck( + _event: ParsedWebhookEvent, + _payload: unknown, + _project: RouterProjectConfig, + _agentType: string, + ): Promise { + // No acknowledgment mechanism for Sentry alerts + return undefined; + } + + buildJob( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + result: TriggerResult, + ): CascadeJob { + const job: SentryJob = { + type: 'sentry', + source: 'sentry', + payload, + projectId: project.id, + eventType: event.eventType, + receivedAt: new Date().toISOString(), + triggerResult: result, + }; + return job; + } +} diff --git a/src/router/index.ts b/src/router/index.ts index 5e70afbb..b82c69fb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -14,10 +14,12 @@ import { createWebhookHandler, parseGitHubPayload, parseJiraPayload, + parseSentryPayload, parseTrelloPayload, } from '../webhook/webhookHandlers.js'; import { GitHubRouterAdapter, injectEventType } from './adapters/github.js'; import { JiraRouterAdapter } from './adapters/jira.js'; +import { SentryRouterAdapter } from './adapters/sentry.js'; import { TrelloRouterAdapter } from './adapters/trello.js'; import { startCancelListener, stopCancelListener } from './cancel-listener.js'; import { getQueueStats } from './queue.js'; @@ -25,6 +27,7 @@ import { processRouterWebhook } from './webhook-processor.js'; import { verifyGitHubWebhookSignature, verifyJiraWebhookSignature, + verifySentryWebhookSignature, verifyTrelloWebhookSignature, } from './webhookVerification.js'; import { @@ -142,6 +145,27 @@ app.post( }), ); +// Sentry webhook handler (alerting integration) +// Uses project-specific URLs: /sentry/webhook/:projectId +// The projectId in the URL is the CASCADE project ID, making routing unambiguous. +app.post( + '/sentry/webhook/:projectId', + createWebhookHandler({ + source: 'sentry', + parsePayload: (c) => parseSentryPayload(c, c.req.param('projectId') ?? ''), + verifySignature: verifySentryWebhookSignature, + processWebhook: async (payload) => { + const adapter = new SentryRouterAdapter(); + const result = await processRouterWebhook(adapter, payload, triggerRegistry); + return { + processed: result.shouldProcess, + projectId: result.projectId, + decisionReason: result.decisionReason, + }; + }, + }), +); + // Graceful shutdown async function shutdown(signal: string): Promise { logger.info('Received shutdown signal', { signal }); diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index ab117929..eef48516 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -64,7 +64,7 @@ export async function resolveJiraCredentials( */ export async function resolveWebhookSecret( projectId: string, - provider: 'github' | 'trello' | 'jira', + provider: 'github' | 'trello' | 'jira' | 'sentry', ): Promise { if (provider === 'github') { return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret'); @@ -72,6 +72,9 @@ export async function resolveWebhookSecret( if (provider === 'jira') { return getIntegrationCredentialOrNull(projectId, 'pm', 'webhook_secret'); } + if (provider === 'sentry') { + return getIntegrationCredentialOrNull(projectId, 'alerting', 'webhook_secret'); + } // Trello signs webhook payloads with the API Secret, not the public API Key. return getIntegrationCredentialOrNull(projectId, 'pm', 'api_secret'); } diff --git a/src/router/queue.ts b/src/router/queue.ts index 63e1b12e..f35035cb 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -47,7 +47,18 @@ export interface JiraJob { triggerResult?: TriggerResult; } -export type CascadeJob = TrelloJob | GitHubJob | JiraJob; +export interface SentryJob { + type: 'sentry'; + source: 'sentry'; + payload: unknown; + projectId: string; + /** Sentry resource type: 'event_alert' | 'metric_alert' | 'issue' */ + eventType: string; + receivedAt: string; + triggerResult?: TriggerResult; +} + +export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob; // Create the job queue export const jobQueue = new Queue('cascade-jobs', { diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index a7de3306..d5e5668e 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -10,6 +10,7 @@ import { logger } from '../utils/logging.js'; import { verifyGitHubSignature, verifyJiraSignature, + verifySentrySignature, verifyTrelloSignature, } from '../webhook/signatureVerification.js'; import { loadProjectConfig, routerConfig } from './config.js'; @@ -151,6 +152,38 @@ export async function verifyGitHubWebhookSignature( : { valid: false, reason: 'GitHub signature mismatch' }; } +/** + * verifySignature callback for the Sentry webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + * + * Sentry sends the signature as a raw HMAC-SHA256 hex digest in the + * `Sentry-Hook-Signature` header (no `sha256=` prefix). + * + * The project ID is taken from the URL path param (`:projectId`), + * which is unambiguous since each Sentry integration gets its own webhook URL. + */ +export async function verifySentryWebhookSignature( + c: Context, + rawBody: string, +): Promise<{ valid: boolean; reason: string } | null> { + const signatureHeader = c.req.header('Sentry-Hook-Signature'); + const projectId = c.req.param('projectId'); + + if (!projectId) return null; + + const secret = await resolveWebhookSecret(projectId, 'sentry'); + if (!secret) return null; // No secret configured — skip verification + + if (!signatureHeader) { + return { valid: false, reason: 'Missing Sentry-Hook-Signature header' }; + } + + const valid = verifySentrySignature(rawBody, signatureHeader, secret); + return valid + ? { valid: true, reason: 'Signature valid' } + : { valid: false, reason: 'Sentry signature mismatch' }; +} + /** * Extract the JIRA project key from a raw webhook payload. * JIRA sends the project key at `issue.fields.project.key`. diff --git a/src/sentry/client.ts b/src/sentry/client.ts new file mode 100644 index 00000000..67c2c84e --- /dev/null +++ b/src/sentry/client.ts @@ -0,0 +1,111 @@ +/** + * Sentry REST API client. + * + * Provides typed access to Sentry's API for reading issues and events. + * Authentication uses a Bearer token sourced from SENTRY_API_TOKEN env var. + * + * Required token scopes: event:read + * API base: https://sentry.io/api/0 + */ + +import type { SentryEvent, SentryIssue } from './types.js'; + +const SENTRY_API_BASE = 'https://sentry.io/api/0'; + +// ============================================================================ +// Client interface +// ============================================================================ + +export interface SentryClient { + /** + * Retrieve a single issue's metadata (title, culprit, status, counts, etc.) + * GET /api/0/organizations/{org}/issues/{issueId}/ + */ + getIssue(organizationSlug: string, issueId: string): Promise; + + /** + * Retrieve a specific event for an issue (with full stacktrace, breadcrumbs, etc.) + * GET /api/0/organizations/{org}/issues/{issueId}/events/{eventId}/ + * Use 'latest', 'oldest', or 'recommended' as eventId for convenience. + */ + getIssueEvent(organizationSlug: string, issueId: string, eventId?: string): Promise; + + /** + * List recent events for an issue. + * GET /api/0/organizations/{org}/issues/{issueId}/events/ + */ + listIssueEvents( + organizationSlug: string, + issueId: string, + options?: { limit?: number; full?: boolean }, + ): Promise; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +async function sentryFetch(url: string, authToken: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Sentry API error ${response.status}: ${body}`); + } + + return response.json() as Promise; +} + +function createSentryClient(authToken: string): SentryClient { + return { + async getIssue(organizationSlug: string, issueId: string): Promise { + const url = `${SENTRY_API_BASE}/organizations/${encodeURIComponent(organizationSlug)}/issues/${encodeURIComponent(issueId)}/`; + return sentryFetch(url, authToken); + }, + + async getIssueEvent( + organizationSlug: string, + issueId: string, + eventId = 'latest', + ): Promise { + const url = `${SENTRY_API_BASE}/organizations/${encodeURIComponent(organizationSlug)}/issues/${encodeURIComponent(issueId)}/events/${encodeURIComponent(eventId)}/`; + return sentryFetch(url, authToken); + }, + + async listIssueEvents( + organizationSlug: string, + issueId: string, + options: { limit?: number; full?: boolean } = {}, + ): Promise { + const params = new URLSearchParams(); + if (options.limit) params.set('limit', String(options.limit)); + if (options.full) params.set('full', 'true'); + const query = params.toString(); + const url = `${SENTRY_API_BASE}/organizations/${encodeURIComponent(organizationSlug)}/issues/${encodeURIComponent(issueId)}/events/${query ? `?${query}` : ''}`; + return sentryFetch(url, authToken); + }, + }; +} + +// ============================================================================ +// Factory — uses SENTRY_API_TOKEN env var +// ============================================================================ + +/** + * Create a Sentry API client from the environment. + * Reads SENTRY_API_TOKEN from the environment. + * Throws if the env var is not set. + * + * Workers are short-lived (one job per container), so a new client per call + * is cheap and avoids stale-token issues in tests. + */ +export function getSentryClient(): SentryClient { + const token = process.env.SENTRY_API_TOKEN; + if (!token) throw new Error('SENTRY_API_TOKEN environment variable is not set'); + return createSentryClient(token); +} diff --git a/src/sentry/integration.ts b/src/sentry/integration.ts new file mode 100644 index 00000000..e8894ca2 --- /dev/null +++ b/src/sentry/integration.ts @@ -0,0 +1,48 @@ +/** + * Sentry alerting integration helpers. + * + * Provides typed access to the Sentry integration config stored in + * project_integrations where category='alerting' and provider='sentry'. + */ + +import { getIntegrationByProjectAndCategory } from '../db/repositories/integrationsRepository.js'; + +// ============================================================================ +// Config interface +// ============================================================================ + +export interface SentryIntegrationConfig { + /** Sentry organization slug (e.g. "my-company") */ + organizationSlug: string; +} + +// ============================================================================ +// Config resolution +// ============================================================================ + +/** + * Get the Sentry integration config for a project. + * Returns null if no Sentry alerting integration is configured. + */ +export async function getSentryIntegrationConfig( + projectId: string, +): Promise { + const row = await getIntegrationByProjectAndCategory(projectId, 'alerting'); + if (!row || row.provider !== 'sentry') return null; + + const config = row.config as Record | null; + if (!config?.organizationSlug || typeof config.organizationSlug !== 'string') return null; + + return { + organizationSlug: config.organizationSlug, + }; +} + +/** + * Returns true if a Sentry alerting integration is configured for the project. + * Used by createIntegrationChecker() in the capability resolver. + */ +export async function hasAlertingIntegration(projectId: string): Promise { + const config = await getSentryIntegrationConfig(projectId); + return config !== null; +} diff --git a/src/sentry/types.ts b/src/sentry/types.ts new file mode 100644 index 00000000..512eeed3 --- /dev/null +++ b/src/sentry/types.ts @@ -0,0 +1,211 @@ +/** + * TypeScript types for Sentry webhook payloads and REST API responses. + * + * Covers: + * - Issue alert webhooks (Sentry-Hook-Resource: event_alert) + * - Metric alert webhooks (Sentry-Hook-Resource: metric_alert) + * - Issue lifecycle webhooks (Sentry-Hook-Resource: issue) + * - REST API responses for issues and events + */ + +// ============================================================================ +// Sentry webhook resource types (Sentry-Hook-Resource header) +// ============================================================================ + +export type SentryHookResource = 'event_alert' | 'metric_alert' | 'issue' | 'error'; + +// ============================================================================ +// Stack trace types +// ============================================================================ + +export interface SentryStackFrame { + filename?: string; + function?: string; + lineno?: number; + colno?: number; + in_app?: boolean; + pre_context?: string[]; + context_line?: string; + post_context?: string[]; + vars?: Record; + abs_path?: string; + module?: string; +} + +export interface SentryStackTrace { + frames?: SentryStackFrame[]; +} + +export interface SentryException { + type?: string; + value?: string; + stacktrace?: SentryStackTrace; + mechanism?: { type?: string; handled?: boolean }; +} + +// ============================================================================ +// Breadcrumbs +// ============================================================================ + +export interface SentryBreadcrumb { + type?: string; + category?: string; + message?: string; + level?: string; + timestamp?: string; + data?: Record; +} + +// ============================================================================ +// Sentry event (included in issue alert payloads and REST API responses) +// ============================================================================ + +export interface SentryEvent { + event_id?: string; + url?: string; + web_url?: string; + issue_id?: string; + issue_url?: string; + project?: string; + release?: string; + environment?: string; + platform?: string; + message?: string; + title?: string; + culprit?: string; + timestamp?: string; + received?: string; + level?: string; + transaction?: string; + exception?: { + values?: SentryException[]; + }; + stacktrace?: SentryStackTrace; + breadcrumbs?: { + values?: SentryBreadcrumb[]; + }; + tags?: Array<[string, string]> | Record; + contexts?: Record; + request?: { + url?: string; + method?: string; + headers?: Record; + query_string?: string; + }; + user?: { + id?: string; + email?: string; + username?: string; + ip_address?: string; + }; + sdk?: { name?: string; version?: string }; +} + +// ============================================================================ +// Sentry issue (REST API response + webhook data) +// ============================================================================ + +export interface SentryIssue { + id: string; + title: string; + culprit?: string; + permalink?: string; + shortId?: string; + status?: string; + substatus?: string; + issueCategory?: string; + issueType?: string; + priority?: string; + count?: string; + userCount?: number; + firstSeen?: string; + lastSeen?: string; + project?: { id: string; name: string; slug: string }; + assignedTo?: { name?: string; email?: string } | null; + isUnhandled?: boolean; +} + +// ============================================================================ +// Webhook payloads +// ============================================================================ + +/** Sentry issue alert webhook payload (Sentry-Hook-Resource: event_alert) */ +export interface SentryIssueAlertPayload { + action: 'triggered'; + actor?: { id?: string; type?: string }; + installation?: { uuid?: string }; + data: { + event: SentryEvent; + triggered_rule?: string; + issue_alert?: { + title?: string; + settings?: Array<{ name?: string; value?: string }>; + }; + }; + /** Injected by CASCADE router to identify the project (from URL path param) */ + _cascadeProjectId?: string; +} + +/** Sentry metric alert webhook payload (Sentry-Hook-Resource: metric_alert) */ +export interface SentryMetricAlertPayload { + action: 'critical' | 'warning' | 'resolved'; + actor?: { id?: string; type?: string }; + installation?: { uuid?: string }; + data: { + description_text?: string; + description_title?: string; + web_url?: string; + metric_alert?: { + alert_rule?: { + aggregate?: string; + dataset?: string; + query?: string; + thresholds?: Record; + triggers?: unknown[]; + }; + status?: string; + projects?: string[]; + date_created?: string; + date_detected?: string; + date_started?: string; + date_closed?: string | null; + }; + }; + /** Injected by CASCADE router to identify the project (from URL path param) */ + _cascadeProjectId?: string; +} + +/** Sentry issue lifecycle webhook payload (Sentry-Hook-Resource: issue) */ +export interface SentryIssuePayload { + action: 'created' | 'resolved' | 'assigned' | 'archived' | 'unresolved'; + actor?: { id?: string; name?: string; type?: string }; + data: { + id?: string; + title?: string; + url?: string; + web_url?: string; + project_url?: string; + status?: string; + substatus?: string; + issueCategory?: string; + count?: number; + userCount?: number; + firstSeen?: string; + lastSeen?: string; + assignedTo?: unknown; + }; + /** Injected by CASCADE router to identify the project (from URL path param) */ + _cascadeProjectId?: string; +} + +export type SentryWebhookPayload = + | SentryIssueAlertPayload + | SentryMetricAlertPayload + | SentryIssuePayload; + +/** Augmented payload injected by the router (includes resource type and project ID) */ +export interface SentryAugmentedPayload { + resource: SentryHookResource; + payload: SentryWebhookPayload; + cascadeProjectId: string; +} diff --git a/src/triggers/builtins.ts b/src/triggers/builtins.ts index e36124fd..80aefcb6 100644 --- a/src/triggers/builtins.ts +++ b/src/triggers/builtins.ts @@ -21,10 +21,12 @@ import { registerGitHubTriggers } from './github/register.js'; import { registerJiraTriggers } from './jira/register.js'; import type { TriggerRegistry } from './registry.js'; +import { registerSentryTriggers } from './sentry/register.js'; import { registerTrelloTriggers } from './trello/register.js'; export function registerBuiltInTriggers(registry: TriggerRegistry): void { registerTrelloTriggers(registry); registerJiraTriggers(registry); registerGitHubTriggers(registry); + registerSentryTriggers(registry); } diff --git a/src/triggers/sentry/alerting-issue.ts b/src/triggers/sentry/alerting-issue.ts new file mode 100644 index 00000000..355a6f4c --- /dev/null +++ b/src/triggers/sentry/alerting-issue.ts @@ -0,0 +1,85 @@ +/** + * Trigger handler: Sentry issue alert (event_alert). + * + * Fires the 'alerting' agent when a Sentry issue alert rule triggers. + * The payload includes the full event object (exception, stacktrace, breadcrumbs). + */ + +import { getSentryIntegrationConfig } from '../../sentry/integration.js'; +import type { SentryAugmentedPayload, SentryIssueAlertPayload } from '../../sentry/types.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; + +export class SentryIssueAlertTrigger implements TriggerHandler { + name = 'sentry-issue-alert'; + description = 'Triggers alerting agent when an issue alert fires'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'sentry') return false; + const p = ctx.payload as SentryAugmentedPayload; + return p.resource === 'event_alert'; + } + + async handle(ctx: TriggerContext): Promise { + const triggerConfig = await checkTriggerEnabledWithParams( + ctx.project.id, + 'alerting', + 'alerting:issue-alert', + this.name, + ); + if (!triggerConfig.enabled) { + logger.debug('SentryIssueAlertTrigger: trigger disabled, skipping', { + projectId: ctx.project.id, + }); + return null; + } + + const augmented = ctx.payload as SentryAugmentedPayload; + const innerPayload = augmented.payload as SentryIssueAlertPayload; + + // Extract issue/event info from the payload + const event = innerPayload.data?.event; + const issueId = event?.issue_id ?? event?.issue_url?.split('/').pop(); + const issueUrl = event?.web_url ?? event?.issue_url; + const alertTitle = + innerPayload.data?.issue_alert?.title ?? + innerPayload.data?.triggered_rule ?? + event?.title ?? + 'Issue Alert'; + + if (!issueId) { + logger.warn('SentryIssueAlertTrigger: cannot determine issue ID from payload', { + projectId: ctx.project.id, + }); + return null; + } + + // Look up org slug from integration config + const sentryConfig = await getSentryIntegrationConfig(ctx.project.id); + if (!sentryConfig) { + logger.warn('SentryIssueAlertTrigger: no Sentry integration config for project', { + projectId: ctx.project.id, + }); + return null; + } + + logger.info('Alerting: issue alert triggered', { + projectId: ctx.project.id, + issueId, + alertTitle, + orgId: sentryConfig.organizationSlug, + }); + + return { + agentType: 'alerting', + agentInput: { + triggerEvent: 'alerting:issue-alert', + alertIssueId: issueId, + alertOrgId: sentryConfig.organizationSlug, + alertTitle, + alertIssueUrl: issueUrl, + }, + }; + } +} diff --git a/src/triggers/sentry/alerting-metric.ts b/src/triggers/sentry/alerting-metric.ts new file mode 100644 index 00000000..ffef55ae --- /dev/null +++ b/src/triggers/sentry/alerting-metric.ts @@ -0,0 +1,88 @@ +/** + * Trigger handler: Sentry metric alert (metric_alert). + * + * Fires the 'alerting' agent when a Sentry metric alert enters a critical + * or warning state (not on resolution). + * + * Supports a `severity` parameter to filter by minimum severity level. + */ + +import { getSentryIntegrationConfig } from '../../sentry/integration.js'; +import type { SentryAugmentedPayload, SentryMetricAlertPayload } from '../../sentry/types.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; + +const ACTIVE_ACTIONS = ['critical', 'warning'] as const; + +export class SentryMetricAlertTrigger implements TriggerHandler { + name = 'sentry-metric-alert'; + description = 'Triggers alerting agent when a metric alert enters critical/warning state'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'sentry') return false; + const p = ctx.payload as SentryAugmentedPayload; + if (p.resource !== 'metric_alert') return false; + const innerPayload = p.payload as SentryMetricAlertPayload; + return ACTIVE_ACTIONS.includes(innerPayload.action as (typeof ACTIVE_ACTIONS)[number]); + } + + async handle(ctx: TriggerContext): Promise { + const triggerConfig = await checkTriggerEnabledWithParams( + ctx.project.id, + 'alerting', + 'alerting:metric-alert', + this.name, + ); + if (!triggerConfig.enabled) { + logger.debug('SentryMetricAlertTrigger: trigger disabled, skipping', { + projectId: ctx.project.id, + }); + return null; + } + + const augmented = ctx.payload as SentryAugmentedPayload; + const innerPayload = augmented.payload as SentryMetricAlertPayload; + const action = innerPayload.action; // 'critical' | 'warning' + + // Apply severity filter from parameters + const minSeverity = (triggerConfig.parameters.severity as string | undefined) ?? 'critical'; + if (minSeverity === 'critical' && action === 'warning') { + logger.debug('SentryMetricAlertTrigger: action=warning below minimum severity=critical', { + projectId: ctx.project.id, + }); + return null; + } + + // Look up org slug from integration config + const sentryConfig = await getSentryIntegrationConfig(ctx.project.id); + if (!sentryConfig) { + logger.warn('SentryMetricAlertTrigger: no Sentry integration config for project', { + projectId: ctx.project.id, + }); + return null; + } + + const alertTitle = + innerPayload.data?.description_title ?? + innerPayload.data?.metric_alert?.alert_rule?.aggregate ?? + `Metric Alert (${action})`; + + logger.info('Alerting: metric alert triggered', { + projectId: ctx.project.id, + action, + alertTitle, + orgId: sentryConfig.organizationSlug, + }); + + return { + agentType: 'alerting', + agentInput: { + triggerEvent: 'alerting:metric-alert', + alertOrgId: sentryConfig.organizationSlug, + alertTitle, + alertIssueUrl: innerPayload.data?.web_url, + }, + }; + } +} diff --git a/src/triggers/sentry/register.ts b/src/triggers/sentry/register.ts new file mode 100644 index 00000000..b96d8d58 --- /dev/null +++ b/src/triggers/sentry/register.ts @@ -0,0 +1,8 @@ +import type { TriggerRegistry } from '../registry.js'; +import { SentryIssueAlertTrigger } from './alerting-issue.js'; +import { SentryMetricAlertTrigger } from './alerting-metric.js'; + +export function registerSentryTriggers(registry: TriggerRegistry): void { + registry.register(new SentryIssueAlertTrigger()); + registry.register(new SentryMetricAlertTrigger()); +} diff --git a/src/types/index.ts b/src/types/index.ts index 760a473f..d9493116 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -53,6 +53,12 @@ export interface AgentInput { // Router/webhook-handler-posted ack message text — reused as initial comment header ackMessage?: string; + // Alerting fields + alertIssueId?: string; + alertOrgId?: string; + alertTitle?: string; + alertIssueUrl?: string; + [key: string]: unknown; } diff --git a/src/webhook/signatureVerification.ts b/src/webhook/signatureVerification.ts index 9fecd393..cbbb49e4 100644 --- a/src/webhook/signatureVerification.ts +++ b/src/webhook/signatureVerification.ts @@ -70,6 +70,33 @@ export function verifyTrelloSignature( return timingSafeEqual(expected, actual); } +/** + * Verify a Sentry webhook signature. + * + * Sentry signs payloads with HMAC-SHA256 and sends the result as a raw hex + * digest in the `Sentry-Hook-Signature` header (no `sha256=` prefix). + * + * @param rawBody - The raw request body string. + * @param signature - The value of the `Sentry-Hook-Signature` header. + * @param secret - The webhook secret configured in Sentry. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifySentrySignature(rawBody: string, signature: string, secret: string): boolean { + if (!signature) { + return false; + } + + const expectedHex = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); + const expected = Buffer.from(expectedHex, 'utf8'); + const actual = Buffer.from(signature, 'utf8'); + + if (expected.length !== actual.length) { + return false; + } + + return timingSafeEqual(expected, actual); +} + /** * Verify a JIRA webhook signature. * diff --git a/src/webhook/webhookHandlers.ts b/src/webhook/webhookHandlers.ts index 15fbb7a7..c839f1c6 100644 --- a/src/webhook/webhookHandlers.ts +++ b/src/webhook/webhookHandlers.ts @@ -21,7 +21,12 @@ import { handleProcessingError, logSuccessfulWebhook } from './webhookLogging.js // --------------------------------------------------------------------------- export type { ParseResult, WebhookHandlerConfig, WebhookLogOverrides } from './webhookTypes.js'; -export { parseGitHubPayload, parseJiraPayload, parseTrelloPayload } from './webhookParsers.js'; +export { + parseGitHubPayload, + parseJiraPayload, + parseSentryPayload, + parseTrelloPayload, +} from './webhookParsers.js'; // --------------------------------------------------------------------------- // Types (local import for factory use) diff --git a/src/webhook/webhookParsers.ts b/src/webhook/webhookParsers.ts index 8dfcbf3b..e4b52a24 100644 --- a/src/webhook/webhookParsers.ts +++ b/src/webhook/webhookParsers.ts @@ -57,6 +57,43 @@ export async function parseGitHubPayload(c: Context): Promise { return { ok: true, payload, eventType, rawBody }; } +/** + * Parse a Sentry webhook request. + * Event type comes from the `Sentry-Hook-Resource` header. + * The cascadeProjectId (from the URL path param) must be passed explicitly + * since Hono path params are not available in this generic parser. + * + * Returns an augmented payload that the SentryRouterAdapter understands. + */ +export async function parseSentryPayload( + c: Context, + cascadeProjectId: string, +): Promise { + try { + const rawBody = await c.req.text(); + const innerPayload = JSON.parse(rawBody); + const resource = c.req.header('Sentry-Hook-Resource') ?? 'unknown'; + const action = (innerPayload as Record)?.action as string | undefined; + + logger.info('Received Sentry webhook', { + resource, + action, + projectId: cascadeProjectId, + }); + + // Build the augmented payload that SentryRouterAdapter expects + const payload = { + resource, + payload: innerPayload, + cascadeProjectId, + }; + + return { ok: true, payload, eventType: resource, rawBody }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + /** * Parse a JIRA webhook request (plain JSON). * Extracts `webhookEvent` as the event type. diff --git a/tests/unit/agents/definitions/async-resolver.test.ts b/tests/unit/agents/definitions/async-resolver.test.ts index fa19b8af..157bf0fa 100644 --- a/tests/unit/agents/definitions/async-resolver.test.ts +++ b/tests/unit/agents/definitions/async-resolver.test.ts @@ -9,6 +9,7 @@ import { } from '../../../../src/agents/definitions/loader.js'; const ALL_AGENT_TYPES = [ + 'alerting', 'backlog-manager', 'debug', 'implementation', diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts index d20f278d..3a2e9a3a 100644 --- a/tests/unit/agents/definitions/loader.test.ts +++ b/tests/unit/agents/definitions/loader.test.ts @@ -15,6 +15,7 @@ import { CONTEXT_STEP_REGISTRY } from '../../../../src/agents/definitions/strate import { getAgentCapabilities } from '../../../../src/agents/shared/capabilities.js'; const ALL_AGENT_TYPES = [ + 'alerting', 'backlog-manager', 'debug', 'implementation', @@ -34,7 +35,7 @@ describe('YAML agent definitions loader', () => { }); describe('getKnownAgentTypes', () => { - it('discovers all 11 agent types from YAML files', () => { + it('discovers all 12 agent types from YAML files', () => { const types = getKnownAgentTypes(); expect(types).toEqual(ALL_AGENT_TYPES); }); @@ -80,7 +81,7 @@ describe('YAML agent definitions loader', () => { }); describe('loadAllAgentDefinitions', () => { - it('returns a map with all 11 agent types', () => { + it('returns a map with all 12 agent types', () => { const all = loadAllAgentDefinitions(); expect(all.size).toBe(ALL_AGENT_TYPES.length); for (const agentType of ALL_AGENT_TYPES) { @@ -478,7 +479,7 @@ describe('YAML agent definitions loader', () => { }); it('all derived integration categories are valid', () => { - const validCategories = ['pm', 'scm', 'email']; + const validCategories = ['pm', 'scm', 'email', 'alerting']; for (const agentType of ALL_AGENT_TYPES) { const def = loadAgentDefinition(agentType); const integrations = deriveIntegrations( diff --git a/tests/unit/agents/definitions/strategies.test.ts b/tests/unit/agents/definitions/strategies.test.ts index 4610f780..ccef058a 100644 --- a/tests/unit/agents/definitions/strategies.test.ts +++ b/tests/unit/agents/definitions/strategies.test.ts @@ -13,6 +13,7 @@ describe.concurrent('CONTEXT_STEP_REGISTRY', () => { 'prContext', 'prConversation', 'pipelineSnapshot', + 'alertingIssue', ]; for (const key of expectedKeys) { @@ -36,6 +37,7 @@ describe.concurrent('CONTEXT_STEP_REGISTRY', () => { 'prContext', 'prConversation', 'pipelineSnapshot', + 'alertingIssue', ]; const actualKeys = Object.keys(CONTEXT_STEP_REGISTRY); diff --git a/tests/unit/config/integrationRoles.test.ts b/tests/unit/config/integrationRoles.test.ts index b16a6694..c09aecca 100644 --- a/tests/unit/config/integrationRoles.test.ts +++ b/tests/unit/config/integrationRoles.test.ts @@ -25,14 +25,14 @@ describe.concurrent('PROVIDER_CATEGORY', () => { }); it('maps all known providers to valid categories', () => { - const validCategories: IntegrationCategory[] = ['pm', 'scm']; + const validCategories: IntegrationCategory[] = ['pm', 'scm', 'alerting']; for (const [provider, category] of Object.entries(PROVIDER_CATEGORY)) { expect(validCategories).toContain(category); } }); it('contains all expected providers', () => { - const expectedProviders: IntegrationProvider[] = ['trello', 'jira', 'github']; + const expectedProviders: IntegrationProvider[] = ['trello', 'jira', 'github', 'sentry']; for (const provider of expectedProviders) { expect(PROVIDER_CATEGORY).toHaveProperty(provider); } diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index 4eab8f9c..3899ca4e 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -54,6 +54,13 @@ vi.mock('../../../src/triggers/trello/label-added.js', () => ({ .mockImplementation(() => ({ name: 'ready-to-process-label' })), })); +vi.mock('../../../src/triggers/sentry/alerting-issue.js', () => ({ + SentryIssueAlertTrigger: vi.fn().mockImplementation(() => ({ name: 'sentry-issue-alert' })), +})); +vi.mock('../../../src/triggers/sentry/alerting-metric.js', () => ({ + SentryMetricAlertTrigger: vi.fn().mockImplementation(() => ({ name: 'sentry-metric-alert' })), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: { debug: vi.fn(), @@ -80,8 +87,8 @@ describe('registerBuiltInTriggers', () => { registerBuiltInTriggers(registry as unknown as TriggerRegistry); - // Should have registered all 18 built-in triggers (17 + pr-conflict-detected) - expect(registry.register).toHaveBeenCalledTimes(18); + // Should have registered all 20 built-in triggers (18 + 2 Sentry alerting triggers) + expect(registry.register).toHaveBeenCalledTimes(20); }); it('registers TrelloCommentMentionTrigger first', () => { @@ -133,6 +140,16 @@ describe('registerBuiltInTriggers', () => { expect(registeredNames).toContain('jira-label-added'); }); + it('registers Sentry alerting triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('sentry-issue-alert'); + expect(registeredNames).toContain('sentry-metric-alert'); + }); + it('registers TrelloCommentMentionTrigger before status-changed triggers', () => { const registry = createMockRegistry(); diff --git a/tests/unit/triggers/sentry-alerting.test.ts b/tests/unit/triggers/sentry-alerting.test.ts new file mode 100644 index 00000000..69e5dd9a --- /dev/null +++ b/tests/unit/triggers/sentry-alerting.test.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +vi.mock('../../../src/sentry/integration.js', () => ({ + getSentryIntegrationConfig: vi.fn(), +})); + +import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js'; +import { SentryIssueAlertTrigger } from '../../../src/triggers/sentry/alerting-issue.js'; +import { SentryMetricAlertTrigger } from '../../../src/triggers/sentry/alerting-metric.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../src/types/index.js'; +import { createMockProject } from '../../helpers/factories.js'; + +const mockProject = createMockProject(); + +const sentryConfig = { organizationSlug: 'my-org' }; + +function makeSentryIssueAlertCtx( + overrides: { + resource?: string; + eventOverrides?: Record; + issueAlertTitle?: string; + triggeredRule?: string; + } = {}, +): TriggerContext { + return { + project: mockProject, + source: 'sentry', + payload: { + resource: overrides.resource ?? 'event_alert', + payload: { + action: 'triggered', + data: { + event: { + event_id: 'evt-abc123', + issue_id: 'issue-42', + web_url: 'https://sentry.io/issues/issue-42/', + title: 'NullPointerException at PaymentService.charge', + ...overrides.eventOverrides, + }, + ...(overrides.issueAlertTitle !== undefined + ? { issue_alert: { title: overrides.issueAlertTitle } } + : {}), + ...(overrides.triggeredRule !== undefined + ? { triggered_rule: overrides.triggeredRule } + : {}), + }, + }, + cascadeProjectId: 'test', + }, + } as TriggerContext; +} + +function makeSentryMetricAlertCtx( + overrides: { + resource?: string; + action?: string; + descriptionTitle?: string; + aggregate?: string; + webUrl?: string; + } = {}, +): TriggerContext { + return { + project: mockProject, + source: 'sentry', + payload: { + resource: overrides.resource ?? 'metric_alert', + payload: { + action: overrides.action ?? 'critical', + data: { + ...(overrides.descriptionTitle !== undefined + ? { description_title: overrides.descriptionTitle } + : {}), + ...(overrides.webUrl !== undefined ? { web_url: overrides.webUrl } : {}), + ...(overrides.aggregate !== undefined + ? { metric_alert: { alert_rule: { aggregate: overrides.aggregate } } } + : {}), + }, + }, + cascadeProjectId: 'test', + }, + } as TriggerContext; +} + +// ============================================================================ +// SentryIssueAlertTrigger +// ============================================================================ + +describe('SentryIssueAlertTrigger', () => { + let trigger: SentryIssueAlertTrigger; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled: true, parameters: {} }); + vi.mocked(getSentryIntegrationConfig).mockResolvedValue(sentryConfig); + trigger = new SentryIssueAlertTrigger(); + }); + + // ------------------------------------------------------------------------- + // matches() + // ------------------------------------------------------------------------- + + describe('matches()', () => { + it('returns true for sentry source with event_alert resource', () => { + expect(trigger.matches(makeSentryIssueAlertCtx())).toBe(true); + }); + + it('returns false for non-sentry source', () => { + const ctx = { ...makeSentryIssueAlertCtx(), source: 'github' } as TriggerContext; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('returns false when resource is not event_alert', () => { + expect(trigger.matches(makeSentryIssueAlertCtx({ resource: 'metric_alert' }))).toBe(false); + }); + + it('returns false when resource is issue lifecycle event', () => { + expect(trigger.matches(makeSentryIssueAlertCtx({ resource: 'issue' }))).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // handle() + // ------------------------------------------------------------------------- + + describe('handle()', () => { + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: false, + parameters: {}, + }); + const result = await trigger.handle(makeSentryIssueAlertCtx()); + expect(result).toBeNull(); + }); + + it('returns null when issue ID cannot be determined', async () => { + const ctx = makeSentryIssueAlertCtx({ + eventOverrides: { issue_id: undefined, issue_url: undefined }, + }); + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('cannot determine issue ID'), + expect.any(Object), + ); + }); + + it('returns null when Sentry integration config is missing', async () => { + vi.mocked(getSentryIntegrationConfig).mockResolvedValue(null); + const result = await trigger.handle(makeSentryIssueAlertCtx()); + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('no Sentry integration config'), + expect.any(Object), + ); + }); + + it('returns a TriggerResult with alertIssueId from event.issue_id', async () => { + const result = await trigger.handle(makeSentryIssueAlertCtx()); + expect(result).toMatchObject({ + agentType: 'alerting', + agentInput: { + triggerEvent: 'alerting:issue-alert', + alertIssueId: 'issue-42', + alertOrgId: 'my-org', + alertIssueUrl: 'https://sentry.io/issues/issue-42/', + }, + }); + }); + + it('extracts issue ID from issue_url when issue_id is absent', async () => { + // URL without trailing slash so .split('/').pop() returns the ID, not '' + const ctx = makeSentryIssueAlertCtx({ + eventOverrides: { + issue_id: undefined, + issue_url: 'https://sentry.io/api/0/issues/9999', + }, + }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertIssueId).toBe('9999'); + }); + + it('uses issue_alert.title as alertTitle', async () => { + const ctx = makeSentryIssueAlertCtx({ issueAlertTitle: 'My Alert Rule' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertTitle).toBe('My Alert Rule'); + }); + + it('falls back to triggered_rule when issue_alert.title is absent', async () => { + const ctx = makeSentryIssueAlertCtx({ triggeredRule: 'Error Rate > 5%' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertTitle).toBe('Error Rate > 5%'); + }); + + it('falls back to event.title when no rule title is present', async () => { + const result = await trigger.handle(makeSentryIssueAlertCtx()); + expect(result?.agentInput?.alertTitle).toBe('NullPointerException at PaymentService.charge'); + }); + + it('uses default alertTitle when all title sources are absent', async () => { + const ctx = makeSentryIssueAlertCtx({ + eventOverrides: { issue_id: 'issue-42', web_url: 'https://sentry.io/', title: undefined }, + }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertTitle).toBe('Issue Alert'); + }); + + it('sets alertIssueUrl from event.web_url', async () => { + const result = await trigger.handle(makeSentryIssueAlertCtx()); + expect(result?.agentInput?.alertIssueUrl).toBe('https://sentry.io/issues/issue-42/'); + }); + + it('falls back to issue_url for alertIssueUrl when web_url is absent', async () => { + const issueUrl = 'https://sentry.io/api/0/issues/issue-42/'; + const ctx = makeSentryIssueAlertCtx({ + eventOverrides: { web_url: undefined, issue_url: issueUrl }, + }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertIssueUrl).toBe(issueUrl); + }); + }); +}); + +// ============================================================================ +// SentryMetricAlertTrigger +// ============================================================================ + +describe('SentryMetricAlertTrigger', () => { + let trigger: SentryMetricAlertTrigger; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled: true, parameters: {} }); + vi.mocked(getSentryIntegrationConfig).mockResolvedValue(sentryConfig); + trigger = new SentryMetricAlertTrigger(); + }); + + // ------------------------------------------------------------------------- + // matches() + // ------------------------------------------------------------------------- + + describe('matches()', () => { + it('returns true for sentry source with metric_alert + critical action', () => { + expect(trigger.matches(makeSentryMetricAlertCtx({ action: 'critical' }))).toBe(true); + }); + + it('returns true for sentry source with metric_alert + warning action', () => { + expect(trigger.matches(makeSentryMetricAlertCtx({ action: 'warning' }))).toBe(true); + }); + + it('returns false for resolved action (not an active alert)', () => { + expect(trigger.matches(makeSentryMetricAlertCtx({ action: 'resolved' }))).toBe(false); + }); + + it('returns false for non-sentry source', () => { + const ctx = { ...makeSentryMetricAlertCtx(), source: 'github' } as TriggerContext; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('returns false when resource is event_alert', () => { + expect(trigger.matches(makeSentryMetricAlertCtx({ resource: 'event_alert' }))).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // handle() + // ------------------------------------------------------------------------- + + describe('handle()', () => { + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: false, + parameters: {}, + }); + const result = await trigger.handle(makeSentryMetricAlertCtx()); + expect(result).toBeNull(); + }); + + it('returns null for warning action when minSeverity defaults to critical', async () => { + // Default parameters: severity is not set, so minSeverity = 'critical' + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'warning' })); + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('below minimum severity'), + expect.any(Object), + ); + }); + + it('allows warning action when minSeverity is set to warning', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { severity: 'warning' }, + }); + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'warning' })); + expect(result).not.toBeNull(); + }); + + it('allows critical action regardless of minSeverity', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { severity: 'critical' }, + }); + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'critical' })); + expect(result).not.toBeNull(); + }); + + it('returns null when Sentry integration config is missing', async () => { + vi.mocked(getSentryIntegrationConfig).mockResolvedValue(null); + const result = await trigger.handle(makeSentryMetricAlertCtx()); + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('no Sentry integration config'), + expect.any(Object), + ); + }); + + it('returns a TriggerResult with alertOrgId from config', async () => { + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'critical' })); + expect(result).toMatchObject({ + agentType: 'alerting', + agentInput: { + triggerEvent: 'alerting:metric-alert', + alertOrgId: 'my-org', + }, + }); + }); + + it('uses description_title as alertTitle', async () => { + const ctx = makeSentryMetricAlertCtx({ descriptionTitle: 'Error Rate High' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertTitle).toBe('Error Rate High'); + }); + + it('falls back to metric_alert aggregate as alertTitle', async () => { + const ctx = makeSentryMetricAlertCtx({ aggregate: 'p95(transaction.duration)' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertTitle).toBe('p95(transaction.duration)'); + }); + + it('uses default alertTitle when all title sources are absent', async () => { + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'critical' })); + expect(result?.agentInput?.alertTitle).toBe('Metric Alert (critical)'); + }); + + it('sets alertIssueUrl from data.web_url', async () => { + const ctx = makeSentryMetricAlertCtx({ webUrl: 'https://sentry.io/alerts/123/' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput?.alertIssueUrl).toBe('https://sentry.io/alerts/123/'); + }); + + it('sets alertIssueUrl to undefined when web_url is absent', async () => { + const result = await trigger.handle(makeSentryMetricAlertCtx({ action: 'critical' })); + expect(result?.agentInput?.alertIssueUrl).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/webhook/signatureVerification.test.ts b/tests/unit/webhook/signatureVerification.test.ts index 36994ac4..5e74b932 100644 --- a/tests/unit/webhook/signatureVerification.test.ts +++ b/tests/unit/webhook/signatureVerification.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { verifyGitHubSignature, verifyJiraSignature, + verifySentrySignature, verifyTrelloSignature, } from '../../../src/webhook/signatureVerification.js'; @@ -188,3 +189,58 @@ describe('verifyJiraSignature', () => { expect(verifyJiraSignature(body, 'sha256=abc', secret)).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// verifySentrySignature +// --------------------------------------------------------------------------- + +describe('verifySentrySignature', () => { + const secret = 'my-sentry-secret'; + const body = '{"action":"triggered","data":{"event":{"title":"Error"}}}'; + + function sentrySignature(b: string, s: string): string { + return createHmac('sha256', s).update(b, 'utf8').digest('hex'); + } + + it('returns true for a valid signature', () => { + const sig = sentrySignature(body, secret); + expect(verifySentrySignature(body, sig, secret)).toBe(true); + }); + + it('returns false for an empty body with a signature for non-empty body', () => { + const sig = sentrySignature(body, secret); + expect(verifySentrySignature('', sig, secret)).toBe(false); + }); + + it('returns true for an empty body when the signature matches the empty body', () => { + const sig = sentrySignature('', secret); + expect(verifySentrySignature('', sig, secret)).toBe(true); + }); + + it('returns false when the signature is an empty string', () => { + expect(verifySentrySignature(body, '', secret)).toBe(false); + }); + + it('returns false when the signature has an unexpected sha256= prefix (unlike GitHub format)', () => { + const withPrefix = `sha256=${sentrySignature(body, secret)}`; + expect(verifySentrySignature(body, withPrefix, secret)).toBe(false); + }); + + it('returns false when signed with a different secret', () => { + const sig = sentrySignature(body, 'wrong-secret'); + expect(verifySentrySignature(body, sig, secret)).toBe(false); + }); + + it('returns false when the body has been tampered with', () => { + const sig = sentrySignature(body, secret); + expect(verifySentrySignature(`${body}tampered`, sig, secret)).toBe(false); + }); + + it('returns false for a completely garbage signature string', () => { + expect(verifySentrySignature(body, 'not-a-real-signature', secret)).toBe(false); + }); + + it('is timing-safe: the comparison does not short-circuit on length mismatch', () => { + expect(verifySentrySignature(body, 'abc', secret)).toBe(false); + }); +});