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
13 changes: 11 additions & 2 deletions src/pm/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 9 additions & 2 deletions src/pm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion src/pm/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<boolean>;

// --- Data operations ---
/** Create a PMProvider instance from the project config */
createProvider(project: ProjectConfig): PMProvider;
Expand Down
16 changes: 16 additions & 0 deletions src/pm/jira/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean> {
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);
Expand Down
21 changes: 20 additions & 1 deletion src/pm/trello/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean> {
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();
Expand Down
8 changes: 8 additions & 0 deletions src/triggers/github/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
// 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.
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/pm/jira/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
// =========================================================================
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/pm/trello/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
// =========================================================================
Expand Down
Loading