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
12 changes: 12 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -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';
78 changes: 78 additions & 0 deletions src/integrations/registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, IntegrationModule>();

/**
* 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<boolean> {
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();
87 changes: 87 additions & 0 deletions src/integrations/types.ts
Original file line number Diff line number Diff line change
@@ -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<T>(projectId: string, fn: () => Promise<T>): Promise<T>;

/**
* Check if this integration is configured for a project.
* Returns true if all required credentials are present.
*/
hasIntegration(projectId: string): Promise<boolean>;

// --- 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<boolean>;

/**
* 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;
}
188 changes: 188 additions & 0 deletions tests/unit/integrations/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading