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
30 changes: 30 additions & 0 deletions src/integrations/alerting.ts
Original file line number Diff line number Diff line change
@@ -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<SentryIntegrationConfig | null>;
}
2 changes: 2 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions src/pm/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,3 +38,6 @@ if (!pmRegistry.getOrNull('jira')) {
if (!integrationRegistry.getOrNull('github')) {
integrationRegistry.register(new GitHubSCMIntegration());
}
if (!integrationRegistry.getOrNull('sentry')) {
integrationRegistry.register(new SentryAlertingIntegration());
}
62 changes: 62 additions & 0 deletions src/sentry/alerting-integration.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return hasAlertingIntegration(projectId);
}

/**
* Get the Sentry integration config for a project.
* Delegates to existing getSentryIntegrationConfig() logic.
*/
async getConfig(projectId: string): Promise<SentryIntegrationConfig | null> {
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<T>(projectId: string, fn: () => Promise<T>): Promise<T> {
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;
}
}
}
}
204 changes: 204 additions & 0 deletions tests/unit/sentry/alerting-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading