From a4a5d2c8f678cbeea462057536a1db975c2f1702 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 24 Mar 2026 10:03:06 +0000 Subject: [PATCH] feat(integrations): add SCMIntegration interface and GitHubSCMIntegration class --- src/github/scm-integration.ts | 59 ++++++ src/integrations/index.ts | 2 + src/integrations/scm.ts | 29 +++ src/pm/bootstrap.ts | 18 +- tests/unit/github/scm-integration.test.ts | 242 ++++++++++++++++++++++ 5 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 src/github/scm-integration.ts create mode 100644 src/integrations/scm.ts create mode 100644 tests/unit/github/scm-integration.test.ts diff --git a/src/github/scm-integration.ts b/src/github/scm-integration.ts new file mode 100644 index 00000000..46c860bd --- /dev/null +++ b/src/github/scm-integration.ts @@ -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 { + 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 { + 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(projectId: string, fn: () => Promise): Promise { + const token = await getIntegrationCredential(projectId, 'scm', 'implementer_token'); + return withGitHubToken(token, fn); + } +} diff --git a/src/integrations/index.ts b/src/integrations/index.ts index b893341e..cdec6130 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -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'; diff --git a/src/integrations/scm.ts b/src/integrations/scm.ts new file mode 100644 index 00000000..696d6dd2 --- /dev/null +++ b/src/integrations/scm.ts @@ -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; +} diff --git a/src/pm/bootstrap.ts b/src/pm/bootstrap.ts index 18a92f4d..5b15194d 100644 --- a/src/pm/bootstrap.ts +++ b/src/pm/bootstrap.ts @@ -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//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'; @@ -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()); +} diff --git a/tests/unit/github/scm-integration.test.ts b/tests/unit/github/scm-integration.test.ts new file mode 100644 index 00000000..f60bf010 --- /dev/null +++ b/tests/unit/github/scm-integration.test.ts @@ -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(); + }); + }); +});