diff --git a/src/agents/base.ts b/src/agents/base.ts index c0d73819..4c1a6dd2 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -30,6 +30,7 @@ import { type Todo, formatTodoList, initTodoSession, saveTodos } from '../gadget import { getPMProvider } from '../pm/index.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; import { logger } from '../utils/logging.js'; +import { extractPRUrl } from '../utils/prUrl.js'; import type { PromptContext } from './prompts/index.js'; import { type BuilderType, createConfiguredBuilder } from './shared/builderFactory.js'; import { type FileLogger, executeAgentLifecycle } from './shared/lifecycle.js'; @@ -511,11 +512,6 @@ async function setupWorkingDirectory( return setupRepository(project, log, agentType, prBranch); } -function extractPRUrl(output: string): string | undefined { - const match = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/); - return match ? match[0] : undefined; -} - export async function executeAgent( agentType: string, input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index c2fc737f..bdb1700a 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -9,6 +9,7 @@ import type { SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk'; import { logger } from '../../utils/logging.js'; +import { extractPRUrl } from '../../utils/prUrl.js'; import type { AgentBackend, AgentBackendInput, @@ -140,14 +141,6 @@ export function buildEnv(projectSecrets?: Record): { return { env }; } -/** - * Extract a GitHub PR URL from text. - */ -function extractPRUrl(text: string): string | undefined { - const match = text.match(/https:\/\/github\.com\/[^\s"')\]]+\/pull\/\d+/); - return match ? match[0] : undefined; -} - /** * Extract a GitHub PR URL from assistant messages (tool results containing create-pr output). */ diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 8ae0e9e4..184b2b52 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,4 +1,3 @@ -import { runAgent } from '../../agents/registry.js'; import { findProjectByRepo, getAgentCredential, @@ -24,115 +23,52 @@ import { setProcessing, startWatchdog, } from '../../utils/index.js'; -import { injectLlmApiKeys } from '../../utils/llmEnv.js'; import { safeOperation } from '../../utils/safeOperation.js'; import type { TriggerRegistry } from '../registry.js'; -import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; -import { checkBudgetExceeded } from '../shared/budget.js'; -import { triggerDebugAnalysis } from '../shared/debug-runner.js'; -import { shouldTriggerDebug } from '../shared/debug-trigger.js'; +import { executeAgentPipeline } from '../shared/agent-pipeline.js'; +import { withProjectCredentials } from '../shared/credential-scope.js'; import type { TriggerResult } from '../types.js'; async function executeGitHubAgent( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, -): Promise { - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); - const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); - - const restoreLlmEnv = await injectLlmApiKeys(project.id); - - try { - const pmProvider = createPMProvider(project); - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, () => - withGitHubToken(githubToken, () => executeGitHubAgentWithCreds(result, project, config)), - ), - ); - } finally { - restoreLlmEnv(); - } -} - -async function executeGitHubAgentWithCreds( - result: TriggerResult, - project: ProjectConfig, - config: CascadeConfig, ): Promise { const cardId = result.cardId ?? result.workItemId; const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - let remainingBudgetUsd: number | undefined; - if (cardId) { - const budgetCheck = await checkBudgetExceeded(cardId, project, config); - if (budgetCheck?.exceeded) { - logger.warn('Budget exceeded, GitHub agent not started', { - cardId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await lifecycle.handleBudgetExceeded(cardId, budgetCheck.currentCost, budgetCheck.budget); - return; - } - remainingBudgetUsd = budgetCheck?.remaining; - } - - const agentResult = await runAgent(result.agentType, { - ...result.agentInput, - remainingBudgetUsd, - project, - config, - }); - - if (cardId) { - await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); + await withProjectCredentials(project, result.agentType, async () => { + const agentResult = await executeAgentPipeline({ + agentType: result.agentType, + agentInput: result.agentInput, + workItemId: cardId, + project, + config, + lifecycle, + prepareLifecycle: false, // GitHub agents don't call prepareForAgent + cleanupLifecycle: false, // GitHub agents don't call cleanupProcessing + onAgentFailure: async (agentResult) => { + if (result.prNumber) { + await updateInitialCommentWithError(result, agentResult); + } + }, + }); - const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); - if (postBudgetCheck?.exceeded) { - await lifecycle.handleBudgetWarning( - cardId, - postBudgetCheck.currentCost, - postBudgetCheck.budget, - ); + // GitHub-specific: Move to in-review if implementation completed successfully + if (cardId && result.agentType === 'implementation' && agentResult.success) { + await lifecycle.handleSuccess(cardId, result.agentType, agentResult.prUrl); } - } - // Move to in-review if implementation completed successfully - if (cardId && result.agentType === 'implementation' && agentResult.success) { - await lifecycle.handleSuccess(cardId, result.agentType, agentResult.prUrl); - } - - if (!agentResult.success && result.prNumber) { - await updateInitialCommentWithError(result, agentResult); - } - - logger.info('GitHub agent completed', { - agentType: result.agentType, - prNumber: result.prNumber, - success: agentResult.success, - cost: agentResult.cost, - runId: agentResult.runId, + logger.info('GitHub agent completed', { + agentType: result.agentType, + prNumber: result.prNumber, + success: agentResult.success, + cost: agentResult.cost, + runId: agentResult.runId, + }); }); - - await tryGitHubAutoDebug(agentResult, project, config); -} - -async function tryGitHubAutoDebug( - agentResult: { runId?: string }, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - if (!agentResult.runId) return; - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } } async function updateInitialCommentWithError( diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts index e8ce4e51..1f804e54 100644 --- a/src/triggers/jira/webhook-handler.ts +++ b/src/triggers/jira/webhook-handler.ts @@ -6,14 +6,11 @@ * and dispatches to the trigger registry. */ -import { runAgent } from '../../agents/registry.js'; import { findProjectByJiraProjectKey, - getAgentCredential, getProjectSecret, loadConfig, } from '../../config/provider.js'; -import { withGitHubToken } from '../../github/client.js'; import { withJiraCredentials } from '../../jira/client.js'; import { PMLifecycleManager, @@ -31,12 +28,9 @@ import { setProcessing, startWatchdog, } from '../../utils/index.js'; -import { injectLlmApiKeys } from '../../utils/llmEnv.js'; import type { TriggerRegistry } from '../registry.js'; -import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; -import { checkBudgetExceeded } from '../shared/budget.js'; -import { triggerDebugAnalysis } from '../shared/debug-runner.js'; -import { shouldTriggerDebug } from '../shared/debug-trigger.js'; +import { executeAgentPipeline } from '../shared/agent-pipeline.js'; +import { withProjectCredentials } from '../shared/credential-scope.js'; import type { TriggerResult } from '../types.js'; interface JiraWebhookPayload { @@ -76,107 +70,22 @@ async function executeJiraAgent( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, -): Promise { - const jiraEmail = await getProjectSecret(project.id, 'JIRA_EMAIL'); - const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); - const jiraBaseUrl = - project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); - const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); - - const agentGitHubToken = await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN'); - const effectiveGithubToken = agentGitHubToken || githubToken; - - const restoreLlmEnv = await injectLlmApiKeys(project.id); - - try { - const pmProvider = createPMProvider(project); - await withJiraCredentials( - { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, - () => - withPMProvider(pmProvider, () => - withGitHubToken(effectiveGithubToken, () => - executeJiraAgentWithCreds(result, project, config), - ), - ), - ); - } finally { - restoreLlmEnv(); - } -} - -async function executeJiraAgentWithCreds( - result: TriggerResult, - project: ProjectConfig, - config: CascadeConfig, ): Promise { const workItemId = result.workItemId ?? result.cardId; const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - let remainingBudgetUsd: number | undefined; - if (workItemId) { - const budgetCheck = await checkBudgetExceeded(workItemId, project, config); - if (budgetCheck?.exceeded) { - logger.warn('Budget exceeded, JIRA agent not started', { - workItemId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await lifecycle.handleBudgetExceeded(workItemId, budgetCheck.currentCost, budgetCheck.budget); - return; - } - remainingBudgetUsd = budgetCheck?.remaining; - } - - if (workItemId) { - await lifecycle.prepareForAgent(workItemId, result.agentType); - } - - const agentResult = await runAgent(result.agentType, { - ...result.agentInput, - cardId: workItemId, - remainingBudgetUsd, - project, - config, - }); - - if (workItemId) { - await handleAgentResultArtifacts(workItemId, result.agentType, agentResult, project); - - const postBudgetCheck = await checkBudgetExceeded(workItemId, project, config); - if (postBudgetCheck?.exceeded) { - await lifecycle.handleBudgetWarning( - workItemId, - postBudgetCheck.currentCost, - postBudgetCheck.budget, - ); - } - - await lifecycle.cleanupProcessing(workItemId); - - if (agentResult.success) { - await lifecycle.handleSuccess(workItemId, result.agentType, agentResult.prUrl); - } else { - await lifecycle.handleFailure(workItemId, agentResult.error); - } - } - - logger.info('JIRA agent completed', { - agentType: result.agentType, - success: agentResult.success, - runId: agentResult.runId, - }); - - // Auto-debug - if (agentResult.runId) { - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } - } + await withProjectCredentials(project, result.agentType, () => + executeAgentPipeline({ + agentType: result.agentType, + agentInput: { ...result.agentInput, cardId: workItemId }, + workItemId, + project, + config, + lifecycle, + }), + ); } function processNextQueuedJiraWebhook(registry: TriggerRegistry): void { diff --git a/src/triggers/shared/agent-pipeline.ts b/src/triggers/shared/agent-pipeline.ts new file mode 100644 index 00000000..e8553274 --- /dev/null +++ b/src/triggers/shared/agent-pipeline.ts @@ -0,0 +1,197 @@ +/** + * Shared agent execution pipeline extracted from webhook handlers. + * Consolidates budget checking, lifecycle management, artifact handling, + * and auto-debug triggering. + */ + +import { runAgent } from '../../agents/registry.js'; +import type { PMLifecycleManager } from '../../pm/index.js'; +import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { handleAgentResultArtifacts } from './agent-result-handler.js'; +import { checkBudgetExceeded } from './budget.js'; +import { triggerDebugAnalysis } from './debug-runner.js'; +import { shouldTriggerDebug } from './debug-trigger.js'; + +/** + * Configuration options for the agent execution pipeline. + */ +export interface AgentExecutionOptions { + /** Agent type to execute */ + agentType: string; + /** Agent input parameters */ + agentInput: Record; + /** Work item ID (card ID or issue ID) */ + workItemId?: string; + /** Project configuration */ + project: ProjectConfig; + /** Cascade configuration */ + config: CascadeConfig; + /** PM lifecycle manager */ + lifecycle: PMLifecycleManager; + /** Whether to call prepareForAgent before execution (default: true) */ + prepareLifecycle?: boolean; + /** Whether to call cleanupProcessing after execution (default: true) */ + cleanupLifecycle?: boolean; + /** Custom error handler called on agent failure */ + onAgentFailure?: (agentResult: AgentResult) => Promise; +} + +/** + * Execute an agent with full pipeline orchestration: + * - Budget check (pre-execution) + * - Lifecycle preparation (optional) + * - Agent execution + * - Artifact handling + * - Budget check (post-execution) + * - Lifecycle cleanup (optional) + * - Success/failure handling + * - Auto-debug triggering + */ +export async function executeAgentPipeline(options: AgentExecutionOptions): Promise { + const { + agentType, + agentInput, + workItemId, + project, + config, + lifecycle, + prepareLifecycle = true, + cleanupLifecycle = true, + onAgentFailure, + } = options; + + // Pre-execution budget check + const remainingBudgetUsd = await checkPreExecutionBudget( + workItemId, + agentType, + project, + config, + lifecycle, + ); + if (remainingBudgetUsd === null) { + // Budget exceeded, return early with error + return { + success: false, + output: '', + error: 'Budget exceeded before agent execution', + }; + } + + // Lifecycle preparation + if (workItemId && prepareLifecycle) { + await lifecycle.prepareForAgent(workItemId, agentType); + } + + // Agent execution + const agentResult = await runAgent(agentType, { + ...agentInput, + remainingBudgetUsd, + project, + config, + }); + + // Post-execution handling + await handlePostExecution( + workItemId, + agentType, + agentResult, + project, + config, + lifecycle, + cleanupLifecycle, + onAgentFailure, + ); + + logger.info('Agent completed', { + agentType, + success: agentResult.success, + runId: agentResult.runId, + }); + + // Auto-debug + await tryAutoDebug(agentResult, project, config, workItemId); + + return agentResult; +} + +async function checkPreExecutionBudget( + workItemId: string | undefined, + agentType: string, + project: ProjectConfig, + config: CascadeConfig, + lifecycle: PMLifecycleManager, +): Promise { + if (!workItemId) return undefined; + + const budgetCheck = await checkBudgetExceeded(workItemId, project, config); + if (budgetCheck?.exceeded) { + logger.warn('Budget exceeded, agent not started', { + workItemId, + agentType, + currentCost: budgetCheck.currentCost, + budget: budgetCheck.budget, + }); + await lifecycle.handleBudgetExceeded(workItemId, budgetCheck.currentCost, budgetCheck.budget); + return null; // Signal budget exceeded + } + return budgetCheck?.remaining; +} + +async function handlePostExecution( + workItemId: string | undefined, + agentType: string, + agentResult: AgentResult, + project: ProjectConfig, + config: CascadeConfig, + lifecycle: PMLifecycleManager, + cleanupLifecycle: boolean, + onAgentFailure: ((agentResult: AgentResult) => Promise) | undefined, +): Promise { + if (!workItemId) return; + + await handleAgentResultArtifacts(workItemId, agentType, agentResult, project); + + // Post-execution budget check + const postBudgetCheck = await checkBudgetExceeded(workItemId, project, config); + if (postBudgetCheck?.exceeded) { + await lifecycle.handleBudgetWarning( + workItemId, + postBudgetCheck.currentCost, + postBudgetCheck.budget, + ); + } + + // Lifecycle cleanup + if (cleanupLifecycle) { + await lifecycle.cleanupProcessing(workItemId); + } + + // Success/failure handling + if (agentResult.success) { + await lifecycle.handleSuccess(workItemId, agentType, agentResult.prUrl); + } else { + await lifecycle.handleFailure(workItemId, agentResult.error); + if (onAgentFailure) { + await onAgentFailure(agentResult); + } + } +} + +async function tryAutoDebug( + agentResult: AgentResult, + project: ProjectConfig, + config: CascadeConfig, + workItemId?: string, +): Promise { + if (!agentResult.runId) return; + const debugTarget = await shouldTriggerDebug(agentResult.runId); + if (debugTarget) { + triggerDebugAnalysis( + debugTarget.runId, + project, + config, + debugTarget.cardId ?? workItemId, + ).catch((err) => logger.error('Auto-debug failed', { error: String(err) })); + } +} diff --git a/src/triggers/shared/credential-scope.ts b/src/triggers/shared/credential-scope.ts new file mode 100644 index 00000000..333c043b --- /dev/null +++ b/src/triggers/shared/credential-scope.ts @@ -0,0 +1,70 @@ +/** + * Shared credential scoping helper. + * Resolves secrets and wraps execution in the correct credential scope + * based on PM provider type (Trello, JIRA, or GitHub-only). + */ + +import { getAgentCredential, getProjectSecret } from '../../config/provider.js'; +import { withGitHubToken } from '../../github/client.js'; +import { withJiraCredentials } from '../../jira/client.js'; +import { createPMProvider, withPMProvider } from '../../pm/index.js'; +import { withTrelloCredentials } from '../../trello/client.js'; +import type { ProjectConfig } from '../../types/index.js'; +import { injectLlmApiKeys } from '../../utils/llmEnv.js'; + +/** + * Execute a function with project credentials resolved and scoped. + * Automatically selects the correct credential wrapper based on PM provider type. + */ +export async function withProjectCredentials( + project: ProjectConfig, + agentType: string, + fn: () => Promise, +): Promise { + const pmType = project.trello ? 'trello' : project.jira ? 'jira' : 'github-only'; + + // Resolve GitHub token with optional agent-specific override + const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); + const agentGitHubToken = await getAgentCredential(project.id, agentType, 'GITHUB_TOKEN'); + const effectiveGithubToken = agentGitHubToken || githubToken; + + // Inject LLM API keys + const restoreLlmEnv = await injectLlmApiKeys(project.id); + + try { + const pmProvider = createPMProvider(project); + + if (pmType === 'trello') { + // Trello-based PM provider + const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); + const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + + return await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => + withPMProvider(pmProvider, () => withGitHubToken(effectiveGithubToken, fn)), + ); + } + + if (pmType === 'jira') { + // JIRA-based PM provider + const jiraEmail = await getProjectSecret(project.id, 'JIRA_EMAIL'); + const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); + const jiraBaseUrl = + project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); + + return await withJiraCredentials( + { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, + () => withPMProvider(pmProvider, () => withGitHubToken(effectiveGithubToken, fn)), + ); + } + + // GitHub-only (no PM provider credentials needed, but may have Trello for optional integrations) + const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); + const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); + + return await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => + withPMProvider(pmProvider, () => withGitHubToken(effectiveGithubToken, fn)), + ); + } finally { + restoreLlmEnv(); + } +} diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 8742ff11..49a72a3b 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -1,11 +1,4 @@ -import { runAgent } from '../../agents/registry.js'; -import { - findProjectByBoardId, - getAgentCredential, - getProjectSecret, - loadConfig, -} from '../../config/provider.js'; -import { withGitHubToken } from '../../github/client.js'; +import { findProjectByBoardId, getProjectSecret, loadConfig } from '../../config/provider.js'; import { PMLifecycleManager, createPMProvider, @@ -13,12 +6,7 @@ import { withPMProvider, } from '../../pm/index.js'; import { withTrelloCredentials } from '../../trello/client.js'; -import type { - AgentResult, - CascadeConfig, - ProjectConfig, - TriggerContext, -} from '../../types/index.js'; +import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; import { clearCardActive, dequeueWebhook, @@ -31,12 +19,9 @@ import { setProcessing, startWatchdog, } from '../../utils/index.js'; -import { injectLlmApiKeys } from '../../utils/llmEnv.js'; import type { TriggerRegistry } from '../registry.js'; -import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; -import { checkBudgetExceeded } from '../shared/budget.js'; -import { triggerDebugAnalysis } from '../shared/debug-runner.js'; -import { shouldTriggerDebug } from '../shared/debug-trigger.js'; +import { executeAgentPipeline } from '../shared/agent-pipeline.js'; +import { withProjectCredentials } from '../shared/credential-scope.js'; import type { TrelloWebhookPayload, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload } from '../types.js'; @@ -48,107 +33,26 @@ async function executeAgent( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, -): Promise { - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); - const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); - - const agentGitHubToken = await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN'); - const effectiveGithubToken = agentGitHubToken || githubToken; - - const restoreLlmEnv = await injectLlmApiKeys(project.id); - - try { - const pmProvider = createPMProvider(project); - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, () => - withGitHubToken(effectiveGithubToken, () => executeAgentWithCreds(result, project, config)), - ), - ); - } finally { - restoreLlmEnv(); - } -} - -async function executeAgentWithCreds( - result: TriggerResult, - project: ProjectConfig, - config: CascadeConfig, ): Promise { const cardId = result.cardId ?? result.workItemId; const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - let remainingBudgetUsd: number | undefined; - if (cardId) { - const budgetCheck = await checkBudgetExceeded(cardId, project, config); - if (budgetCheck?.exceeded) { - logger.warn('Budget exceeded, agent not started', { - cardId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await lifecycle.handleBudgetExceeded(cardId, budgetCheck.currentCost, budgetCheck.budget); - return; - } - remainingBudgetUsd = budgetCheck?.remaining; - } - if (cardId) { setCardActive(cardId); - await lifecycle.prepareForAgent(cardId, result.agentType); - } - - const agentResult = await runAgent(result.agentType, { - ...result.agentInput, - remainingBudgetUsd, - project, - config, - }); - - if (cardId) { - await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); - - const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); - if (postBudgetCheck?.exceeded) { - await lifecycle.handleBudgetWarning( - cardId, - postBudgetCheck.currentCost, - postBudgetCheck.budget, - ); - } - - await lifecycle.cleanupProcessing(cardId); - - if (agentResult.success) { - await lifecycle.handleSuccess(cardId, result.agentType, agentResult.prUrl); - } else { - await lifecycle.handleFailure(cardId, agentResult.error); - } } - logger.info('Agent completed', { - agentType: result.agentType, - success: agentResult.success, - runId: agentResult.runId, - }); - - await tryAutoDebug(agentResult, project, config); -} - -async function tryAutoDebug( - agentResult: AgentResult, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - if (!agentResult.runId) return; - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } + await withProjectCredentials(project, result.agentType, () => + executeAgentPipeline({ + agentType: result.agentType, + agentInput: result.agentInput, + workItemId: cardId, + project, + config, + lifecycle, + }), + ); } // ============================================================================ diff --git a/src/utils/prUrl.ts b/src/utils/prUrl.ts new file mode 100644 index 00000000..32bd58a9 --- /dev/null +++ b/src/utils/prUrl.ts @@ -0,0 +1,9 @@ +/** + * Extract a GitHub PR URL from text output. + * Supports standard GitHub PR URLs with improved pattern matching. + */ +export function extractPRUrl(text: string): string | undefined { + // Match GitHub PR URLs, excluding trailing punctuation and quotes + const match = text.match(/https:\/\/github\.com\/[^\s"')\]]+\/pull\/\d+/); + return match ? match[0] : undefined; +} diff --git a/tests/unit/triggers/agent-pipeline.test.ts b/tests/unit/triggers/agent-pipeline.test.ts new file mode 100644 index 00000000..6c45758d --- /dev/null +++ b/tests/unit/triggers/agent-pipeline.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/agents/registry.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/agent-result-handler.js', () => ({ + handleAgentResultArtifacts: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/budget.js', () => ({ + checkBudgetExceeded: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/debug-runner.js', () => ({ + triggerDebugAnalysis: vi.fn(() => Promise.resolve()), +})); + +vi.mock('../../../src/triggers/shared/debug-trigger.js', () => ({ + shouldTriggerDebug: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { runAgent } from '../../../src/agents/registry.js'; +import { executeAgentPipeline } from '../../../src/triggers/shared/agent-pipeline.js'; +import { handleAgentResultArtifacts } from '../../../src/triggers/shared/agent-result-handler.js'; +import { checkBudgetExceeded } from '../../../src/triggers/shared/budget.js'; +import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner.js'; +import { shouldTriggerDebug } from '../../../src/triggers/shared/debug-trigger.js'; +import type { AgentResult, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; + +const mockLifecycle = { + prepareForAgent: vi.fn(), + cleanupProcessing: vi.fn(), + handleBudgetExceeded: vi.fn(), + handleBudgetWarning: vi.fn(), + handleSuccess: vi.fn(), + handleFailure: vi.fn(), + handleError: vi.fn(), +}; + +const baseProject: ProjectConfig = { + id: 'test-project', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + }, +}; + +const baseConfig: CascadeConfig = { + defaults: { + model: 'test-model', + agentModels: {}, + maxIterations: 50, + agentIterations: {}, + watchdogTimeoutMs: 1800000, + cardBudgetUsd: 5, + agentBackend: 'llmist', + progressModel: 'test-progress-model', + progressIntervalMinutes: 5, + }, + projects: [baseProject], +}; + +describe('executeAgentPipeline', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('executes full pipeline with successful agent', async () => { + const agentResult: AgentResult = { + success: true, + output: 'Agent completed', + runId: 'run-123', + prUrl: 'https://github.com/owner/repo/pull/1', + }; + + vi.mocked(checkBudgetExceeded).mockResolvedValue({ + exceeded: false, + currentCost: 1.0, + budget: 5.0, + remaining: 4.0, + }); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + const result = await executeAgentPipeline({ + agentType: 'implementation', + agentInput: { foo: 'bar' }, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + }); + + expect(result).toBe(agentResult); + expect(checkBudgetExceeded).toHaveBeenCalledWith('card-1', baseProject, baseConfig); + expect(mockLifecycle.prepareForAgent).toHaveBeenCalledWith('card-1', 'implementation'); + expect(runAgent).toHaveBeenCalledWith('implementation', { + foo: 'bar', + remainingBudgetUsd: 4.0, + project: baseProject, + config: baseConfig, + }); + expect(handleAgentResultArtifacts).toHaveBeenCalledWith( + 'card-1', + 'implementation', + agentResult, + baseProject, + ); + expect(mockLifecycle.cleanupProcessing).toHaveBeenCalledWith('card-1'); + expect(mockLifecycle.handleSuccess).toHaveBeenCalledWith( + 'card-1', + 'implementation', + 'https://github.com/owner/repo/pull/1', + ); + }); + + it('skips lifecycle preparation when prepareLifecycle=false', async () => { + const agentResult: AgentResult = { success: true, output: '', runId: 'run-123' }; + + vi.mocked(checkBudgetExceeded).mockResolvedValue(null); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + await executeAgentPipeline({ + agentType: 'respond-to-review', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + prepareLifecycle: false, + }); + + expect(mockLifecycle.prepareForAgent).not.toHaveBeenCalled(); + }); + + it('skips lifecycle cleanup when cleanupLifecycle=false', async () => { + const agentResult: AgentResult = { success: true, output: '', runId: 'run-123' }; + + vi.mocked(checkBudgetExceeded).mockResolvedValue(null); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + await executeAgentPipeline({ + agentType: 'respond-to-review', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + cleanupLifecycle: false, + }); + + expect(mockLifecycle.cleanupProcessing).not.toHaveBeenCalled(); + }); + + it('calls onAgentFailure when agent fails', async () => { + const agentResult: AgentResult = { + success: false, + output: '', + error: 'Agent failed', + runId: 'run-123', + }; + const onAgentFailure = vi.fn(); + + vi.mocked(checkBudgetExceeded).mockResolvedValue(null); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + await executeAgentPipeline({ + agentType: 'implementation', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + onAgentFailure, + }); + + expect(mockLifecycle.handleFailure).toHaveBeenCalledWith('card-1', 'Agent failed'); + expect(onAgentFailure).toHaveBeenCalledWith(agentResult); + }); + + it('aborts when pre-execution budget exceeded', async () => { + vi.mocked(checkBudgetExceeded).mockResolvedValue({ + exceeded: true, + currentCost: 6.0, + budget: 5.0, + remaining: 0, + }); + + const result = await executeAgentPipeline({ + agentType: 'implementation', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Budget exceeded'); + expect(mockLifecycle.handleBudgetExceeded).toHaveBeenCalledWith('card-1', 6.0, 5.0); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it('triggers budget warning when post-execution budget exceeded', async () => { + const agentResult: AgentResult = { success: true, output: '', runId: 'run-123' }; + + vi.mocked(checkBudgetExceeded) + .mockResolvedValueOnce({ exceeded: false, currentCost: 3.0, budget: 5.0, remaining: 2.0 }) + .mockResolvedValueOnce({ exceeded: true, currentCost: 5.5, budget: 5.0, remaining: 0 }); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + await executeAgentPipeline({ + agentType: 'implementation', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + }); + + expect(mockLifecycle.handleBudgetWarning).toHaveBeenCalledWith('card-1', 5.5, 5.0); + }); + + it('triggers debug analysis when shouldTriggerDebug returns target', async () => { + const agentResult: AgentResult = { + success: false, + output: '', + error: 'Failed', + runId: 'run-123', + }; + + vi.mocked(checkBudgetExceeded).mockResolvedValue(null); + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue({ + runId: 'run-123', + agentType: 'implementation', + cardId: 'card-1', + }); + + await executeAgentPipeline({ + agentType: 'implementation', + agentInput: {}, + workItemId: 'card-1', + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + }); + + expect(triggerDebugAnalysis).toHaveBeenCalledWith('run-123', baseProject, baseConfig, 'card-1'); + }); + + it('executes without workItemId', async () => { + const agentResult: AgentResult = { success: true, output: '', runId: 'run-123' }; + + vi.mocked(runAgent).mockResolvedValue(agentResult); + vi.mocked(shouldTriggerDebug).mockResolvedValue(null); + + await executeAgentPipeline({ + agentType: 'review', + agentInput: { prNumber: 42 }, + project: baseProject, + config: baseConfig, + lifecycle: mockLifecycle as any, + }); + + expect(checkBudgetExceeded).not.toHaveBeenCalled(); + expect(mockLifecycle.prepareForAgent).not.toHaveBeenCalled(); + expect(handleAgentResultArtifacts).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith('review', { + prNumber: 42, + remainingBudgetUsd: undefined, + project: baseProject, + config: baseConfig, + }); + }); +}); diff --git a/tests/unit/triggers/credential-scope.test.ts b/tests/unit/triggers/credential-scope.test.ts new file mode 100644 index 00000000..db895c60 --- /dev/null +++ b/tests/unit/triggers/credential-scope.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/provider.js', () => ({ + getProjectSecret: vi.fn(), + getAgentCredential: vi.fn(), +})); + +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn((token, fn) => fn()), +})); + +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn((creds, fn) => fn()), +})); + +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn((creds, fn) => fn()), +})); + +vi.mock('../../../src/pm/index.js', () => ({ + createPMProvider: vi.fn(() => ({})), + withPMProvider: vi.fn((provider, fn) => fn()), +})); + +vi.mock('../../../src/utils/llmEnv.js', () => ({ + injectLlmApiKeys: vi.fn(() => Promise.resolve(() => {})), +})); + +import { getAgentCredential, getProjectSecret } from '../../../src/config/provider.js'; +import { withGitHubToken } from '../../../src/github/client.js'; +import { withJiraCredentials } from '../../../src/jira/client.js'; +import { withPMProvider } from '../../../src/pm/index.js'; +import { withTrelloCredentials } from '../../../src/trello/client.js'; +import { withProjectCredentials } from '../../../src/triggers/shared/credential-scope.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; +import { injectLlmApiKeys } from '../../../src/utils/llmEnv.js'; + +const trelloProject: ProjectConfig = { + id: 'trello-project', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + }, +}; + +const jiraProject: ProjectConfig = { + id: 'jira-project', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + projectKey: 'PROJ', + baseUrl: 'https://company.atlassian.net', + lists: {}, + labels: {}, + }, +}; + +const githubOnlyProject: ProjectConfig = { + id: 'github-project', + name: 'GitHub Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', +}; + +describe('withProjectCredentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getProjectSecret).mockImplementation((projectId, key) => { + const secrets: Record> = { + 'trello-project': { + TRELLO_API_KEY: 'trello-key', + TRELLO_TOKEN: 'trello-token', + GITHUB_TOKEN: 'github-token', + }, + 'jira-project': { + JIRA_EMAIL: 'user@example.com', + JIRA_API_TOKEN: 'jira-token', + JIRA_BASE_URL: 'https://company.atlassian.net', + GITHUB_TOKEN: 'github-token', + }, + 'github-project': { + GITHUB_TOKEN: 'github-token', + TRELLO_API_KEY: '', + TRELLO_TOKEN: '', + }, + }; + return Promise.resolve(secrets[projectId]?.[key] ?? ''); + }); + vi.mocked(getAgentCredential).mockResolvedValue(null); + }); + + it('uses Trello credentials for Trello project', async () => { + const fn = vi.fn().mockResolvedValue('result'); + + const result = await withProjectCredentials(trelloProject, 'implementation', fn); + + expect(result).toBe('result'); + expect(injectLlmApiKeys).toHaveBeenCalledWith('trello-project'); + expect(withTrelloCredentials).toHaveBeenCalledWith( + { apiKey: 'trello-key', token: 'trello-token' }, + expect.any(Function), + ); + expect(withPMProvider).toHaveBeenCalled(); + expect(withGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + expect(fn).toHaveBeenCalled(); + }); + + it('uses JIRA credentials for JIRA project', async () => { + const fn = vi.fn().mockResolvedValue('result'); + + const result = await withProjectCredentials(jiraProject, 'implementation', fn); + + expect(result).toBe('result'); + expect(injectLlmApiKeys).toHaveBeenCalledWith('jira-project'); + expect(withJiraCredentials).toHaveBeenCalledWith( + { + email: 'user@example.com', + apiToken: 'jira-token', + baseUrl: 'https://company.atlassian.net', + }, + expect.any(Function), + ); + expect(withPMProvider).toHaveBeenCalled(); + expect(withGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + expect(fn).toHaveBeenCalled(); + }); + + it('uses GitHub-only credentials for GitHub project', async () => { + const fn = vi.fn().mockResolvedValue('result'); + + const result = await withProjectCredentials(githubOnlyProject, 'review', fn); + + expect(result).toBe('result'); + expect(injectLlmApiKeys).toHaveBeenCalledWith('github-project'); + expect(withTrelloCredentials).toHaveBeenCalledWith( + { apiKey: '', token: '' }, + expect.any(Function), + ); + expect(withPMProvider).toHaveBeenCalled(); + expect(withGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + expect(fn).toHaveBeenCalled(); + }); + + it('uses agent-specific GitHub token override', async () => { + vi.mocked(getAgentCredential).mockResolvedValue('agent-github-token'); + const fn = vi.fn().mockResolvedValue('result'); + + await withProjectCredentials(trelloProject, 'review', fn); + + expect(getAgentCredential).toHaveBeenCalledWith('trello-project', 'review', 'GITHUB_TOKEN'); + expect(withGitHubToken).toHaveBeenCalledWith('agent-github-token', expect.any(Function)); + }); + + it('falls back to project GitHub token when no agent override', async () => { + vi.mocked(getAgentCredential).mockResolvedValue(null); + const fn = vi.fn().mockResolvedValue('result'); + + await withProjectCredentials(trelloProject, 'implementation', fn); + + expect(withGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + }); + + it('restores LLM environment after execution', async () => { + const restoreFn = vi.fn(); + vi.mocked(injectLlmApiKeys).mockResolvedValue(restoreFn); + const fn = vi.fn().mockResolvedValue('result'); + + await withProjectCredentials(trelloProject, 'implementation', fn); + + expect(restoreFn).toHaveBeenCalled(); + }); + + it('restores LLM environment even if function throws', async () => { + const restoreFn = vi.fn(); + vi.mocked(injectLlmApiKeys).mockResolvedValue(restoreFn); + const fn = vi.fn().mockRejectedValue(new Error('Test error')); + + await expect(withProjectCredentials(trelloProject, 'implementation', fn)).rejects.toThrow( + 'Test error', + ); + + expect(restoreFn).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/prUrl.test.ts b/tests/unit/utils/prUrl.test.ts new file mode 100644 index 00000000..cc316852 --- /dev/null +++ b/tests/unit/utils/prUrl.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { extractPRUrl } from '../../../src/utils/prUrl.js'; + +describe('extractPRUrl', () => { + it('extracts GitHub PR URL from text', () => { + const text = 'Created PR: https://github.com/owner/repo/pull/123'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/123'); + }); + + it('extracts PR URL with trailing punctuation', () => { + const text = 'See https://github.com/owner/repo/pull/456.'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/456'); + }); + + it('extracts PR URL surrounded by quotes', () => { + const text = 'Link: "https://github.com/owner/repo/pull/789"'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/789'); + }); + + it('extracts PR URL in parentheses', () => { + const text = 'Check (https://github.com/owner/repo/pull/999)'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/999'); + }); + + it('extracts PR URL in markdown link', () => { + const text = '[PR](https://github.com/owner/repo/pull/111)'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/111'); + }); + + it('extracts first PR URL when multiple are present', () => { + const text = + 'First: https://github.com/owner/repo/pull/1 Second: https://github.com/owner/repo/pull/2'; + expect(extractPRUrl(text)).toBe('https://github.com/owner/repo/pull/1'); + }); + + it('returns undefined for non-PR GitHub URLs', () => { + expect(extractPRUrl('https://github.com/owner/repo')).toBeUndefined(); + expect(extractPRUrl('https://github.com/owner/repo/issues/123')).toBeUndefined(); + }); + + it('returns undefined when no URL present', () => { + expect(extractPRUrl('No URL here')).toBeUndefined(); + expect(extractPRUrl('')).toBeUndefined(); + }); + + it('handles URLs with hyphenated owner and repo names', () => { + const text = 'PR: https://github.com/my-org/my-repo/pull/42'; + expect(extractPRUrl(text)).toBe('https://github.com/my-org/my-repo/pull/42'); + }); + + it('handles URLs with underscores in names', () => { + const text = 'PR: https://github.com/my_org/my_repo/pull/100'; + expect(extractPRUrl(text)).toBe('https://github.com/my_org/my_repo/pull/100'); + }); +});