diff --git a/src/pm/bootstrap.ts b/src/pm/bootstrap.ts index 342b3466..18a92f4d 100644 --- a/src/pm/bootstrap.ts +++ b/src/pm/bootstrap.ts @@ -14,9 +14,18 @@ * 2. Registering it here. */ +import { integrationRegistry } from '../integrations/registry.js'; import { JiraIntegration } from './jira/integration.js'; import { pmRegistry } from './registry.js'; import { TrelloIntegration } from './trello/integration.js'; -if (!pmRegistry.getOrNull('trello')) pmRegistry.register(new TrelloIntegration()); -if (!pmRegistry.getOrNull('jira')) pmRegistry.register(new JiraIntegration()); +if (!pmRegistry.getOrNull('trello')) { + const trello = new TrelloIntegration(); + pmRegistry.register(trello); + if (!integrationRegistry.getOrNull('trello')) integrationRegistry.register(trello); +} +if (!pmRegistry.getOrNull('jira')) { + const jira = new JiraIntegration(); + pmRegistry.register(jira); + if (!integrationRegistry.getOrNull('jira')) integrationRegistry.register(jira); +} diff --git a/src/pm/index.ts b/src/pm/index.ts index 8c2a578b..13eab619 100644 --- a/src/pm/index.ts +++ b/src/pm/index.ts @@ -31,14 +31,21 @@ export { hasPmIntegration } from './integration.js'; export { pmRegistry } from './registry.js'; export { processPMWebhook } from './webhook-handler.js'; +import { integrationRegistry } from '../integrations/registry.js'; import type { ProjectConfig } from '../types/index.js'; import { JiraIntegration } from './jira/integration.js'; import { pmRegistry } from './registry.js'; // Register built-in integrations at import time import { TrelloIntegration } from './trello/integration.js'; import type { PMProvider } from './types.js'; -pmRegistry.register(new TrelloIntegration()); -pmRegistry.register(new JiraIntegration()); + +const trelloIntegration = new TrelloIntegration(); +pmRegistry.register(trelloIntegration); +if (!integrationRegistry.getOrNull('trello')) integrationRegistry.register(trelloIntegration); + +const jiraIntegration = new JiraIntegration(); +pmRegistry.register(jiraIntegration); +if (!integrationRegistry.getOrNull('jira')) integrationRegistry.register(jiraIntegration); export function createPMProvider(project: ProjectConfig): PMProvider { return pmRegistry.createProvider(project); diff --git a/src/pm/integration.ts b/src/pm/integration.ts index fedf0bb8..8c450cb0 100644 --- a/src/pm/integration.ts +++ b/src/pm/integration.ts @@ -7,11 +7,14 @@ * interface as a single self-contained class. Generic infrastructure (router, * webhook handler, lifecycle manager) consumes the interface without * provider-specific branching. + * + * Extends IntegrationModule so PM providers participate in the unified registry. */ import { PROVIDER_CREDENTIAL_ROLES } from '../config/integrationRoles.js'; import { getIntegrationCredentialOrNull } from '../config/provider.js'; import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; +import type { IntegrationModule } from '../integrations/types.js'; import type { AgentExecutionConfig } from '../triggers/shared/agent-execution.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; import type { ProjectPMConfig } from './lifecycle.js'; @@ -31,10 +34,19 @@ export interface PMWebhookEvent { raw: unknown; } -export interface PMIntegration { +export interface PMIntegration extends IntegrationModule { /** Provider identifier — matches the string stored in project_integrations.provider */ readonly type: string; + /** Integration category — always 'pm' for PM providers */ + readonly category: 'pm'; + + /** + * Check if this PM integration is configured for a project. + * Returns true if all required credentials are present. + */ + hasIntegration(projectId: string): Promise; + // --- Data operations --- /** Create a PMProvider instance from the project config */ createProvider(project: ProjectConfig): PMProvider; diff --git a/src/pm/jira/integration.ts b/src/pm/jira/integration.ts index e0595921..232e7fc5 100644 --- a/src/pm/jira/integration.ts +++ b/src/pm/jira/integration.ts @@ -9,11 +9,14 @@ * and router/reactions.ts. */ +import { PROVIDER_CREDENTIAL_ROLES } from '../../config/integrationRoles.js'; import { findProjectById, getIntegrationCredential, + getIntegrationCredentialOrNull, loadProjectConfigByJiraProjectKey, } from '../../config/provider.js'; +import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js'; import { withJiraCredentials } from '../../jira/client.js'; import { deleteJiraAck, @@ -33,6 +36,19 @@ const JIRA_ISSUE_KEY_REGEX = /\b([A-Z][A-Z0-9]+-\d+)\b/; export class JiraIntegration implements PMIntegration { readonly type = 'jira'; + readonly category = 'pm' as const; + + async hasIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'pm'); + if (provider !== 'jira') return false; + + const roles = PROVIDER_CREDENTIAL_ROLES.jira; + const requiredRoles = roles.filter((r) => !r.optional); + const values = await Promise.all( + requiredRoles.map((roleDef) => getIntegrationCredentialOrNull(projectId, 'pm', roleDef.role)), + ); + return values.every((v) => v !== null); + } createProvider(project: ProjectConfig): PMProvider { const jiraConfig = getJiraConfig(project); diff --git a/src/pm/trello/integration.ts b/src/pm/trello/integration.ts index 182fd2bd..f739b22b 100644 --- a/src/pm/trello/integration.ts +++ b/src/pm/trello/integration.ts @@ -9,7 +9,13 @@ * and router/reactions.ts. */ -import { getIntegrationCredential, loadProjectConfigByBoardId } from '../../config/provider.js'; +import { PROVIDER_CREDENTIAL_ROLES } from '../../config/integrationRoles.js'; +import { + getIntegrationCredential, + getIntegrationCredentialOrNull, + loadProjectConfigByBoardId, +} from '../../config/provider.js'; +import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js'; import { deleteTrelloAck, postTrelloAck, @@ -26,6 +32,19 @@ import { TrelloPMProvider } from './adapter.js'; export class TrelloIntegration implements PMIntegration { readonly type = 'trello'; + readonly category = 'pm' as const; + + async hasIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'pm'); + if (provider !== 'trello') return false; + + const roles = PROVIDER_CREDENTIAL_ROLES.trello; + const requiredRoles = roles.filter((r) => !r.optional); + const values = await Promise.all( + requiredRoles.map((roleDef) => getIntegrationCredentialOrNull(projectId, 'pm', roleDef.role)), + ); + return values.every((v) => v !== null); + } createProvider(_project: ProjectConfig): PMProvider { return new TrelloPMProvider(); diff --git a/src/triggers/github/integration.ts b/src/triggers/github/integration.ts index bbb1a39b..e9f6c899 100644 --- a/src/triggers/github/integration.ts +++ b/src/triggers/github/integration.ts @@ -21,6 +21,14 @@ import { deleteProgressCommentOnSuccess, updateInitialCommentWithError } from '. export class GitHubWebhookIntegration implements PMIntegration { readonly type = 'github'; + readonly category = 'pm' as const; + + async hasIntegration(_projectId: string): Promise { + // GitHubWebhookIntegration is a PM-pipeline adapter for GitHub webhooks, + // not a real PM provider. It is not registered in the integration registry + // and does not have PM credentials to check. + return false; + } createProvider(_project: ProjectConfig): PMProvider { // GitHub doesn't use a PM provider — returning a minimal no-op. diff --git a/tests/unit/pm/jira/integration.test.ts b/tests/unit/pm/jira/integration.test.ts index 83dea279..413d12a0 100644 --- a/tests/unit/pm/jira/integration.test.ts +++ b/tests/unit/pm/jira/integration.test.ts @@ -5,16 +5,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- const mockGetIntegrationCredential = vi.fn(); +const mockGetIntegrationCredentialOrNull = vi.fn(); const mockFindProjectById = vi.fn(); const mockLoadProjectConfigByJiraProjectKey = vi.fn(); vi.mock('../../../../src/config/provider.js', () => ({ getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args), + getIntegrationCredentialOrNull: (...args: unknown[]) => + mockGetIntegrationCredentialOrNull(...args), findProjectById: (...args: unknown[]) => mockFindProjectById(...args), loadProjectConfigByJiraProjectKey: (...args: unknown[]) => mockLoadProjectConfigByJiraProjectKey(...args), })); +const mockGetIntegrationProvider = vi.fn(); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: (...args: unknown[]) => mockGetIntegrationProvider(...args), +})); + const mockWithJiraCredentials = vi.fn().mockImplementation((_creds, fn) => fn()); vi.mock('../../../../src/jira/client.js', () => ({ withJiraCredentials: (...args: unknown[]) => mockWithJiraCredentials(...args), @@ -103,6 +111,81 @@ describe('JiraIntegration', () => { expect(integration.type).toBe('jira'); }); + it('has category "pm"', () => { + expect(integration.category).toBe('pm'); + }); + + // ========================================================================= + // hasIntegration + // ========================================================================= + describe('hasIntegration', () => { + it('returns false when PM provider is not jira', async () => { + mockGetIntegrationProvider.mockResolvedValue(null); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + expect(mockGetIntegrationCredentialOrNull).not.toHaveBeenCalled(); + }); + + it('returns false when PM provider is trello (not jira)', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns true when provider is jira and all required credentials are present', async () => { + mockGetIntegrationProvider.mockResolvedValue('jira'); + // JIRA required roles: email, api_token (webhook_secret is optional) + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('bot@example.com') // email + .mockResolvedValueOnce('api-token-xxx'); // api_token + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + }); + + it('returns false when email is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('jira'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce(null) // email missing + .mockResolvedValueOnce('api-token-xxx'); // api_token present + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns false when api_token is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('jira'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('bot@example.com') // email present + .mockResolvedValueOnce(null); // api_token missing + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('checks for pm category credentials (email, api_token) — not optional webhook_secret', async () => { + mockGetIntegrationProvider.mockResolvedValue('jira'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('bot@example.com') + .mockResolvedValueOnce('api-token-xxx'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + // Only 2 required credentials checked (email, api_token), not webhook_secret + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledTimes(2); + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith('proj-1', 'pm', 'email'); + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith('proj-1', 'pm', 'api_token'); + }); + }); + // ========================================================================= // createProvider // ========================================================================= diff --git a/tests/unit/pm/trello/integration.test.ts b/tests/unit/pm/trello/integration.test.ts index f90f5469..13f352b1 100644 --- a/tests/unit/pm/trello/integration.test.ts +++ b/tests/unit/pm/trello/integration.test.ts @@ -5,13 +5,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- const mockGetIntegrationCredential = vi.fn(); +const mockGetIntegrationCredentialOrNull = vi.fn(); const mockLoadProjectConfigByBoardId = vi.fn(); vi.mock('../../../../src/config/provider.js', () => ({ getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args), + getIntegrationCredentialOrNull: (...args: unknown[]) => + mockGetIntegrationCredentialOrNull(...args), loadProjectConfigByBoardId: (...args: unknown[]) => mockLoadProjectConfigByBoardId(...args), })); +const mockGetIntegrationProvider = vi.fn(); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: (...args: unknown[]) => mockGetIntegrationProvider(...args), +})); + const mockWithTrelloCredentials = vi.fn().mockImplementation((_creds, fn) => fn()); vi.mock('../../../../src/trello/client.js', () => ({ withTrelloCredentials: (...args: unknown[]) => mockWithTrelloCredentials(...args), @@ -90,6 +98,81 @@ describe('TrelloIntegration', () => { expect(integration.type).toBe('trello'); }); + it('has category "pm"', () => { + expect(integration.category).toBe('pm'); + }); + + // ========================================================================= + // hasIntegration + // ========================================================================= + describe('hasIntegration', () => { + it('returns false when PM provider is not trello', async () => { + mockGetIntegrationProvider.mockResolvedValue(null); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + expect(mockGetIntegrationCredentialOrNull).not.toHaveBeenCalled(); + }); + + it('returns false when PM provider is jira (not trello)', async () => { + mockGetIntegrationProvider.mockResolvedValue('jira'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns true when provider is trello and all required credentials are present', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + // Trello required roles: api_key, token (api_secret is optional) + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('my-api-key') // api_key + .mockResolvedValueOnce('my-token'); // token + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + }); + + it('returns false when api_key is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce(null) // api_key missing + .mockResolvedValueOnce('my-token'); // token present + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns false when token is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('my-api-key') // api_key present + .mockResolvedValueOnce(null); // token missing + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('checks for pm category credentials (api_key, token) — not optional api_secret', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + mockGetIntegrationCredentialOrNull + .mockResolvedValueOnce('my-api-key') + .mockResolvedValueOnce('my-token'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + // Only 2 required credentials checked (api_key, token), not api_secret + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledTimes(2); + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith('proj-1', 'pm', 'api_key'); + expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith('proj-1', 'pm', 'token'); + }); + }); + // ========================================================================= // createProvider // =========================================================================