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
59 changes: 59 additions & 0 deletions src/github/scm-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* GitHubSCMIntegration — implements SCMIntegration for GitHub.
*
* Encapsulates GitHub SCM credential resolution and validation
* into a unified integration class following the IntegrationModule pattern.
*
* Consolidates:
* - `hasScmIntegration()` logic from src/github/integration.ts
* - `hasScmPersonaToken()` logic from src/github/integration.ts
* - `withGitHubToken()` usage from src/github/client.ts
*
* Backward compatibility: the standalone functions in src/github/integration.ts
* remain exported and continue to work identically.
*/

import { getIntegrationCredential, getIntegrationCredentialOrNull } from '../config/provider.js';
import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js';
import type { SCMIntegration } from '../integrations/scm.js';
import { withGitHubToken } from './client.js';

export class GitHubSCMIntegration implements SCMIntegration {
readonly type = 'github';
readonly category = 'scm' as const;

/**
* Check if GitHub SCM integration is configured for a project.
* Returns true if the integration exists and has at least one token linked.
*/
async hasIntegration(projectId: string): Promise<boolean> {
const provider = await getIntegrationProvider(projectId, 'scm');
if (!provider) return false;

// Check if either token is available (some agents only need one)
const [impl, rev] = await Promise.all([
getIntegrationCredentialOrNull(projectId, 'scm', 'implementer_token'),
getIntegrationCredentialOrNull(projectId, 'scm', 'reviewer_token'),
]);

return impl !== null || rev !== null;
}

/**
* Check if a specific SCM persona token is configured for a project.
*/
async hasPersonaToken(projectId: string, persona: 'implementer' | 'reviewer'): Promise<boolean> {
const role = persona === 'implementer' ? 'implementer_token' : 'reviewer_token';
const token = await getIntegrationCredentialOrNull(projectId, 'scm', role);
return token !== null;
}

/**
* Resolve the implementer token from credentials and run `fn` within that
* GitHub credential scope. Follows the same pattern as TrelloIntegration.withCredentials().
*/
async withCredentials<T>(projectId: string, fn: () => Promise<T>): Promise<T> {
const token = await getIntegrationCredential(projectId, 'scm', 'implementer_token');
return withGitHubToken(token, fn);
}
}
2 changes: 2 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
* Exports:
* - `IntegrationModule` interface — the category-agnostic contract all integrations implement
* - `IntegrationWebhookEvent` — normalized webhook event type
* - `SCMIntegration` interface — SCM-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 { IntegrationRegistry, integrationRegistry } from './registry.js';
29 changes: 29 additions & 0 deletions src/integrations/scm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* SCMIntegration — the category-specific interface all SCM integrations implement.
*
* Extends IntegrationModule with SCM-specific capabilities:
* - `category` is narrowed to 'scm'
* - `hasPersonaToken()` checks if a specific persona token is available
*/

import type { IntegrationModule } from './types.js';

/**
* SCMIntegration — extends IntegrationModule with SCM-specific capabilities.
*
* All SCM integrations (e.g. GitHub) must implement this interface.
* The `category` is narrowed to 'scm' to allow type-safe filtering.
*/
export interface SCMIntegration extends IntegrationModule {
/** Narrowed category — always 'scm' for SCM integrations */
readonly category: 'scm';

/**
* Check if a specific persona token is configured for a project.
*
* @param projectId - The project to check
* @param persona - The persona to check ('implementer' or 'reviewer')
* @returns true if the persona's token is present
*/
hasPersonaToken(projectId: string, persona: 'implementer' | 'reviewer'): Promise<boolean>;
}
18 changes: 13 additions & 5 deletions src/pm/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
/**
* PM integration bootstrap — safe to import from the router.
* Integration bootstrap — safe to import from the router.
*
* Registers all built-in PM integrations into the pmRegistry without
* pulling in the full agent execution pipeline (no processPMWebhook,
* no template files, no agent execution dependencies).
* Registers all built-in integrations (PM and SCM) into their respective
* registries without pulling in the full agent execution pipeline (no
* processPMWebhook, no template files, no agent execution dependencies).
*
* Import this module from the router entry point to ensure PM integrations
* Import this module from the router entry point to ensure integrations
* are available before any platform adapters are called. Each integration
* class is standalone (HTTP-based, no agent pipeline dependencies).
*
* Adding a new PM integration requires:
* 1. Implementing PMIntegration in `pm/<provider>/integration.ts`
* 2. Registering it here.
*
* Adding a new SCM integration requires:
* 1. Implementing SCMIntegration in `github/scm-integration.ts` (or similar)
* 2. Registering it here.
*/

import { GitHubSCMIntegration } from '../github/scm-integration.js';
import { integrationRegistry } from '../integrations/registry.js';
import { JiraIntegration } from './jira/integration.js';
import { pmRegistry } from './registry.js';
Expand All @@ -29,3 +34,6 @@ if (!pmRegistry.getOrNull('jira')) {
pmRegistry.register(jira);
if (!integrationRegistry.getOrNull('jira')) integrationRegistry.register(jira);
}
if (!integrationRegistry.getOrNull('github')) {
integrationRegistry.register(new GitHubSCMIntegration());
}
242 changes: 242 additions & 0 deletions tests/unit/github/scm-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

const mockGetIntegrationCredential = vi.fn();
const mockGetIntegrationCredentialOrNull = vi.fn();

vi.mock('../../../src/config/provider.js', () => ({
getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args),
getIntegrationCredentialOrNull: (...args: unknown[]) =>
mockGetIntegrationCredentialOrNull(...args),
}));

const mockGetIntegrationProvider = vi.fn();
vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({
getIntegrationProvider: (...args: unknown[]) => mockGetIntegrationProvider(...args),
}));

