From 24b62aa8443365ec6ed67071f9a82b25e2a2b110 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 22:33:47 +0000 Subject: [PATCH 1/2] feat(sentry): add worker-side Sentry webhook job dispatch --- src/triggers/sentry/webhook-handler.ts | 41 +++++++++ src/worker-entry.ts | 33 ++++++- .../triggers/sentry-webhook-handler.test.ts | 87 +++++++++++++++++++ tests/unit/worker-entry.test.ts | 31 +++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/triggers/sentry/webhook-handler.ts create mode 100644 tests/unit/triggers/sentry-webhook-handler.test.ts diff --git a/src/triggers/sentry/webhook-handler.ts b/src/triggers/sentry/webhook-handler.ts new file mode 100644 index 00000000..ad9adaed --- /dev/null +++ b/src/triggers/sentry/webhook-handler.ts @@ -0,0 +1,41 @@ +/** + * Sentry webhook handler. + * + * Thin wrapper that creates a TriggerContext and dispatches + * through the trigger registry (which has SentryIssueAlertTrigger + * and SentryMetricAlertTrigger registered). + */ + +import type { TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerRegistry } from '../registry.js'; + +export async function processSentryWebhook( + payload: unknown, + projectId: string, + registry: TriggerRegistry, + triggerResult?: TriggerResult, +): Promise { + if (triggerResult) { + logger.debug('processSentryWebhook: using pre-computed trigger result', { + projectId, + agentType: triggerResult.agentType, + }); + } + + const { loadProjectConfigById } = await import('../../config/provider.js'); + + const pc = await loadProjectConfigById(projectId); + if (!pc) { + logger.warn('processSentryWebhook: project not found, skipping', { projectId }); + return; + } + + const ctx = { + project: pc.project, + source: 'sentry' as const, + payload, + }; + + await registry.dispatch(ctx); +} diff --git a/src/worker-entry.ts b/src/worker-entry.ts index ade73698..054c5b93 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' */ + 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..1b335ee4 --- /dev/null +++ b/tests/unit/triggers/sentry-webhook-handler.test.ts @@ -0,0 +1,87 @@ +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(), +})); + +import { loadProjectConfigById } from '../../../src/config/provider.js'; +import { processSentryWebhook } from '../../../src/triggers/sentry/webhook-handler.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, + }); + }); + + it('loads project config by projectId and dispatches with sentry source', 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('dispatches even when triggerResult is provided (pre-computed result is logged, not used to skip dispatch)', 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).toHaveBeenCalled(); + }); + + it('logs debug 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.debug).toHaveBeenCalledWith( + expect.stringContaining('pre-computed trigger result'), + expect.objectContaining({ projectId: 'proj-sentry', agentType: 'alerting' }), + ); + }); +}); 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] }; From 5c8a806ad13e7fdea1bc1ab5b07f26286f58887b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 22:58:24 +0000 Subject: [PATCH 2/2] fix(sentry): use pre-computed triggerResult and run agent in processSentryWebhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Short-circuit registry.dispatch when triggerResult is already provided, matching the router→worker contract followed by Trello, JIRA, and GitHub handlers - After resolving the trigger result, call runAgentExecutionPipeline to actually execute the matched agent (fixes silent discard of matched triggers) - Fix SentryJobData JSDoc comment: add 'issue' to eventType union to match router-side SentryJob type in src/router/queue.ts - Update tests: assert registry.dispatch is NOT called when triggerResult is provided; add coverage for agent execution pipeline call Co-Authored-By: Claude Opus 4.6 --- src/triggers/sentry/webhook-handler.ts | 69 +++++++++++++++---- src/worker-entry.ts | 2 +- .../triggers/sentry-webhook-handler.test.ts | 57 +++++++++++++-- 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/triggers/sentry/webhook-handler.ts b/src/triggers/sentry/webhook-handler.ts index ad9adaed..9bb3ec98 100644 --- a/src/triggers/sentry/webhook-handler.ts +++ b/src/triggers/sentry/webhook-handler.ts @@ -1,14 +1,19 @@ /** * Sentry webhook handler. * - * Thin wrapper that creates a TriggerContext and dispatches - * through the trigger registry (which has SentryIssueAlertTrigger - * and SentryMetricAlertTrigger registered). + * 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, @@ -16,26 +21,62 @@ export async function processSentryWebhook( 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.debug('processSentryWebhook: using pre-computed trigger result', { + 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); } - const { loadProjectConfigById } = await import('../../config/provider.js'); + if (!result) { + logger.info('processSentryWebhook: no trigger matched', { projectId }); + return; + } - const pc = await loadProjectConfigById(projectId); - if (!pc) { - logger.warn('processSentryWebhook: project not found, skipping', { projectId }); + if (!result.agentType) { + logger.info('processSentryWebhook: trigger matched but no agent type, skipping', { + projectId, + }); return; } - const ctx = { - project: pc.project, - source: 'sentry' as const, - payload, - }; + logger.info('processSentryWebhook: running agent', { + projectId, + agentType: result.agentType, + }); + + startWatchdog(pc.project.watchdogTimeoutMs); - await registry.dispatch(ctx); + 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 054c5b93..a8758c64 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -72,7 +72,7 @@ export interface SentryJobData { source: 'sentry'; payload: unknown; projectId: string; - /** Sentry resource type: 'event_alert' | 'metric_alert' */ + /** Sentry resource type: 'event_alert' | 'metric_alert' | 'issue' */ eventType: string; receivedAt: string; triggerResult?: TriggerResult; diff --git a/tests/unit/triggers/sentry-webhook-handler.test.ts b/tests/unit/triggers/sentry-webhook-handler.test.ts index 1b335ee4..1fb9d121 100644 --- a/tests/unit/triggers/sentry-webhook-handler.test.ts +++ b/tests/unit/triggers/sentry-webhook-handler.test.ts @@ -7,8 +7,28 @@ 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' }); @@ -23,9 +43,13 @@ describe('processSentryWebhook', () => { 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', async () => { + 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); @@ -64,24 +88,47 @@ describe('processSentryWebhook', () => { expect(mockRegistry.dispatch).not.toHaveBeenCalled(); }); - it('dispatches even when triggerResult is provided (pre-computed result is logged, not used to skip dispatch)', async () => { + 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).toHaveBeenCalled(); + expect(mockRegistry.dispatch).not.toHaveBeenCalled(); }); - it('logs debug message when triggerResult is provided', async () => { + 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.debug).toHaveBeenCalledWith( + 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(); + }); });