diff --git a/src/agents/definitions/debug.yaml b/src/agents/definitions/debug.yaml index ecccbc44..35282fcf 100644 --- a/src/agents/definitions/debug.yaml +++ b/src/agents/definitions/debug.yaml @@ -26,3 +26,7 @@ backend: compaction: default hint: Analyze the current issue fully before moving to the next. + +integrations: + required: [pm] + optional: [] diff --git a/src/agents/definitions/email-joke.yaml b/src/agents/definitions/email-joke.yaml index 18ffb320..fb4a26ec 100644 --- a/src/agents/definitions/email-joke.yaml +++ b/src/agents/definitions/email-joke.yaml @@ -31,3 +31,7 @@ trailingMessage: {} hint: >- Search for emails, read them, and send funny responses. Mark processed emails as seen to prevent re-processing. + +integrations: + required: [email] + optional: [] diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 5fa9ed2d..6ddd80a1 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -38,3 +38,7 @@ trailingMessage: includeGitStatus: true includePRStatus: true includeReminder: true + +integrations: + required: [scm, pm] + optional: [] diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index 8c065af5..d8224566 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -26,3 +26,7 @@ backend: compaction: default hint: Complete the current planning step efficiently before moving to the next. + +integrations: + required: [scm, pm] + optional: [] diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 827955b4..97311c0d 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -31,3 +31,7 @@ hint: Fix CI failures with minimal, focused changes. Batch related file edits to trailingMessage: includeDiagnostics: true + +integrations: + required: [scm] + optional: [pm] diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml index ca40a7a2..d77ff7ea 100644 --- a/src/agents/definitions/respond-to-planning-comment.yaml +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -26,3 +26,7 @@ backend: compaction: default hint: Complete the current task efficiently before moving to the next. + +integrations: + required: [scm, pm] + optional: [] diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index d804a39e..b15992c5 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -29,3 +29,7 @@ backend: compaction: default hint: Complete the current task efficiently before moving to the next. + +integrations: + required: [scm] + optional: [pm] diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index 557b6729..8d1a59f0 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -32,3 +32,7 @@ hint: Address the current review comment fully before moving to the next. Batch trailingMessage: includeDiagnostics: true + +integrations: + required: [scm] + optional: [pm] diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index d6ef59a4..d1d4618c 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -27,3 +27,7 @@ backend: compaction: default hint: Focus on the current aspect of review before moving to the next. Read related files together. + +integrations: + required: [scm] + optional: [pm] diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 4f8f2839..189783e3 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -4,6 +4,29 @@ import { z } from 'zod'; // Agent Definition Schema // ============================================================================ +// Integration categories (aligned with integrationRoles.ts) +export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'email']); + +// Integration requirements schema (REQUIRED field) +const IntegrationsSchema = z + .object({ + /** Integrations that MUST be configured for the agent to run */ + required: z.array(IntegrationCategorySchema), + /** + * Integrations the agent CAN use if available (for future use). + * Currently not validated - reserved for dashboard filtering and + * conditional agent behavior based on available integrations. + */ + optional: z.array(IntegrationCategorySchema), + }) + .refine( + (data) => { + const requiredSet = new Set(data.required); + return !data.optional.some((cat) => requiredSet.has(cat)); + }, + { message: 'A category cannot be both required and optional' }, + ); + const IdentitySchema = z.object({ emoji: z.string(), label: z.string(), @@ -85,6 +108,11 @@ export const AgentDefinitionSchema = z.object({ compaction: z.enum(['implementation', 'default']), hint: z.string(), trailingMessage: TrailingMessageSchema, + integrations: IntegrationsSchema, }); export type AgentDefinition = z.infer; + +export type IntegrationCategory = z.infer; + +export type AgentIntegrations = z.infer; diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 4213d571..b7c865f2 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -26,3 +26,7 @@ backend: compaction: default hint: Gather all context needed for the current step before proceeding. + +integrations: + required: [scm, pm] + optional: [] diff --git a/src/github/integration.ts b/src/github/integration.ts new file mode 100644 index 00000000..21f3b5b0 --- /dev/null +++ b/src/github/integration.ts @@ -0,0 +1,38 @@ +/** + * SCM (GitHub) integration — credential validation helpers. + * + * Provides hasScmIntegration() for checking if SCM integration is configured, + * consistent with the pattern in email/integration.ts. + */ + +import { getIntegrationCredentialOrNull } from '../config/provider.js'; +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; + +/** + * Check if SCM integration is configured for a project. + * Returns true if the integration exists and has at least one token linked. + */ +export async function hasScmIntegration(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. + */ +export async function hasScmPersonaToken( + projectId: string, + persona: 'implementer' | 'reviewer', +): Promise { + const role = persona === 'implementer' ? 'implementer_token' : 'reviewer_token'; + const token = await getIntegrationCredentialOrNull(projectId, 'scm', role); + return token !== null; +} diff --git a/src/pm/index.ts b/src/pm/index.ts index 1cc44b72..87eed6f5 100644 --- a/src/pm/index.ts +++ b/src/pm/index.ts @@ -18,6 +18,7 @@ export type { ProjectPMConfig } from './lifecycle.js'; // PMIntegration interface + registry export type { PMIntegration, PMWebhookEvent } from './integration.js'; +export { hasPmIntegration } from './integration.js'; export { pmRegistry } from './registry.js'; export { processPMWebhook } from './webhook-handler.js'; diff --git a/src/pm/integration.ts b/src/pm/integration.ts index b315623c..ab0dbafb 100644 --- a/src/pm/integration.ts +++ b/src/pm/integration.ts @@ -9,6 +9,8 @@ * provider-specific branching. */ +import { getIntegrationCredentialOrNull } from '../config/provider.js'; +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; import type { ProjectPMConfig } from './lifecycle.js'; import type { PMProvider } from './types.js'; @@ -70,3 +72,33 @@ export interface PMIntegration { /** Extract a work item ID from text (e.g. PR body). Returns null if not found. */ extractWorkItemId(text: string): string | null; } + +// ============================================================================ +// Integration check helpers +// ============================================================================ + +/** + * Check if PM integration is configured for a project. + * Returns true if a PM integration (Trello/JIRA) exists with required credentials. + */ +export async function hasPmIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'pm'); + if (!provider) return false; + + // Check provider-specific required credentials + if (provider === 'trello') { + const [key, token] = await Promise.all([ + getIntegrationCredentialOrNull(projectId, 'pm', 'api_key'), + getIntegrationCredentialOrNull(projectId, 'pm', 'token'), + ]); + return key !== null && token !== null; + } + if (provider === 'jira') { + const [email, apiToken] = await Promise.all([ + getIntegrationCredentialOrNull(projectId, 'pm', 'email'), + getIntegrationCredentialOrNull(projectId, 'pm', 'api_token'), + ]); + return email !== null && apiToken !== null; + } + return false; +} diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 1ea6adab..35afd458 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -7,6 +7,7 @@ import { handleAgentResultArtifacts } from './agent-result-handler.js'; import { checkBudgetExceeded } from './budget.js'; import { triggerDebugAnalysis } from './debug-runner.js'; import { shouldTriggerDebug } from './debug-trigger.js'; +import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; /** * Configuration for source-specific behavior in the agent execution pipeline. @@ -149,13 +150,38 @@ export async function runAgentExecutionPipeline( } const agentType = result.agentType; - const { skipPrepareForAgent = false, onFailure, logLabel = 'Agent' } = executionConfig; - - const workItemId = result.workItemId; + // Create lifecycle manager once (reused for validation failure and normal flow) const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + // Pre-flight integration validation + const validation = await validateIntegrations(project.id, agentType); + if (!validation.valid) { + const errorMessage = formatValidationErrors(validation); + logger.error('Integration validation failed', { + agentType, + projectId: project.id, + errors: validation.errors, + }); + + // Only notify via PM if PM validation passed (otherwise PM isn't configured) + const pmFailed = validation.errors.some((e) => e.category === 'pm'); + if (result.workItemId && !pmFailed) { + await lifecycle.handleFailure(result.workItemId, errorMessage); + } + + // Call onFailure callback (for GitHub PR updates) + if (executionConfig.onFailure) { + await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage }); + } + return; + } + + const { skipPrepareForAgent = false, onFailure, logLabel = 'Agent' } = executionConfig; + + const workItemId = result.workItemId; + let remainingBudgetUsd: number | undefined; if (workItemId) { const budgetResult = await checkPreRunBudget(workItemId, project, config, lifecycle); diff --git a/src/triggers/shared/integration-validation.ts b/src/triggers/shared/integration-validation.ts new file mode 100644 index 00000000..2341c0aa --- /dev/null +++ b/src/triggers/shared/integration-validation.ts @@ -0,0 +1,145 @@ +/** + * Pre-flight integration validation for agents. + * + * Validates that all required integrations are configured before an agent runs. + * This prevents confusing runtime errors and provides clear feedback about + * missing configuration. + */ + +import { loadAgentDefinition } from '../../agents/definitions/loader.js'; +import type { AgentIntegrations, IntegrationCategory } from '../../agents/definitions/schema.js'; +import { hasEmailIntegration } from '../../email/integration.js'; +import { hasScmIntegration, hasScmPersonaToken } from '../../github/integration.js'; +import { getPersonaForAgentType } from '../../github/personas.js'; +import { hasPmIntegration } from '../../pm/integration.js'; +import { logger } from '../../utils/logging.js'; + +export interface ValidationError { + category: IntegrationCategory; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +/** + * Get integration requirements for an agent. + */ +export function getIntegrationRequirements(agentType: string): AgentIntegrations { + const def = loadAgentDefinition(agentType); + return def.integrations; +} + +// ============================================================================ +// Category-specific validators +// ============================================================================ + +async function validatePmIntegration( + projectId: string, + agentType: string, +): Promise { + const hasPM = await hasPmIntegration(projectId); + if (!hasPM) { + return { + category: 'pm', + message: `Agent '${agentType}' requires a PM integration (Trello/JIRA), but none is configured.`, + }; + } + return null; +} + +async function validateScmIntegration( + projectId: string, + agentType: string, +): Promise { + const hasSCM = await hasScmIntegration(projectId); + if (!hasSCM) { + return { + category: 'scm', + message: `Agent '${agentType}' requires SCM integration (GitHub), but none is configured.`, + }; + } + + // Also check specific persona token + const persona = getPersonaForAgentType(agentType); + const hasToken = await hasScmPersonaToken(projectId, persona); + if (!hasToken) { + const label = persona === 'implementer' ? 'Implementer' : 'Reviewer'; + return { + category: 'scm', + message: `Agent '${agentType}' requires ${label} token, but it is not configured.`, + }; + } + + return null; +} + +async function validateEmailIntegration( + projectId: string, + agentType: string, +): Promise { + const hasEmail = await hasEmailIntegration(projectId); + if (!hasEmail) { + return { + category: 'email', + message: `Agent '${agentType}' requires email integration, but none is configured.`, + }; + } + return null; +} + +// ============================================================================ +// Main validation function +// ============================================================================ + +/** + * Validate all required integrations are configured before agent runs. + */ +export async function validateIntegrations( + projectId: string, + agentType: string, +): Promise { + const { required } = getIntegrationRequirements(agentType); + + // Run all validations in parallel + const validationPromises = required.map(async (category): Promise => { + switch (category) { + case 'pm': + return validatePmIntegration(projectId, agentType); + case 'scm': + return validateScmIntegration(projectId, agentType); + case 'email': + return validateEmailIntegration(projectId, agentType); + default: + return null; + } + }); + + const results = await Promise.all(validationPromises); + const errors = results.filter((e): e is ValidationError => e !== null); + + if (errors.length > 0) { + logger.warn('Integration validation failed', { + projectId, + agentType, + errors: errors.map((e) => e.message), + }); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Format validation errors into user-friendly message. + */ +export function formatValidationErrors(result: ValidationResult): string { + if (result.valid) return ''; + return [ + 'Integration validation failed:', + ...result.errors.map((e) => ` - ${e.message}`), + '', + 'Configure missing integrations in Project Settings > Integrations.', + ].join('\n'); +} diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 2a100ea6..507a47e0 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -7,6 +7,7 @@ import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; +import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; /** * In-memory tracking to prevent duplicate concurrent manual triggers. @@ -81,6 +82,12 @@ export async function triggerManualRun( ); } + // Pre-flight integration validation + const validation = await validateIntegrations(input.projectId, input.agentType); + if (!validation.valid) { + throw new Error(formatValidationErrors(validation)); + } + logger.info('Triggering manual agent run', { projectId: input.projectId, agentType: input.agentType, diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 53085934..b007f3d5 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -300,3 +300,229 @@ export async function seedSession(overrides: { .returning(); return row; } + +// ============================================================================ +// Composite helpers for common integration setups +// ============================================================================ + +/** + * Seeds a complete Trello PM integration with both required credentials. + */ +export async function seedTrelloIntegration( + projectId = 'test-project', + options?: { skipApiKey?: boolean; skipToken?: boolean }, +) { + const integ = await seedIntegration({ + projectId, + category: 'pm', + provider: 'trello', + config: { boardId: 'board-1', lists: {}, labels: {} }, + }); + + if (!options?.skipApiKey) { + const apiKey = await seedCredential({ + envVarKey: 'TRELLO_API_KEY', + value: 'test-api-key', + name: 'Trello API Key', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'api_key', + credentialId: apiKey.id, + }); + } + + if (!options?.skipToken) { + const token = await seedCredential({ + envVarKey: 'TRELLO_TOKEN', + value: 'test-token', + name: 'Trello Token', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'token', + credentialId: token.id, + }); + } + + return integ; +} + +/** + * Seeds a complete JIRA PM integration with both required credentials. + */ +export async function seedJiraIntegration( + projectId = 'test-project', + options?: { skipEmail?: boolean; skipApiToken?: boolean }, +) { + const integ = await seedIntegration({ + projectId, + category: 'pm', + provider: 'jira', + config: { siteUrl: 'https://test.atlassian.net', projectKey: 'TEST', statuses: {} }, + }); + + if (!options?.skipEmail) { + const email = await seedCredential({ + envVarKey: 'JIRA_EMAIL', + value: 'test@example.com', + name: 'JIRA Email', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'email', + credentialId: email.id, + }); + } + + if (!options?.skipApiToken) { + const apiToken = await seedCredential({ + envVarKey: 'JIRA_API_TOKEN', + value: 'test-api-token', + name: 'JIRA API Token', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'api_token', + credentialId: apiToken.id, + }); + } + + return integ; +} + +/** + * Seeds a GitHub SCM integration with configurable persona tokens. + * + * By default, seeds both implementer and reviewer tokens. + * Use skipImplementer/skipReviewer to omit specific tokens. + */ +export async function seedGitHubIntegration( + projectId = 'test-project', + options?: { skipImplementer?: boolean; skipReviewer?: boolean }, +) { + const integ = await seedIntegration({ + projectId, + category: 'scm', + provider: 'github', + }); + + if (!options?.skipImplementer) { + const implCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'ghp-impl-test', + name: 'Implementer Token', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'implementer_token', + credentialId: implCred.id, + }); + } + + if (!options?.skipReviewer) { + const revCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_REVIEWER', + value: 'ghp-rev-test', + name: 'Reviewer Token', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'reviewer_token', + credentialId: revCred.id, + }); + } + + return integ; +} + +/** + * Seeds a complete IMAP email integration with all 6 required credentials. + */ +export async function seedImapEmailIntegration( + projectId = 'test-project', + options?: { skipCredential?: string }, +) { + const integ = await seedIntegration({ + projectId, + category: 'email', + provider: 'imap', + }); + + const roles = [ + { role: 'imap_host', envKey: 'EMAIL_IMAP_HOST', value: 'imap.example.com' }, + { role: 'imap_port', envKey: 'EMAIL_IMAP_PORT', value: '993' }, + { role: 'smtp_host', envKey: 'EMAIL_SMTP_HOST', value: 'smtp.example.com' }, + { role: 'smtp_port', envKey: 'EMAIL_SMTP_PORT', value: '465' }, + { role: 'username', envKey: 'EMAIL_USERNAME', value: 'user@example.com' }, + { role: 'password', envKey: 'EMAIL_PASSWORD', value: 'secret' }, + ]; + + for (const { role, envKey, value } of roles) { + if (options?.skipCredential === role) continue; + const cred = await seedCredential({ envVarKey: envKey, value, name: `Email ${role}` }); + await seedIntegrationCredential({ integrationId: integ.id, role, credentialId: cred.id }); + } + + return integ; +} + +/** + * Seeds a Gmail email integration with required credentials. + * Note: Does NOT seed the OAuth tokens needed for actual Gmail access, + * only the gmail_email and gmail_refresh_token integration credentials. + */ +export async function seedGmailEmailIntegration( + projectId = 'test-project', + options?: { skipEmail?: boolean; skipRefreshToken?: boolean; includeOrgOAuth?: boolean }, +) { + const integ = await seedIntegration({ + projectId, + category: 'email', + provider: 'gmail', + }); + + if (!options?.skipEmail) { + const gmailEmail = await seedCredential({ + envVarKey: 'GMAIL_EMAIL', + value: 'test@gmail.com', + name: 'Gmail Email', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'gmail_email', + credentialId: gmailEmail.id, + }); + } + + if (!options?.skipRefreshToken) { + const refreshToken = await seedCredential({ + envVarKey: 'GMAIL_REFRESH_TOKEN', + value: 'test-refresh-token', + name: 'Gmail Refresh Token', + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'gmail_refresh_token', + credentialId: refreshToken.id, + }); + } + + // Optionally seed org-level OAuth credentials + if (options?.includeOrgOAuth) { + await seedCredential({ + envVarKey: 'GOOGLE_OAUTH_CLIENT_ID', + value: 'test-client-id', + name: 'Google OAuth Client ID', + isDefault: true, + }); + await seedCredential({ + envVarKey: 'GOOGLE_OAUTH_CLIENT_SECRET', + value: 'test-client-secret', + name: 'Google OAuth Client Secret', + isDefault: true, + }); + } + + return integ; +} diff --git a/tests/integration/integration-validation.test.ts b/tests/integration/integration-validation.test.ts new file mode 100644 index 00000000..74bf1efc --- /dev/null +++ b/tests/integration/integration-validation.test.ts @@ -0,0 +1,577 @@ +/** + * Integration tests for the integration validation system. + * + * Tests the full validation pipeline against a real database: + * - PM integration validation (Trello, JIRA) + * - SCM integration validation (GitHub) + * - Persona-specific token validation (implementer vs reviewer) + * - Email integration validation (IMAP) + * - Partial credential scenarios + * - Error message formatting + * + * Unit tests (mocked) are in tests/unit/triggers/shared/integration-validation.test.ts + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasEmailIntegration } from '../../src/email/integration.js'; +import { hasScmIntegration, hasScmPersonaToken } from '../../src/github/integration.js'; +import { hasPmIntegration } from '../../src/pm/integration.js'; +import { + formatValidationErrors, + getIntegrationRequirements, + validateIntegrations, +} from '../../src/triggers/shared/integration-validation.js'; +import { truncateAll } from './helpers/db.js'; +import { + seedCredential, + seedGitHubIntegration, + seedGmailEmailIntegration, + seedImapEmailIntegration, + seedIntegration, + seedIntegrationCredential, + seedJiraIntegration, + seedOrg, + seedProject, + seedTrelloIntegration, +} from './helpers/seed.js'; + +// Suppress logging during tests +vi.mock('../../src/utils/logging.js', () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('Integration Validation (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // PM Integration Validation + // ========================================================================= + + describe('PM integration validation', () => { + describe('Trello', () => { + it('passes when Trello has complete credentials', async () => { + await seedTrelloIntegration(); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(true); + + // debug agent only requires PM + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('fails when Trello is missing api_key', async () => { + await seedTrelloIntegration('test-project', { skipApiKey: true }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('pm'); + expect(result.errors[0].message).toContain('PM integration'); + }); + + it('fails when Trello is missing token', async () => { + await seedTrelloIntegration('test-project', { skipToken: true }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('pm'); + }); + }); + + describe('JIRA', () => { + it('passes when JIRA has complete credentials', async () => { + await seedJiraIntegration(); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(true); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(true); + }); + + it('fails when JIRA is missing email', async () => { + await seedJiraIntegration('test-project', { skipEmail: true }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.errors).toHaveLength(1); + expect(result.valid).toBe(false); + expect(result.errors[0].category).toBe('pm'); + }); + + it('fails when JIRA is missing api_token', async () => { + await seedJiraIntegration('test-project', { skipApiToken: true }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(false); + expect(result.errors[0].category).toBe('pm'); + expect(result.errors).toHaveLength(1); + }); + }); + + it('fails when no PM integration at all', async () => { + // Only seed SCM, no PM + await seedGitHubIntegration(); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + + // implementation requires both PM and SCM + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + const pmErrors = result.errors.filter((e) => e.category === 'pm'); + expect(pmErrors).toHaveLength(1); + }); + }); + + // ========================================================================= + // SCM Integration Validation + // ========================================================================= + + describe('SCM integration validation', () => { + it('passes with both persona tokens configured', async () => { + await seedTrelloIntegration(); + await seedGitHubIntegration(); + + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(true); + + // implementation requires SCM + PM + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(true); + }); + + it('passes with only implementer token (for hasScmIntegration check)', async () => { + await seedGitHubIntegration('test-project', { skipReviewer: true }); + + // hasScmIntegration returns true if at least one token exists + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(true); + }); + + it('passes with only reviewer token (for hasScmIntegration check)', async () => { + await seedGitHubIntegration('test-project', { skipImplementer: true }); + + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(true); + }); + + it('fails when no SCM integration exists', async () => { + // Only PM, no SCM + await seedTrelloIntegration(); + + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(false); + + // review agent requires SCM + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('scm'); + expect(result.errors[0].message).toContain('SCM integration'); + }); + + it('fails when SCM integration exists but no tokens linked', async () => { + await seedIntegration({ + category: 'scm', + provider: 'github', + }); + + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(false); + }); + }); + + // ========================================================================= + // Persona-Specific Token Validation + // ========================================================================= + + describe('persona-specific token validation', () => { + describe('implementer persona agents', () => { + // Agents that need implementer token: + // splitting, planning, implementation, respond-to-review, + // respond-to-ci, respond-to-pr-comment, respond-to-planning-comment, debug + + it('implementation agent passes with implementer token', async () => { + await seedTrelloIntegration(); + await seedGitHubIntegration('test-project', { skipReviewer: true }); + + const hasImpl = await hasScmPersonaToken('test-project', 'implementer'); + expect(hasImpl).toBe(true); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(true); + }); + + it('implementation agent fails without implementer token', async () => { + await seedTrelloIntegration(); + await seedGitHubIntegration('test-project', { skipImplementer: true }); + + const hasImpl = await hasScmPersonaToken('test-project', 'implementer'); + expect(hasImpl).toBe(false); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Implementer token'); + }); + + // Use it.each() for the remaining implementer agents to reduce duplication + const implementerAgents = [ + 'splitting', + 'planning', + 'respond-to-review', + 'respond-to-ci', + 'respond-to-pr-comment', + 'respond-to-planning-comment', + ]; + + it.each(implementerAgents)('%s agent needs implementer token', async (agentType) => { + await seedTrelloIntegration(); + await seedGitHubIntegration('test-project', { skipImplementer: true }); + + const result = await validateIntegrations('test-project', agentType); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Implementer token'); + }); + }); + + describe('reviewer persona agents', () => { + it('review agent passes with reviewer token', async () => { + await seedGitHubIntegration('test-project', { skipImplementer: true }); + + const hasRev = await hasScmPersonaToken('test-project', 'reviewer'); + expect(hasRev).toBe(true); + + // review agent only requires SCM + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(true); + }); + + it('review agent fails without reviewer token', async () => { + await seedGitHubIntegration('test-project', { skipReviewer: true }); + + const hasRev = await hasScmPersonaToken('test-project', 'reviewer'); + expect(hasRev).toBe(false); + + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Reviewer token'); + }); + }); + }); + + // ========================================================================= + // Email Integration Validation + // ========================================================================= + + describe('email integration validation', () => { + describe('IMAP', () => { + it('passes when all 6 IMAP credentials are configured', async () => { + await seedImapEmailIntegration(); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(true); + + // email-joke requires email + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(true); + }); + + it('fails when password is missing', async () => { + await seedImapEmailIntegration('test-project', { skipCredential: 'password' }); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('email'); + }); + + it('fails when imap_host is missing', async () => { + await seedImapEmailIntegration('test-project', { skipCredential: 'imap_host' }); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + }); + + it('fails when username is missing', async () => { + await seedImapEmailIntegration('test-project', { skipCredential: 'username' }); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + }); + }); + + describe('Gmail', () => { + // Note: Can't fully test Gmail validation without mocking the OAuth token refresh. + // The hasEmailIntegration check requires fetching a valid access token from Google. + // Instead, we verify that missing credentials are properly detected. + + it('fails when gmail_email is missing', async () => { + await seedGmailEmailIntegration('test-project', { skipEmail: true }); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('email'); + }); + + it('fails when gmail_refresh_token is missing', async () => { + await seedGmailEmailIntegration('test-project', { skipRefreshToken: true }); + + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('email'); + }); + }); + + it('fails when no email integration exists', async () => { + // No email integration at all + const hasEmail = await hasEmailIntegration('test-project'); + expect(hasEmail).toBe(false); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('email'); + expect(result.errors[0].message).toContain('email integration'); + }); + }); + + // ========================================================================= + // Partial Credential Scenarios + // ========================================================================= + + describe('partial credential scenarios', () => { + it('provider exists but no credentials are linked', async () => { + // Create PM integration without linking any credentials + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-1', lists: {}, labels: {} }, + }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + }); + + it('credential row exists but not linked to integration', async () => { + // Create integration without linking credentials + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-1', lists: {}, labels: {} }, + }); + + // Create credential rows but don't link them + await seedCredential({ envVarKey: 'TRELLO_API_KEY', value: 'orphan-key' }); + await seedCredential({ envVarKey: 'TRELLO_TOKEN', value: 'orphan-token' }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + }); + + it('only one of two required credentials is linked', async () => { + const integ = await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-1', lists: {}, labels: {} }, + }); + + // Link only api_key, not token + const apiKey = await seedCredential({ envVarKey: 'TRELLO_API_KEY', value: 'key' }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'api_key', + credentialId: apiKey.id, + }); + + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(false); + }); + + it('SCM integration exists but both tokens are missing', async () => { + await seedGitHubIntegration('test-project', { skipImplementer: true, skipReviewer: true }); + + const hasSCM = await hasScmIntegration('test-project'); + expect(hasSCM).toBe(false); + }); + + it('empty credential value is accepted (not treated as missing)', async () => { + // Note: Current implementation does NOT treat empty strings as missing. + // This test documents the actual behavior. If empty values should fail, + // the credential resolution logic would need to be updated. + const integ = await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-1', lists: {}, labels: {} }, + }); + + // Link credentials but with empty value for api_key + const apiKey = await seedCredential({ envVarKey: 'TRELLO_API_KEY', value: '' }); + const token = await seedCredential({ envVarKey: 'TRELLO_TOKEN', value: 'valid-token' }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'api_key', + credentialId: apiKey.id, + }); + await seedIntegrationCredential({ + integrationId: integ.id, + role: 'token', + credentialId: token.id, + }); + + // Empty credential value is currently accepted (both credentials linked) + const hasPM = await hasPmIntegration('test-project'); + expect(hasPM).toBe(true); + }); + }); + + // ========================================================================= + // Error Message Verification + // ========================================================================= + + describe('error message format', () => { + it('PM errors contain provider reference', async () => { + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('PM integration (Trello/JIRA)'); + }); + + it('SCM errors contain provider reference', async () => { + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('SCM integration (GitHub)'); + }); + + it('formatValidationErrors includes dashboard link', async () => { + const result = await validateIntegrations('test-project', 'debug'); + const formatted = formatValidationErrors(result); + expect(formatted).toContain('Project Settings > Integrations'); + }); + + it('formatValidationErrors lists all errors', async () => { + // implementation needs both PM and SCM + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + + const formatted = formatValidationErrors(result); + expect(formatted).toContain('PM integration'); + expect(formatted).toContain('SCM integration'); + }); + + it('token errors name the specific token', async () => { + // SCM exists but only reviewer token (implementation needs implementer) + await seedTrelloIntegration(); + await seedGitHubIntegration('test-project', { skipImplementer: true }); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Implementer token'); + }); + }); + + // ========================================================================= + // Integration Requirements + // ========================================================================= + + describe('integration requirements', () => { + it('implementation requires both scm and pm', () => { + const reqs = getIntegrationRequirements('implementation'); + expect(reqs.required).toContain('scm'); + expect(reqs.required).toContain('pm'); + }); + + it('review requires only scm with optional pm', () => { + const reqs = getIntegrationRequirements('review'); + expect(reqs.required).toEqual(['scm']); + expect(reqs.optional).toContain('pm'); + }); + + it('debug requires only pm', () => { + const reqs = getIntegrationRequirements('debug'); + expect(reqs.required).toEqual(['pm']); + }); + + it('email-joke requires only email', () => { + const reqs = getIntegrationRequirements('email-joke'); + expect(reqs.required).toEqual(['email']); + }); + }); + + // ========================================================================= + // Multiple Missing Integrations + // ========================================================================= + + describe('multiple missing integrations', () => { + it('reports all missing integrations for implementation agent', async () => { + // No integrations at all + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + + const categories = result.errors.map((e) => e.category); + expect(categories).toContain('pm'); + expect(categories).toContain('scm'); + }); + }); + + // ========================================================================= + // Cross-Project Isolation + // ========================================================================= + + describe('cross-project isolation', () => { + it('validates integrations per-project', async () => { + // Create two projects + await seedProject({ id: 'project-a', name: 'Project A', repo: 'owner/repo-a' }); + await seedProject({ id: 'project-b', name: 'Project B', repo: 'owner/repo-b' }); + + // Only project-a has integrations + await seedTrelloIntegration('project-a'); + await seedGitHubIntegration('project-a'); + + const resultA = await validateIntegrations('project-a', 'implementation'); + expect(resultA.valid).toBe(true); + + const resultB = await validateIntegrations('project-b', 'implementation'); + expect(resultB.valid).toBe(false); + }); + }); +}); diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts index d9ff2f8a..ecaf9fc0 100644 --- a/tests/unit/agents/definitions/loader.test.ts +++ b/tests/unit/agents/definitions/loader.test.ts @@ -360,4 +360,94 @@ describe('YAML agent definitions loader', () => { }); }); }); + + describe('integration requirements', () => { + it('all agents have integrations field with required and optional arrays', () => { + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + expect(def.integrations).toBeDefined(); + expect(Array.isArray(def.integrations.required)).toBe(true); + expect(Array.isArray(def.integrations.optional)).toBe(true); + } + }); + + it('implementation agent requires scm and pm', () => { + const def = loadAgentDefinition('implementation'); + expect(def.integrations.required).toEqual(['scm', 'pm']); + expect(def.integrations.optional).toEqual([]); + }); + + it('splitting agent requires scm and pm', () => { + const def = loadAgentDefinition('splitting'); + expect(def.integrations.required).toEqual(['scm', 'pm']); + expect(def.integrations.optional).toEqual([]); + }); + + it('planning agent requires scm and pm', () => { + const def = loadAgentDefinition('planning'); + expect(def.integrations.required).toEqual(['scm', 'pm']); + expect(def.integrations.optional).toEqual([]); + }); + + it('review agent requires scm, pm is optional', () => { + const def = loadAgentDefinition('review'); + expect(def.integrations.required).toEqual(['scm']); + expect(def.integrations.optional).toEqual(['pm']); + }); + + it('respond-to-review agent requires scm, pm is optional', () => { + const def = loadAgentDefinition('respond-to-review'); + expect(def.integrations.required).toEqual(['scm']); + expect(def.integrations.optional).toEqual(['pm']); + }); + + it('respond-to-ci agent requires scm, pm is optional', () => { + const def = loadAgentDefinition('respond-to-ci'); + expect(def.integrations.required).toEqual(['scm']); + expect(def.integrations.optional).toEqual(['pm']); + }); + + it('respond-to-pr-comment agent requires scm, pm is optional', () => { + const def = loadAgentDefinition('respond-to-pr-comment'); + expect(def.integrations.required).toEqual(['scm']); + expect(def.integrations.optional).toEqual(['pm']); + }); + + it('respond-to-planning-comment agent requires scm and pm', () => { + const def = loadAgentDefinition('respond-to-planning-comment'); + expect(def.integrations.required).toEqual(['scm', 'pm']); + expect(def.integrations.optional).toEqual([]); + }); + + it('debug agent requires pm only', () => { + const def = loadAgentDefinition('debug'); + expect(def.integrations.required).toEqual(['pm']); + expect(def.integrations.optional).toEqual([]); + }); + + it('email-joke agent requires email only', () => { + const def = loadAgentDefinition('email-joke'); + expect(def.integrations.required).toEqual(['email']); + expect(def.integrations.optional).toEqual([]); + }); + + it('all integration categories are valid', () => { + const validCategories = ['pm', 'scm', 'email']; + for (const agentType of ALL_AGENT_TYPES) { + const def = loadAgentDefinition(agentType); + for (const cat of def.integrations.required) { + expect( + validCategories.includes(cat), + `${agentType}: invalid required category '${cat}'`, + ).toBe(true); + } + for (const cat of def.integrations.optional) { + expect( + validCategories.includes(cat), + `${agentType}: invalid optional category '${cat}'`, + ).toBe(true); + } + } + }); + }); }); diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts index a7e4327b..8945fef0 100644 --- a/tests/unit/agents/definitions/schema.test.ts +++ b/tests/unit/agents/definitions/schema.test.ts @@ -30,6 +30,10 @@ describe('AgentDefinitionSchema', () => { }, compaction: 'default', hint: 'Do the thing efficiently.', + integrations: { + required: ['pm'], + optional: [], + }, }; it('parses a valid minimal definition', () => { @@ -177,4 +181,19 @@ describe('AgentDefinitionSchema', () => { const result = AgentDefinitionSchema.safeParse(good); expect(result.success).toBe(true); }); + + it('rejects overlapping required and optional categories', () => { + const bad = { + ...validDefinition, + integrations: { + required: ['pm', 'scm'], + optional: ['pm'], // pm is in both + }, + }; + const result = AgentDefinitionSchema.safeParse(bad); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('cannot be both required and optional'); + } + }); }); diff --git a/tests/unit/triggers/agent-execution.test.ts b/tests/unit/triggers/agent-execution.test.ts index 649c634c..3003cee4 100644 --- a/tests/unit/triggers/agent-execution.test.ts +++ b/tests/unit/triggers/agent-execution.test.ts @@ -34,6 +34,11 @@ vi.mock('../../../src/triggers/shared/debug-trigger.js', () => ({ shouldTriggerDebug: vi.fn(), })); +vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ + validateIntegrations: vi.fn().mockResolvedValue({ valid: true, errors: [] }), + formatValidationErrors: vi.fn().mockReturnValue(''), +})); + import { runAgent } from '../../../src/agents/registry.js'; import { PMLifecycleManager, diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 62ef21a5..96025544 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -36,6 +36,11 @@ vi.mock('../../../src/email/integration.js', () => ({ withEmailIntegration: vi.fn((_projectId: string, fn: () => unknown) => fn()), })); +vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ + validateIntegrations: vi.fn().mockResolvedValue({ valid: true, errors: [] }), + formatValidationErrors: vi.fn().mockReturnValue(''), +})); + import { runAgent } from '../../../src/agents/registry.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; import { withPMCredentials } from '../../../src/pm/context.js'; @@ -82,7 +87,8 @@ describe('triggerManualRun', () => { mockConfig, ); - // markTriggerRunning happens synchronously before runAgent, so no tick needed + // Wait for async validation to complete and trigger to be marked as running + await new Promise((resolve) => setTimeout(resolve, 10)); // Try to trigger again — should throw duplicate check await expect( diff --git a/tests/unit/triggers/shared/integration-validation.test.ts b/tests/unit/triggers/shared/integration-validation.test.ts new file mode 100644 index 00000000..44c92352 --- /dev/null +++ b/tests/unit/triggers/shared/integration-validation.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatValidationErrors, + getIntegrationRequirements, + validateIntegrations, +} from '../../../../src/triggers/shared/integration-validation.js'; + +// Mock the integration check functions +vi.mock('../../../../src/pm/integration.js', () => ({ + hasPmIntegration: vi.fn(), +})); + +vi.mock('../../../../src/github/integration.js', () => ({ + hasScmIntegration: vi.fn(), + hasScmPersonaToken: vi.fn(), +})); + +vi.mock('../../../../src/email/integration.js', () => ({ + hasEmailIntegration: vi.fn(), +})); + +vi.mock('../../../../src/github/personas.js', () => ({ + getPersonaForAgentType: vi.fn().mockReturnValue('implementer'), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +import { hasEmailIntegration } from '../../../../src/email/integration.js'; +import { hasScmIntegration, hasScmPersonaToken } from '../../../../src/github/integration.js'; +import { getPersonaForAgentType } from '../../../../src/github/personas.js'; +import { hasPmIntegration } from '../../../../src/pm/integration.js'; + +describe('integration-validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getIntegrationRequirements', () => { + it('returns integration requirements for implementation agent', () => { + const reqs = getIntegrationRequirements('implementation'); + expect(reqs.required).toEqual(['scm', 'pm']); + expect(reqs.optional).toEqual([]); + }); + + it('returns integration requirements for review agent', () => { + const reqs = getIntegrationRequirements('review'); + expect(reqs.required).toEqual(['scm']); + expect(reqs.optional).toEqual(['pm']); + }); + + it('returns integration requirements for email-joke agent', () => { + const reqs = getIntegrationRequirements('email-joke'); + expect(reqs.required).toEqual(['email']); + expect(reqs.optional).toEqual([]); + }); + + it('returns integration requirements for debug agent', () => { + const reqs = getIntegrationRequirements('debug'); + expect(reqs.required).toEqual(['pm']); + expect(reqs.optional).toEqual([]); + }); + + it('throws for unknown agent type', () => { + expect(() => getIntegrationRequirements('nonexistent-agent')).toThrow( + 'Agent definition not found', + ); + }); + }); + + describe('validateIntegrations', () => { + describe('PM integration validation', () => { + it('passes when PM integration is configured for agents requiring it', async () => { + vi.mocked(hasPmIntegration).mockResolvedValue(true); + vi.mocked(hasScmIntegration).mockResolvedValue(true); + vi.mocked(hasScmPersonaToken).mockResolvedValue(true); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('fails when PM integration is missing for agents requiring it', async () => { + vi.mocked(hasPmIntegration).mockResolvedValue(false); + vi.mocked(hasScmIntegration).mockResolvedValue(true); + vi.mocked(hasScmPersonaToken).mockResolvedValue(true); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('pm'); + expect(result.errors[0].message).toContain('requires a PM integration'); + }); + + it('passes for debug agent when only PM is configured', async () => { + vi.mocked(hasPmIntegration).mockResolvedValue(true); + + const result = await validateIntegrations('test-project', 'debug'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); + + describe('SCM integration validation', () => { + it('passes when SCM integration and persona token are configured', async () => { + vi.mocked(hasScmIntegration).mockResolvedValue(true); + vi.mocked(hasScmPersonaToken).mockResolvedValue(true); + + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('fails when SCM integration is missing', async () => { + vi.mocked(hasScmIntegration).mockResolvedValue(false); + + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('scm'); + expect(result.errors[0].message).toContain('requires SCM integration'); + }); + + it('fails when persona token is missing', async () => { + vi.mocked(hasScmIntegration).mockResolvedValue(true); + vi.mocked(hasScmPersonaToken).mockResolvedValue(false); + + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('scm'); + expect(result.errors[0].message).toContain('token'); + }); + + it('checks reviewer token for review agent', async () => { + vi.mocked(getPersonaForAgentType).mockReturnValue('reviewer'); + vi.mocked(hasScmIntegration).mockResolvedValue(true); + vi.mocked(hasScmPersonaToken).mockResolvedValue(false); + + const result = await validateIntegrations('test-project', 'review'); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('Reviewer token'); + }); + }); + + describe('Email integration validation', () => { + it('passes when email integration is configured', async () => { + vi.mocked(hasEmailIntegration).mockResolvedValue(true); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('fails when email integration is missing', async () => { + vi.mocked(hasEmailIntegration).mockResolvedValue(false); + + const result = await validateIntegrations('test-project', 'email-joke'); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].category).toBe('email'); + expect(result.errors[0].message).toContain('requires email integration'); + }); + }); + + describe('multiple missing integrations', () => { + it('reports all missing integrations', async () => { + vi.mocked(hasPmIntegration).mockResolvedValue(false); + vi.mocked(hasScmIntegration).mockResolvedValue(false); + + const result = await validateIntegrations('test-project', 'implementation'); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + const categories = result.errors.map((e) => e.category); + expect(categories).toContain('pm'); + expect(categories).toContain('scm'); + }); + }); + }); + + describe('formatValidationErrors', () => { + it('returns empty string for valid result', () => { + const result = { valid: true, errors: [] }; + expect(formatValidationErrors(result)).toBe(''); + }); + + it('formats single error correctly', () => { + const result = { + valid: false, + errors: [{ category: 'pm' as const, message: 'PM integration missing' }], + }; + const formatted = formatValidationErrors(result); + expect(formatted).toContain('Integration validation failed'); + expect(formatted).toContain('PM integration missing'); + expect(formatted).toContain('Project Settings > Integrations'); + }); + + it('formats multiple errors correctly', () => { + const result = { + valid: false, + errors: [ + { category: 'pm' as const, message: 'PM integration missing' }, + { category: 'scm' as const, message: 'GitHub integration missing' }, + ], + }; + const formatted = formatValidationErrors(result); + expect(formatted).toContain('PM integration missing'); + expect(formatted).toContain('GitHub integration missing'); + }); + }); +});