diff --git a/src/triggers/sentry/webhook-handler.ts b/src/triggers/sentry/webhook-handler.ts new file mode 100644 index 00000000..9bb3ec98 --- /dev/null +++ b/src/triggers/sentry/webhook-handler.ts @@ -0,0 +1,82 @@ +/** + * Sentry webhook handler. + * + * Uses the pre-computed TriggerResult from the router when available, + * falling back to dispatching through the trigger registry if not. + * After resolving the trigger result, runs the matched agent via the + * shared execution pipeline. + */ + +import { withPMCredentials, withPMProvider } from '../../pm/context.js'; +import { createPMProvider, pmRegistry } from '../../pm/index.js'; +import type { TriggerResult } from '../../types/index.js'; +import { startWatchdog } from '../../utils/lifecycle.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerRegistry } from '../registry.js'; +import { runAgentExecutionPipeline } from '../shared/agent-execution.js'; + +export async function processSentryWebhook( + payload: unknown, + projectId: string, + registry: TriggerRegistry, + triggerResult?: TriggerResult, +): Promise { + const { loadProjectConfigById } = await import('../../config/provider.js'); + + const pc = await loadProjectConfigById(projectId); + if (!pc) { + logger.warn('processSentryWebhook: project not found, skipping', { projectId }); + return; + } + + // Resolve trigger result — use pre-computed from router or dispatch via registry + let result: TriggerResult | null; + if (triggerResult) { + logger.info('processSentryWebhook: using pre-computed trigger result', { + projectId, + agentType: triggerResult.agentType, + }); + result = triggerResult; + } else { + const ctx = { + project: pc.project, + source: 'sentry' as const, + payload, + }; + result = await registry.dispatch(ctx); + } + + if (!result) { + logger.info('processSentryWebhook: no trigger matched', { projectId }); + return; + } + + if (!result.agentType) { + logger.info('processSentryWebhook: trigger matched but no agent type, skipping', { + projectId, + }); + return; + } + + logger.info('processSentryWebhook: running agent', { + projectId, + agentType: result.agentType, + }); + + startWatchdog(pc.project.watchdogTimeoutMs); + + const pmProvider = createPMProvider(pc.project); + await withPMCredentials( + pc.project.id, + pc.project.pm?.type, + (t) => pmRegistry.getOrNull(t), + () => + withPMProvider(pmProvider, () => + runAgentExecutionPipeline(result, pc.project, pc.config, { + logLabel: 'Sentry agent', + skipPrepareForAgent: true, + skipHandleFailure: true, + }), + ), + ); +} diff --git a/src/worker-entry.ts b/src/worker-entry.ts index ade73698..a8758c64 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -25,6 +25,7 @@ import { processJiraWebhook, registerBuiltInTriggers, } from './triggers/index.js'; +import { processSentryWebhook } from './triggers/sentry/webhook-handler.js'; import { processTrelloWebhook } from './triggers/trello/webhook-handler.js'; import type { TriggerResult } from './types/index.js'; import { scrubSensitiveEnv } from './utils/envScrub.js'; @@ -66,6 +67,17 @@ export interface JiraJobData { triggerResult?: TriggerResult; } +export interface SentryJobData { + type: 'sentry'; + source: 'sentry'; + payload: unknown; + projectId: string; + /** Sentry resource type: 'event_alert' | 'metric_alert' | 'issue' */ + eventType: string; + receivedAt: string; + triggerResult?: TriggerResult; +} + export interface ManualRunJobData { type: 'manual-run'; projectId: string; @@ -94,7 +106,12 @@ export interface DebugAnalysisJobData { export type DashboardJobData = ManualRunJobData | RetryRunJobData | DebugAnalysisJobData; -export type JobData = TrelloJobData | GitHubJobData | JiraJobData | DashboardJobData; +export type JobData = + | TrelloJobData + | GitHubJobData + | JiraJobData + | SentryJobData + | DashboardJobData; export async function processDashboardJob(jobId: string, jobData: DashboardJobData): Promise { const { loadProjectConfigById } = await import('./config/provider.js'); @@ -193,6 +210,20 @@ export async function dispatchJob( jobData.triggerResult, ); break; + case 'sentry': + logger.info('[Worker] Processing Sentry job', { + jobId, + projectId: jobData.projectId, + eventType: jobData.eventType, + hasTriggerResult: !!jobData.triggerResult, + }); + await processSentryWebhook( + jobData.payload, + jobData.projectId, + triggerRegistry, + jobData.triggerResult, + ); + break; case 'manual-run': case 'retry-run': case 'debug-analysis': diff --git a/tests/unit/triggers/sentry-webhook-handler.test.ts b/tests/unit/triggers/sentry-webhook-handler.test.ts new file mode 100644 index 00000000..1fb9d121 --- /dev/null +++ b/tests/unit/triggers/sentry-webhook-handler.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); + +vi.mock('../../../src/config/provider.js', () => ({ + loadProjectConfigById: vi.fn(), +})); + +vi.mock('../../../src/pm/context.js', () => ({ + withPMCredentials: vi.fn().mockImplementation((_id, _type, _getter, fn) => fn()), + withPMProvider: vi.fn().mockImplementation((_provider, fn) => fn()), +})); + +vi.mock('../../../src/pm/index.js', () => ({ + createPMProvider: vi.fn().mockReturnValue({}), + pmRegistry: { getOrNull: vi.fn().mockReturnValue(null) }, +})); + +vi.mock('../../../src/utils/lifecycle.js', () => ({ + startWatchdog: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/agent-execution.js', () => ({ + runAgentExecutionPipeline: vi.fn().mockResolvedValue(undefined), +})); + +import { loadProjectConfigById } from '../../../src/config/provider.js'; +import { withPMCredentials, withPMProvider } from '../../../src/pm/context.js'; +import { processSentryWebhook } from '../../../src/triggers/sentry/webhook-handler.js'; +import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; +import { createMockProject } from '../../helpers/factories.js'; + +const mockProject = createMockProject({ id: 'proj-sentry' }); + +describe('processSentryWebhook', () => { + let mockRegistry: { dispatch: ReturnType }; + + beforeEach(() => { + vi.resetAllMocks(); + mockRegistry = { dispatch: vi.fn().mockResolvedValue(null) }; + vi.mocked(loadProjectConfigById).mockResolvedValue({ + project: mockProject, + config: { projects: [mockProject] } as never, + }); + vi.mocked(runAgentExecutionPipeline).mockResolvedValue(undefined); + // Re-apply pass-through implementations after resetAllMocks clears them + vi.mocked(withPMCredentials).mockImplementation((_id, _type, _getter, fn) => fn()); + vi.mocked(withPMProvider).mockImplementation((_provider, fn) => fn()); + }); + + it('loads project config by projectId and dispatches with sentry source when no triggerResult', async () => { + const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, undefined); + + expect(loadProjectConfigById).toHaveBeenCalledWith('proj-sentry'); + expect(mockRegistry.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'sentry', + payload, + project: mockProject, + }), + ); + }); + + it('creates a TriggerContext with source sentry and the given payload', async () => { + const payload = { resource: 'metric_alert', cascadeProjectId: 'proj-sentry' }; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never); + + const dispatchCall = mockRegistry.dispatch.mock.calls[0][0]; + expect(dispatchCall.source).toBe('sentry'); + expect(dispatchCall.payload).toBe(payload); + expect(dispatchCall.project).toBe(mockProject); + }); + + it('logs a warning and returns without dispatching when project is not found', async () => { + vi.mocked(loadProjectConfigById).mockResolvedValue(undefined); + + const payload = { resource: 'event_alert' }; + await processSentryWebhook(payload, 'unknown-proj', mockRegistry as never); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('project not found'), + expect.objectContaining({ projectId: 'unknown-proj' }), + ); + expect(mockRegistry.dispatch).not.toHaveBeenCalled(); + }); + + it('does NOT call registry.dispatch when triggerResult is provided', async () => { + const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const triggerResult = { agentType: 'alerting', agentInput: {} } as never; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, triggerResult); + + expect(mockRegistry.dispatch).not.toHaveBeenCalled(); + }); + + it('logs info message when triggerResult is provided', async () => { + const payload = { resource: 'event_alert' }; + const triggerResult = { agentType: 'alerting', agentInput: {} } as never; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, triggerResult); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('pre-computed trigger result'), + expect.objectContaining({ projectId: 'proj-sentry', agentType: 'alerting' }), + ); + }); + + it('runs the agent execution pipeline when triggerResult has an agentType', async () => { + const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const triggerResult = { agentType: 'alerting', agentInput: {} } as never; + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never, triggerResult); + + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( + triggerResult, + mockProject, + expect.objectContaining({ projects: [mockProject] }), + expect.objectContaining({ logLabel: 'Sentry agent' }), + ); + }); + + it('does not run the agent when registry dispatch returns null', async () => { + const payload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + mockRegistry.dispatch.mockResolvedValue(null); + + await processSentryWebhook(payload, 'proj-sentry', mockRegistry as never); + + expect(runAgentExecutionPipeline).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts index 5553ff55..8d2bc352 100644 --- a/tests/unit/worker-entry.test.ts +++ b/tests/unit/worker-entry.test.ts @@ -36,6 +36,10 @@ vi.mock('../../src/triggers/trello/webhook-handler.js', () => ({ processTrelloWebhook: vi.fn().mockResolvedValue(undefined), })); +vi.mock('../../src/triggers/sentry/webhook-handler.js', () => ({ + processSentryWebhook: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/utils/index.js', () => ({ logger: { info: vi.fn(), @@ -81,6 +85,7 @@ import { loadProjectConfigById } from '../../src/config/provider.js'; import { getRunById } from '../../src/db/repositories/runsRepository.js'; import { captureException, flush } from '../../src/sentry.js'; import { processGitHubWebhook, processJiraWebhook } from '../../src/triggers/index.js'; +import { processSentryWebhook } from '../../src/triggers/sentry/webhook-handler.js'; import { triggerDebugAnalysis } from '../../src/triggers/shared/debug-runner.js'; import { triggerManualRun, triggerRetryRun } from '../../src/triggers/shared/manual-runner.js'; import { processTrelloWebhook } from '../../src/triggers/trello/webhook-handler.js'; @@ -90,6 +95,7 @@ import { type JiraJobData, type ManualRunJobData, type RetryRunJobData, + type SentryJobData, type TrelloJobData, dispatchJob, main, @@ -182,6 +188,31 @@ describe('dispatchJob routing', () => { ); }); + it('routes sentry job to processSentryWebhook with payload, projectId, registry, and triggerResult', async () => { + const mockRegistry = {}; + const jobPayload = { resource: 'event_alert', cascadeProjectId: 'proj-sentry' }; + const triggerResult = { matched: true, agentType: 'alerting' } as never; + + const jobData: SentryJobData = { + type: 'sentry', + source: 'sentry', + payload: jobPayload, + projectId: 'proj-sentry', + eventType: 'event_alert', + receivedAt: '2024-01-01T00:00:00Z', + triggerResult, + }; + + await dispatchJob('job-sentry-1', jobData, mockRegistry as never); + + expect(processSentryWebhook).toHaveBeenCalledWith( + jobPayload, + 'proj-sentry', + mockRegistry, + triggerResult, + ); + }); + it('routes manual-run job to processDashboardJob (calls triggerManualRun)', async () => { const mockProject = { id: 'proj-1', name: 'Test Project' }; const mockConfig = { projects: [mockProject] };