diff --git a/src/integrations/alerting.ts b/src/integrations/alerting.ts new file mode 100644 index 00000000..deb8e57c --- /dev/null +++ b/src/integrations/alerting.ts @@ -0,0 +1,30 @@ +/** + * AlertingIntegration — the category-specific interface all alerting integrations implement. + * + * Extends IntegrationModule with alerting-specific capabilities: + * - `category` is narrowed to 'alerting' + * - `getConfig()` retrieves the alerting provider config for a project + */ + +import type { SentryIntegrationConfig } from '../sentry/integration.js'; +import type { IntegrationModule } from './types.js'; + +/** + * AlertingIntegration — extends IntegrationModule with alerting-specific capabilities. + * + * All alerting integrations (e.g. Sentry) must implement this interface. + * The `category` is narrowed to 'alerting' to allow type-safe filtering. + */ +export interface AlertingIntegration extends IntegrationModule { + /** Narrowed category — always 'alerting' for alerting integrations */ + readonly category: 'alerting'; + + /** + * Get the alerting provider config for a project. + * Returns null if no alerting integration is configured. + * + * @param projectId - The project to retrieve config for + * @returns The alerting config or null if not configured + */ + getConfig(projectId: string): Promise; +} diff --git a/src/integrations/index.ts b/src/integrations/index.ts index cdec6130..f264ce17 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -5,10 +5,12 @@ * - `IntegrationModule` interface — the category-agnostic contract all integrations implement * - `IntegrationWebhookEvent` — normalized webhook event type * - `SCMIntegration` interface — SCM-specific extension of IntegrationModule + * - `AlertingIntegration` interface — alerting-specific extension of IntegrationModule * - `IntegrationRegistry` class — registry for managing integration modules * - `integrationRegistry` singleton — the shared registry instance */ export type { IntegrationModule, IntegrationWebhookEvent } from './types.js'; export type { SCMIntegration } from './scm.js'; +export type { AlertingIntegration } from './alerting.js'; export { IntegrationRegistry, integrationRegistry } from './registry.js'; diff --git a/src/pm/bootstrap.ts b/src/pm/bootstrap.ts index 5b15194d..df435a47 100644 --- a/src/pm/bootstrap.ts +++ b/src/pm/bootstrap.ts @@ -20,6 +20,7 @@ import { GitHubSCMIntegration } from '../github/scm-integration.js'; import { integrationRegistry } from '../integrations/registry.js'; +import { SentryAlertingIntegration } from '../sentry/alerting-integration.js'; import { JiraIntegration } from './jira/integration.js'; import { pmRegistry } from './registry.js'; import { TrelloIntegration } from './trello/integration.js'; @@ -37,3 +38,6 @@ if (!pmRegistry.getOrNull('jira')) { if (!integrationRegistry.getOrNull('github')) { integrationRegistry.register(new GitHubSCMIntegration()); } +if (!integrationRegistry.getOrNull('sentry')) { + integrationRegistry.register(new SentryAlertingIntegration()); +} diff --git a/src/sentry/alerting-integration.ts b/src/sentry/alerting-integration.ts new file mode 100644 index 00000000..ba2c5bb8 --- /dev/null +++ b/src/sentry/alerting-integration.ts @@ -0,0 +1,62 @@ +/** + * SentryAlertingIntegration — implements AlertingIntegration for Sentry. + * + * Encapsulates Sentry alerting credential resolution and validation + * into a unified integration class following the IntegrationModule pattern. + * + * Consolidates: + * - `getSentryIntegrationConfig()` logic from src/sentry/integration.ts + * - `hasAlertingIntegration()` logic from src/sentry/integration.ts + * + * Backward compatibility: the standalone functions in src/sentry/integration.ts + * remain exported and continue to work identically. + */ + +import { getIntegrationCredential } from '../config/provider.js'; +import type { AlertingIntegration } from '../integrations/alerting.js'; +import { + type SentryIntegrationConfig, + getSentryIntegrationConfig, + hasAlertingIntegration, +} from './integration.js'; + +export class SentryAlertingIntegration implements AlertingIntegration { + readonly type = 'sentry'; + readonly category = 'alerting' as const; + + /** + * Check if Sentry alerting integration is configured for a project. + * Delegates to existing hasAlertingIntegration() logic. + */ + async hasIntegration(projectId: string): Promise { + return hasAlertingIntegration(projectId); + } + + /** + * Get the Sentry integration config for a project. + * Delegates to existing getSentryIntegrationConfig() logic. + */ + async getConfig(projectId: string): Promise { + return getSentryIntegrationConfig(projectId); + } + + /** + * Resolve SENTRY_API_TOKEN from credentials and run `fn` within that + * credential scope. Sets process.env.SENTRY_API_TOKEN before calling fn + * and restores the previous value afterwards. + */ + async withCredentials(projectId: string, fn: () => Promise): Promise { + const token = await getIntegrationCredential(projectId, 'alerting', 'api_token'); + const previous = process.env.SENTRY_API_TOKEN; + process.env.SENTRY_API_TOKEN = token; + try { + return await fn(); + } finally { + if (previous === undefined) { + process.env.SENTRY_API_TOKEN = undefined; + } else { + process.env.SENTRY_API_TOKEN = previous; + } + } + } +} diff --git a/tests/unit/sentry/alerting-integration.test.ts b/tests/unit/sentry/alerting-integration.test.ts new file mode 100644 index 00000000..7cfd7d31 --- /dev/null +++ b/tests/unit/sentry/alerting-integration.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetIntegrationCredential = vi.fn(); + +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args), +})); + +const mockGetSentryIntegrationConfig = vi.fn(); +const mockHasAlertingIntegration = vi.fn(); + +vi.mock('../../../src/sentry/integration.js', () => ({ + getSentryIntegrationConfig: (...args: unknown[]) => mockGetSentryIntegrationConfig(...args), + hasAlertingIntegration: (...args: unknown[]) => mockHasAlertingIntegration(...args), +})); + +import { SentryAlertingIntegration } from '../../../src/sentry/alerting-integration.js'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SentryAlertingIntegration', () => { + let integration: SentryAlertingIntegration; + + beforeEach(() => { + integration = new SentryAlertingIntegration(); + vi.clearAllMocks(); + }); + + // ========================================================================= + // Metadata + // ========================================================================= + describe('metadata', () => { + it('has type "sentry"', () => { + expect(integration.type).toBe('sentry'); + }); + + it('has category "alerting"', () => { + expect(integration.category).toBe('alerting'); + }); + }); + + // ========================================================================= + // hasIntegration + // ========================================================================= + describe('hasIntegration', () => { + it('returns true when sentry integration is configured', async () => { + mockHasAlertingIntegration.mockResolvedValue(true); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + expect(mockHasAlertingIntegration).toHaveBeenCalledWith('proj-1'); + }); + + it('returns false when sentry integration is not configured', async () => { + mockHasAlertingIntegration.mockResolvedValue(false); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + expect(mockHasAlertingIntegration).toHaveBeenCalledWith('proj-1'); + }); + + it('delegates to hasAlertingIntegration() with the correct projectId', async () => { + mockHasAlertingIntegration.mockResolvedValue(true); + + await integration.hasIntegration('my-project-id'); + + expect(mockHasAlertingIntegration).toHaveBeenCalledWith('my-project-id'); + }); + }); + + // ========================================================================= + // getConfig + // ========================================================================= + describe('getConfig', () => { + it('returns SentryIntegrationConfig when sentry integration is configured', async () => { + const config = { organizationSlug: 'my-company' }; + mockGetSentryIntegrationConfig.mockResolvedValue(config); + + const result = await integration.getConfig('proj-1'); + + expect(result).toEqual({ organizationSlug: 'my-company' }); + expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('proj-1'); + }); + + it('returns null when sentry integration is not configured', async () => { + mockGetSentryIntegrationConfig.mockResolvedValue(null); + + const result = await integration.getConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('delegates to getSentryIntegrationConfig() with the correct projectId', async () => { + mockGetSentryIntegrationConfig.mockResolvedValue(null); + + await integration.getConfig('specific-proj-id'); + + expect(mockGetSentryIntegrationConfig).toHaveBeenCalledWith('specific-proj-id'); + }); + }); + + // ========================================================================= + // withCredentials + // ========================================================================= + describe('withCredentials', () => { + it('resolves SENTRY_API_TOKEN from credentials and sets it in process.env', async () => { + mockGetIntegrationCredential.mockResolvedValue('sentry-token-123'); + const fn = vi.fn().mockResolvedValue('result'); + + await integration.withCredentials('proj-1', fn); + + expect(mockGetIntegrationCredential).toHaveBeenCalledWith('proj-1', 'alerting', 'api_token'); + expect(fn).toHaveBeenCalled(); + }); + + it('returns the value returned by fn', async () => { + mockGetIntegrationCredential.mockResolvedValue('sentry-token-123'); + const fn = vi.fn().mockResolvedValue({ data: 42 }); + + const result = await integration.withCredentials('proj-1', fn); + + expect(result).toEqual({ data: 42 }); + }); + + it('sets SENTRY_API_TOKEN in process.env before calling fn', async () => { + const token = 'test-sentry-token'; + mockGetIntegrationCredential.mockResolvedValue(token); + + let capturedToken: string | undefined; + const fn = vi.fn().mockImplementation(async () => { + capturedToken = process.env.SENTRY_API_TOKEN; + return 'ok'; + }); + + await integration.withCredentials('proj-1', fn); + + expect(capturedToken).toBe(token); + }); + + it('restores the previous SENTRY_API_TOKEN after fn completes', async () => { + const previousToken = 'previous-token'; + process.env.SENTRY_API_TOKEN = previousToken; + + mockGetIntegrationCredential.mockResolvedValue('new-sentry-token'); + const fn = vi.fn().mockResolvedValue('result'); + + await integration.withCredentials('proj-1', fn); + + expect(process.env.SENTRY_API_TOKEN).toBe(previousToken); + + // Cleanup + process.env.SENTRY_API_TOKEN = undefined; + }); + + it('clears SENTRY_API_TOKEN from process.env when it was not set before', async () => { + // Ensure the env var is not set (following codebase pattern) + process.env.SENTRY_API_TOKEN = undefined; + const previousState = process.env.SENTRY_API_TOKEN; + + mockGetIntegrationCredential.mockResolvedValue('sentry-token-123'); + const fn = vi.fn().mockResolvedValue('result'); + + await integration.withCredentials('proj-1', fn); + + // After withCredentials, env var should be restored to its pre-call state + expect(process.env.SENTRY_API_TOKEN).toBe(previousState); + }); + + it('restores SENTRY_API_TOKEN after fn throws', async () => { + const previousToken = 'previous-token'; + process.env.SENTRY_API_TOKEN = previousToken; + + mockGetIntegrationCredential.mockResolvedValue('new-sentry-token'); + const fn = vi.fn().mockRejectedValue(new Error('API error')); + + await expect(integration.withCredentials('proj-1', fn)).rejects.toThrow('API error'); + + expect(process.env.SENTRY_API_TOKEN).toBe(previousToken); + + // Cleanup + process.env.SENTRY_API_TOKEN = undefined; + }); + + it('propagates errors from credential resolution without setting env', async () => { + process.env.SENTRY_API_TOKEN = undefined; + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); + + const fn = vi.fn(); + + await expect(integration.withCredentials('proj-1', fn)).rejects.toThrow( + 'Credential not found', + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); +});