From 2f2f2844d3f1af33af4e439cb8a364a66281ad9f Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 21:55:54 +0000 Subject: [PATCH] feat(integrations): add unified IntegrationModule interface and IntegrationRegistry --- src/integrations/index.ts | 12 ++ src/integrations/registry.ts | 78 ++++++++++ src/integrations/types.ts | 87 +++++++++++ tests/unit/integrations/registry.test.ts | 188 +++++++++++++++++++++++ vitest.config.ts | 1 + 5 files changed, 366 insertions(+) create mode 100644 src/integrations/index.ts create mode 100644 src/integrations/registry.ts create mode 100644 src/integrations/types.ts create mode 100644 tests/unit/integrations/registry.test.ts diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 00000000..b893341e --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,12 @@ +/** + * Unified integration abstraction layer. + * + * Exports: + * - `IntegrationModule` interface — the category-agnostic contract all integrations implement + * - `IntegrationWebhookEvent` — normalized webhook event type + * - `IntegrationRegistry` class — registry for managing integration modules + * - `integrationRegistry` singleton — the shared registry instance + */ + +export type { IntegrationModule, IntegrationWebhookEvent } from './types.js'; +export { IntegrationRegistry, integrationRegistry } from './registry.js'; diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts new file mode 100644 index 00000000..d278689a --- /dev/null +++ b/src/integrations/registry.ts @@ -0,0 +1,78 @@ +/** + * IntegrationRegistry — singleton that holds all registered integration modules. + * + * Populated at import time by each integration module. Infrastructure + * (router, worker, webhook handler) uses `integrationRegistry.get(type)` to + * obtain the integration instance without provider-specific branching. + * + * Supports lookup by both provider type and integration category. + */ + +import type { IntegrationCategory } from '../config/integrationRoles.js'; +import type { IntegrationModule } from './types.js'; + +export class IntegrationRegistry { + private integrations = new Map(); + + /** + * Register an integration module. + * Throws if an integration with the same type is already registered. + */ + register(integration: IntegrationModule): void { + if (this.integrations.has(integration.type)) { + throw new Error( + `Integration type '${integration.type}' is already registered. Each provider type must be unique.`, + ); + } + this.integrations.set(integration.type, integration); + } + + /** + * Get an integration by provider type. + * Throws if the type is not registered. + */ + get(type: string): IntegrationModule { + const integration = this.integrations.get(type); + if (!integration) { + throw new Error( + `Unknown integration type: '${type}'. Registered: ${[...this.integrations.keys()].join(', ')}`, + ); + } + return integration; + } + + /** + * Get an integration by provider type, or null if not registered. + */ + getOrNull(type: string): IntegrationModule | null { + return this.integrations.get(type) ?? null; + } + + /** + * Get all integrations belonging to a specific category. + * Returns an empty array if no integrations are registered for that category. + */ + getByCategory(category: IntegrationCategory): IntegrationModule[] { + return [...this.integrations.values()].filter((i) => i.category === category); + } + + /** + * Get all registered integration modules. + */ + all(): IntegrationModule[] { + return [...this.integrations.values()]; + } + + /** + * Check if any integration registered for the given project has this integration configured. + * Delegates to the integration module's `hasIntegration()` method. + */ + async hasIntegration(type: string, projectId: string): Promise { + const integration = this.getOrNull(type); + if (!integration) return false; + return integration.hasIntegration(projectId); + } +} + +/** Singleton registry, populated at import time by each integration module */ +export const integrationRegistry = new IntegrationRegistry(); diff --git a/src/integrations/types.ts b/src/integrations/types.ts new file mode 100644 index 00000000..3cb94283 --- /dev/null +++ b/src/integrations/types.ts @@ -0,0 +1,87 @@ +/** + * IntegrationModule — the category-agnostic contract that every integration + * (PM, SCM, Alerting) must implement. + * + * This is the foundational interface for the unified integration abstraction + * layer. All integration types share this common contract, enabling the + * IntegrationRegistry to manage them without category-specific branching. + */ + +import type { IntegrationCategory } from '../config/integrationRoles.js'; +import type { CascadeConfig, ProjectConfig } from '../types/index.js'; + +/** + * Normalized webhook event — what the generic webhook handler operates on + * at the integration level. + */ +export interface IntegrationWebhookEvent { + /** Provider-specific event type (e.g. 'updateCard', 'jira:issue_updated', 'push') */ + eventType: string; + /** Provider-specific identifier for matching a project */ + projectIdentifier: string; + /** Work item ID when available */ + workItemId?: string; + /** Original payload, passed to trigger dispatch */ + raw: unknown; +} + +/** + * IntegrationModule — the unified interface all integrations must implement. + * + * Required methods: + * - `type`: Unique provider identifier (e.g. 'trello', 'github', 'sentry') + * - `category`: Which category this integration belongs to + * - `withCredentials()`: Run a function within the credential scope for a project + * - `hasIntegration()`: Check if a project has this integration configured with all required credentials + * + * Optional webhook methods are implemented by integrations that receive webhooks. + */ +export interface IntegrationModule { + /** Provider identifier — matches the string stored in project_integrations.provider */ + readonly type: string; + + /** Integration category — determines which capability group this belongs to */ + readonly category: IntegrationCategory; + + /** + * Resolve credentials from DB and run `fn` within the credential scope. + * Implementations should set the necessary env vars before calling `fn` + * and clean up afterwards. + */ + withCredentials(projectId: string, fn: () => Promise): Promise; + + /** + * Check if this integration is configured for a project. + * Returns true if all required credentials are present. + */ + hasIntegration(projectId: string): Promise; + + // --- Optional webhook methods --- + + /** + * Parse a raw webhook body into a normalized event, or null if irrelevant. + * Implemented by integrations that receive webhooks. + */ + parseWebhookPayload?(raw: unknown): IntegrationWebhookEvent | null; + + /** + * Check if a webhook event was authored by the integration's own bot account. + * Implemented by integrations that need to filter self-authored events. + */ + isSelfAuthored?(event: IntegrationWebhookEvent, projectId: string): Promise; + + /** + * Find the project config + cascade config from a webhook identifier. + * Implemented by integrations that need to route webhooks to projects. + */ + lookupProject?( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null>; + + /** + * Extract a work item ID from text (e.g. PR body). + * Returns null if not found. + * Implemented by integrations that support cross-referencing work items. + */ + extractWorkItemId?(text: string): string | null; +} diff --git a/tests/unit/integrations/registry.test.ts b/tests/unit/integrations/registry.test.ts new file mode 100644 index 00000000..18a4064b --- /dev/null +++ b/tests/unit/integrations/registry.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IntegrationRegistry } from '../../../src/integrations/registry.js'; +import type { IntegrationModule } from '../../../src/integrations/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeModule( + type: string, + category: 'pm' | 'scm' | 'alerting', + hasIntegrationResult = false, +): IntegrationModule { + return { + type, + category, + withCredentials: vi.fn((_projectId, fn) => fn()), + hasIntegration: vi.fn().mockResolvedValue(hasIntegrationResult), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('IntegrationRegistry', () => { + let registry: IntegrationRegistry; + + beforeEach(() => { + registry = new IntegrationRegistry(); + }); + + // ========================================================================= + // register + // ========================================================================= + describe('register', () => { + it('registers an integration without error', () => { + const module = makeModule('trello', 'pm'); + expect(() => registry.register(module)).not.toThrow(); + }); + + it('throws on duplicate registration for the same type', () => { + const module = makeModule('trello', 'pm'); + registry.register(module); + expect(() => registry.register(module)).toThrow( + "Integration type 'trello' is already registered", + ); + }); + + it('allows registering multiple integrations of different types', () => { + registry.register(makeModule('trello', 'pm')); + registry.register(makeModule('github', 'scm')); + registry.register(makeModule('sentry', 'alerting')); + expect(registry.all()).toHaveLength(3); + }); + }); + + // ========================================================================= + // get + // ========================================================================= + describe('get', () => { + it('returns the registered integration by type', () => { + const module = makeModule('trello', 'pm'); + registry.register(module); + expect(registry.get('trello')).toBe(module); + }); + + it('throws for an unknown provider type', () => { + expect(() => registry.get('unknown-provider')).toThrow( + "Unknown integration type: 'unknown-provider'", + ); + }); + + it('error message includes registered types', () => { + registry.register(makeModule('trello', 'pm')); + registry.register(makeModule('github', 'scm')); + expect(() => registry.get('sentry')).toThrow(/Registered: trello, github/); + }); + }); + + // ========================================================================= + // getOrNull + // ========================================================================= + describe('getOrNull', () => { + it('returns the integration when found', () => { + const module = makeModule('github', 'scm'); + registry.register(module); + expect(registry.getOrNull('github')).toBe(module); + }); + + it('returns null for an unregistered type', () => { + expect(registry.getOrNull('not-registered')).toBeNull(); + }); + }); + + // ========================================================================= + // getByCategory + // ========================================================================= + describe('getByCategory', () => { + beforeEach(() => { + registry.register(makeModule('trello', 'pm')); + registry.register(makeModule('jira', 'pm')); + registry.register(makeModule('github', 'scm')); + registry.register(makeModule('sentry', 'alerting')); + }); + + it('returns all PM integrations', () => { + const pmIntegrations = registry.getByCategory('pm'); + expect(pmIntegrations).toHaveLength(2); + expect(pmIntegrations.map((i) => i.type)).toEqual(expect.arrayContaining(['trello', 'jira'])); + }); + + it('returns all SCM integrations', () => { + const scmIntegrations = registry.getByCategory('scm'); + expect(scmIntegrations).toHaveLength(1); + expect(scmIntegrations[0].type).toBe('github'); + }); + + it('returns all alerting integrations', () => { + const alertingIntegrations = registry.getByCategory('alerting'); + expect(alertingIntegrations).toHaveLength(1); + expect(alertingIntegrations[0].type).toBe('sentry'); + }); + + it('returns empty array when no integrations match the category', () => { + const emptyRegistry = new IntegrationRegistry(); + expect(emptyRegistry.getByCategory('pm')).toEqual([]); + }); + }); + + // ========================================================================= + // all + // ========================================================================= + describe('all', () => { + it('returns empty array when no integrations are registered', () => { + expect(registry.all()).toEqual([]); + }); + + it('returns all registered integrations', () => { + const trello = makeModule('trello', 'pm'); + const github = makeModule('github', 'scm'); + registry.register(trello); + registry.register(github); + + const all = registry.all(); + expect(all).toHaveLength(2); + expect(all).toEqual(expect.arrayContaining([trello, github])); + }); + }); + + // ========================================================================= + // hasIntegration + // ========================================================================= + describe('hasIntegration', () => { + it('returns false when the integration type is not registered', async () => { + const result = await registry.hasIntegration('unknown', 'proj-1'); + expect(result).toBe(false); + }); + + it('delegates to the module hasIntegration() when integration is registered and returns true', async () => { + const module = makeModule('trello', 'pm', true); + registry.register(module); + + const result = await registry.hasIntegration('trello', 'proj-1'); + expect(result).toBe(true); + expect(module.hasIntegration).toHaveBeenCalledWith('proj-1'); + }); + + it('delegates to the module hasIntegration() when integration is registered and returns false', async () => { + const module = makeModule('github', 'scm', false); + registry.register(module); + + const result = await registry.hasIntegration('github', 'proj-2'); + expect(result).toBe(false); + expect(module.hasIntegration).toHaveBeenCalledWith('proj-2'); + }); + + it('passes the correct projectId to the module', async () => { + const module = makeModule('sentry', 'alerting', true); + registry.register(module); + + await registry.hasIntegration('sentry', 'my-project-id'); + + expect(module.hasIntegration).toHaveBeenCalledWith('my-project-id'); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ac0ad67e..2e17c567 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -114,6 +114,7 @@ export default defineConfig({ 'tests/unit/utils/**/*.test.ts', 'tests/unit/cli/**/*.test.ts', 'tests/unit/pm/**/*.test.ts', + 'tests/unit/integrations/**/*.test.ts', 'tests/unit/github/**/*.test.ts', 'tests/unit/jira/**/*.test.ts', 'tests/unit/trello/**/*.test.ts',