const mockWithGitHubToken = vi.fn().mockImplementation((_token, fn) => fn());
vi.mock('../../../src/github/client.js', () => ({
withGitHubToken: (...args: unknown[]) => mockWithGitHubToken(...args),
}));

import { GitHubSCMIntegration } from '../../../src/github/scm-integration.js';

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('GitHubSCMIntegration', () => {
let integration: GitHubSCMIntegration;

beforeEach(() => {
integration = new GitHubSCMIntegration();
vi.clearAllMocks();
});

// =========================================================================
// Metadata
// =========================================================================
describe('metadata', () => {
it('has type "github"', () => {
expect(integration.type).toBe('github');
});

it('has category "scm"', () => {
expect(integration.category).toBe('scm');
});
});

// =========================================================================
// hasIntegration
// =========================================================================
describe('hasIntegration', () => {
it('returns false when no SCM integration provider configured', async () => {
mockGetIntegrationProvider.mockResolvedValue(null);

const result = await integration.hasIntegration('proj-1');

expect(result).toBe(false);
expect(mockGetIntegrationCredentialOrNull).not.toHaveBeenCalled();
});

it('returns true when implementer_token is present (reviewer absent)', async () => {
mockGetIntegrationProvider.mockResolvedValue('github');
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce('ghp_implementer_token') // implementer_token
.mockResolvedValueOnce(null); // reviewer_token

const result = await integration.hasIntegration('proj-1');

expect(result).toBe(true);
expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith(
'proj-1',
'scm',
'implementer_token',
);
});

it('returns true when reviewer_token is present (implementer absent)', async () => {
mockGetIntegrationProvider.mockResolvedValue('github');
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce(null) // implementer_token
.mockResolvedValueOnce('ghp_reviewer_token'); // reviewer_token

const result = await integration.hasIntegration('proj-1');

expect(result).toBe(true);
});

it('returns true when both tokens are present', async () => {
mockGetIntegrationProvider.mockResolvedValue('github');
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce('ghp_impl')
.mockResolvedValueOnce('ghp_rev');

const result = await integration.hasIntegration('proj-1');

expect(result).toBe(true);
});

it('returns false when provider exists but both tokens are missing', async () => {
mockGetIntegrationProvider.mockResolvedValue('github');
mockGetIntegrationCredentialOrNull
.mockResolvedValueOnce(null) // implementer_token
.mockResolvedValueOnce(null); // reviewer_token

const result = await integration.hasIntegration('proj-1');

expect(result).toBe(false);
});

it('passes correct projectId and category to getIntegrationProvider', async () => {
mockGetIntegrationProvider.mockResolvedValue(null);

await integration.hasIntegration('my-project');

expect(mockGetIntegrationProvider).toHaveBeenCalledWith('my-project', 'scm');
});
});

// =========================================================================
// hasPersonaToken
// =========================================================================
describe('hasPersonaToken', () => {
it('returns true when implementer token is present', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue('ghp_implementer');

const result = await integration.hasPersonaToken('proj-1', 'implementer');

expect(result).toBe(true);
expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith(
'proj-1',
'scm',
'implementer_token',
);
});

it('returns false when implementer token is absent', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue(null);

const result = await integration.hasPersonaToken('proj-1', 'implementer');

expect(result).toBe(false);
});

it('returns true when reviewer token is present', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue('ghp_reviewer');

const result = await integration.hasPersonaToken('proj-1', 'reviewer');

expect(result).toBe(true);
expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith(
'proj-1',
'scm',
'reviewer_token',
);
});

it('returns false when reviewer token is absent', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue(null);

const result = await integration.hasPersonaToken('proj-1', 'reviewer');

expect(result).toBe(false);
});

it('maps implementer persona to implementer_token role', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue('some-token');

await integration.hasPersonaToken('proj-2', 'implementer');

expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith(
'proj-2',
'scm',
'implementer_token',
);
});

it('maps reviewer persona to reviewer_token role', async () => {
mockGetIntegrationCredentialOrNull.mockResolvedValue('some-token');

await integration.hasPersonaToken('proj-2', 'reviewer');

expect(mockGetIntegrationCredentialOrNull).toHaveBeenCalledWith(
'proj-2',
'scm',
'reviewer_token',
);
});
});

// =========================================================================
// withCredentials
// =========================================================================
describe('withCredentials', () => {
it('resolves the implementer_token and calls withGitHubToken', async () => {
mockGetIntegrationCredential.mockResolvedValue('ghp_implementer_123');
const fn = vi.fn().mockResolvedValue('result');

const result = await integration.withCredentials('proj-1', fn);

expect(mockGetIntegrationCredential).toHaveBeenCalledWith(
'proj-1',
'scm',
'implementer_token',
);
expect(mockWithGitHubToken).toHaveBeenCalledWith('ghp_implementer_123', fn);
expect(result).toBe('result');
});

it('returns the value returned by fn', async () => {
mockGetIntegrationCredential.mockResolvedValue('ghp_token');
const fn = vi.fn().mockResolvedValue({ data: 42 });

const result = await integration.withCredentials('proj-1', fn);

expect(result).toEqual({ data: 42 });
});

it('propagates errors from fn', async () => {
mockGetIntegrationCredential.mockResolvedValue('ghp_token');
mockWithGitHubToken.mockImplementation((_token, fn) => fn());
const fn = vi.fn().mockRejectedValue(new Error('API error'));

await expect(integration.withCredentials('proj-1', fn)).rejects.toThrow('API error');
});

it('propagates errors from credential resolution', async () => {
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