Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/triggers/sentry/webhook-handler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
}),
),
);
}
33 changes: 32 additions & 1 deletion src/worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
const { loadProjectConfigById } = await import('./config/provider.js');
Expand Down Expand Up @@ -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':
Expand Down
134 changes: 134 additions & 0 deletions tests/unit/triggers/sentry-webhook-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> };

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();
});
});
31 changes: 31 additions & 0 deletions tests/unit/worker-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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';
Expand All @@ -90,6 +95,7 @@ import {
type JiraJobData,
type ManualRunJobData,
type RetryRunJobData,
type SentryJobData,
type TrelloJobData,
dispatchJob,
main,
Expand Down Expand Up @@ -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] };
Expand Down
Loading