diff --git a/CLAUDE.md b/CLAUDE.md index 1c0939c9..3b88820a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -489,9 +489,9 @@ cascade agents update --max-iterations 30 cascade agents delete --yes # Webhooks -cascade webhooks list -cascade webhooks create --callback-url https://cascade.example.com -cascade webhooks delete --callback-url https://cascade.example.com +cascade webhooks list [--github-token ghp_xxx] +cascade webhooks create [--callback-url URL] [--github-token ghp_xxx] +cascade webhooks delete [--callback-url URL] [--github-token ghp_xxx] ``` ### Global Flags diff --git a/Dockerfile.worker b/Dockerfile.worker index 0fb43bba..4e068eeb 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -16,7 +16,7 @@ FROM zbigniew1/niu-browser-base:latest AS production WORKDIR /app # Install pnpm and squint globally (some repos use pnpm, squint for codebase analysis) -RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.7.0 --force +RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.10.2 --force # Install additional tools not in niu-browser-base # Note: PostgreSQL is NOT installed - workers connect to external PostgreSQL diff --git a/src/agents/shared/executionPipeline.ts b/src/agents/shared/executionPipeline.ts new file mode 100644 index 00000000..876a5366 --- /dev/null +++ b/src/agents/shared/executionPipeline.ts @@ -0,0 +1,274 @@ +import { captureException } from '../../sentry.js'; +import type { AgentResult } from '../../types/index.js'; +import { loadCascadeEnv, unloadCascadeEnv } from '../../utils/cascadeEnv.js'; +import { createFileLogger } from '../../utils/fileLogger.js'; +import { setWatchdogCleanup } from '../../utils/lifecycle.js'; +import { logger } from '../../utils/logging.js'; +import { setupRemoteSquintDb } from '../../utils/squintDb.js'; +import { createAgentLogger } from '../utils/logging.js'; +import { cleanupAgentResources } from './cleanup.js'; +import type { RunTrackingInput } from './runTracking.js'; +import { tryCreateRun } from './runTracking.js'; + +export type FileLogger = ReturnType; +export type AgentLogger = ReturnType; + +/** + * A LogWriter that writes to both the file logger and the structured logger. + */ +export type LogWriter = (level: string, message: string, context?: Record) => void; + +/** + * Creates a LogWriter that forwards to both the file logger and the structured logger. + */ +export function createLogWriter(fileLogger: FileLogger): LogWriter { + return (level: string, message: string, context?: Record) => { + fileLogger.write(level, message, context); + const logFn = + level === 'ERROR' + ? logger.error + : level === 'WARN' + ? logger.warn + : level === 'DEBUG' + ? logger.debug + : logger.info; + logFn.call(logger, message, context); + }; +} + +/** + * Context passed to the execute callback. + */ +export interface PipelineContext { + repoDir: string; + fileLogger: FileLogger; + logWriter: LogWriter; + runId: string | undefined; + /** + * Update the pipeline's runId. Call this when the execute callback creates + * the run record itself (e.g., after resolving model/maxIterations). + * The updated runId is used for finalizeRun and in the error path. + */ + setRunId: (id: string) => void; +} + +/** + * Result returned by the execute callback. + */ +export interface ExecutionResult { + success: boolean; + output: string; + error?: string; + cost?: number; + prUrl?: string; + progressCommentId?: string; + /** Log buffer from the execution, if available from the execute callback */ + logBuffer?: Buffer; + /** + * Additional metadata to pass to finalizeRun. + * Useful for backend-specific finalization fields (e.g., llmIterations, gadgetCalls). + */ + finalizeMetadata?: Record; +} + +/** + * Options for the shared agent execution pipeline. + */ +export interface AgentPipelineOptions { + /** + * Identifier for log file naming (e.g., "review-42", "ci-42"). + */ + loggerIdentifier: string; + + /** + * Set up the working directory (clone repo, etc.). + */ + setupRepoDir: (log: AgentLogger) => Promise; + + /** + * Run tracking configuration. When set, creates a DB run record before execution. + */ + runTracking?: RunTrackingInput & { model?: string; maxIterations?: number }; + + /** + * Remote Squint DB URL for projects that don't commit .squint.db. + */ + squintDbUrl?: string; + + /** + * Whether the repoDir was pre-existing (skip deletion on cleanup). + * When true, skips temp dir deletion in cleanup. + */ + skipRepoDeletion?: boolean; + + /** + * The backend-specific execution step. + * Receives the pipeline context and returns the execution result. + * The pipeline handles CWD change/restore around this callback. + */ + execute: (ctx: PipelineContext) => Promise; + + /** + * Called when the watchdog timer expires. + * FileLogger is already closed when this is invoked. + * Runs inside the watchdog cleanup — keep it fast and non-throwing. + */ + onWatchdogTimeout?: (fileLogger: FileLogger, runId?: string) => Promise; + + /** + * Finalize the run record (store logs, mark complete). + * Called with the outcome of execution (success or error). + */ + finalizeRun: ( + runId: string | undefined, + fileLogger: FileLogger, + outcome: FinalizeRunOutcome, + ) => Promise; +} + +/** + * Outcome passed to finalizeRun. + */ +export interface FinalizeRunOutcome { + status: 'completed' | 'failed' | 'timed_out'; + durationMs: number; + success: boolean; + error?: string; + costUsd?: number; + prUrl?: string; + outputSummary?: string; + /** Additional backend-specific metadata (e.g., llmIterations, gadgetCalls for llmist) */ + metadata?: Record; +} + +/** + * Shared agent execution scaffold used by both the llmist lifecycle and + * the Claude Code backend adapter. + * + * Handles: FileLogger → Watchdog → Repo setup → Env snapshot → Squint DB → + * Run tracking → CWD change → Execute → Restore CWD → Finalize run → Cleanup. + * + * The only divergent step is the `execute` callback. + */ +export async function executeAgentPipeline(options: AgentPipelineOptions): Promise { + let repoDir: string | null = null; + let runId: string | undefined; + const startTime = Date.now(); + + const fileLogger = createFileLogger(`cascade-${options.loggerIdentifier}`); + const log = createAgentLogger(fileLogger); + const logWriter = createLogWriter(fileLogger); + + setWatchdogCleanup(async () => { + const durationMs = Date.now() - startTime; + captureException(new Error('Agent watchdog timeout'), { + tags: { source: 'watchdog_timeout', agent: options.loggerIdentifier }, + extra: { runId, durationMs }, + }); + fileLogger.close(); + await options.finalizeRun(runId, fileLogger, { + status: 'timed_out', + durationMs, + success: false, + error: 'Watchdog timeout', + }); + await options.onWatchdogTimeout?.(fileLogger, runId); + }); + + try { + repoDir = await options.setupRepoDir(log); + const envSnapshot = loadCascadeEnv(repoDir, log); + const squintCleanup = await setupRemoteSquintDb( + repoDir, + { squintDbUrl: options.squintDbUrl }, + log, + ); + + if (options.runTracking) { + runId = await tryCreateRun( + options.runTracking, + options.runTracking.model, + options.runTracking.maxIterations, + ); + } + + const originalCwd = process.cwd(); + process.chdir(repoDir); + + let result: ExecutionResult; + try { + result = await options.execute({ + repoDir, + fileLogger, + logWriter, + runId, + setRunId: (id) => { + runId = id; + }, + }); + } finally { + process.chdir(originalCwd); + squintCleanup?.(); + unloadCascadeEnv(envSnapshot); + } + + // runId may have been updated by setRunId() inside execute + const effectiveRunId = runId; + + fileLogger.close(); + const logBuffer = result.logBuffer ?? (await fileLogger.getZippedBuffer()); + + const durationMs = Date.now() - startTime; + await options.finalizeRun(effectiveRunId, fileLogger, { + status: result.success ? 'completed' : 'failed', + durationMs, + success: result.success, + error: result.error, + costUsd: result.cost, + prUrl: result.prUrl, + outputSummary: result.output.slice(0, 500), + metadata: result.finalizeMetadata, + }); + + return { + success: result.success, + output: result.output, + prUrl: result.prUrl, + progressCommentId: result.progressCommentId, + error: result.error, + cost: result.cost, + logBuffer: logBuffer ?? undefined, + runId: effectiveRunId, + durationMs, + }; + } catch (err) { + logger.error('Agent execution failed', { + identifier: options.loggerIdentifier, + error: String(err), + }); + captureException(err, { + tags: { source: 'agent_execution', agent: options.loggerIdentifier }, + extra: { runId, durationMs: Date.now() - startTime }, + }); + + let logBuffer: Buffer | undefined; + try { + fileLogger.close(); + logBuffer = await fileLogger.getZippedBuffer(); + } catch { + // Ignore log buffer errors + } + + const durationMs = Date.now() - startTime; + await options.finalizeRun(runId, fileLogger, { + status: 'failed', + durationMs, + success: false, + error: String(err), + }); + + return { success: false, output: '', error: String(err), logBuffer, runId, durationMs }; + } finally { + cleanupAgentResources(repoDir, fileLogger, options.skipRepoDeletion ?? false); + } +} diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts index 6ef06947..8c5a2c2a 100644 --- a/src/agents/shared/lifecycle.ts +++ b/src/agents/shared/lifecycle.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; + import { LLMist, type ModelSpec, createLogger } from 'llmist'; import type { ProgressMonitor } from '../../backends/progressMonitor.js'; @@ -8,24 +9,24 @@ import { storeLlmCallsBulk, storeRunLogs, } from '../../db/repositories/runsRepository.js'; -import { addBreadcrumb, captureException } from '../../sentry.js'; +import { addBreadcrumb } from '../../sentry.js'; import type { AgentResult } from '../../types/index.js'; -import { loadCascadeEnv, unloadCascadeEnv } from '../../utils/cascadeEnv.js'; -import { createFileLogger } from '../../utils/fileLogger.js'; -import { setWatchdogCleanup } from '../../utils/lifecycle.js'; import { logger } from '../../utils/logging.js'; -import { setupRemoteSquintDb } from '../../utils/squintDb.js'; import { runAgentLoop } from '../utils/agentLoop.js'; import type { AccumulatedLlmCall } from '../utils/hooks.js'; import { getLogLevel } from '../utils/index.js'; import { createAgentLogger } from '../utils/logging.js'; -import { type TrackingContext, createTrackingContext } from '../utils/tracking.js'; +import { createTrackingContext } from '../utils/tracking.js'; import type { BuilderType } from './builderFactory.js'; -import { cleanupAgentResources } from './cleanup.js'; -import { type RunTrackingInput, tryCompleteRun, tryCreateRun } from './runTracking.js'; - -type FileLogger = ReturnType; -type AgentLogger = ReturnType; +import { + type AgentLogger, + type FileLogger, + type FinalizeRunOutcome, + type PipelineContext, + executeAgentPipeline, +} from './executionPipeline.js'; +import type { RunTrackingInput } from './runTracking.js'; +import { tryCompleteRun, tryCreateRun } from './runTracking.js'; export type { FileLogger, AgentLogger }; @@ -56,7 +57,7 @@ export interface ExecuteAgentOptions { client: LLMist; ctx: TContext; llmistLogger: ReturnType; - trackingContext: TrackingContext; + trackingContext: ReturnType; fileLogger: FileLogger; repoDir: string; progressMonitor: ProgressMonitor | null; @@ -69,7 +70,7 @@ export interface ExecuteAgentOptions { injectSyntheticCalls: (params: { builder: BuilderType; ctx: TContext; - trackingContext: TrackingContext; + trackingContext: ReturnType; repoDir: string; }) => Promise; @@ -172,11 +173,7 @@ async function tryStoreLogsAndCalls( } } -// ============================================================================ -// Run Finalization Helper -// ============================================================================ - -async function finalizeRun( +async function finalizeRunWithLlmCalls( runId: string | undefined, fileLogger: FileLogger, llmCallAccumulator: AccumulatedLlmCall[], @@ -188,37 +185,6 @@ async function finalizeRun( await tryCompleteRun(runId, input); } -function buildAgentResult( - result: Awaited>, - logBuffer: Buffer, - runId: string | undefined, - durationMs: number, - postProcess?: (output: string) => Partial, -): AgentResult { - if (result.loopTerminated) { - return { - success: false, - output: result.output, - error: 'Agent terminated due to persistent loop', - logBuffer, - cost: result.cost, - runId, - durationMs, - }; - } - - const postProcessed = postProcess?.(result.output) ?? {}; - return { - success: true, - output: result.output, - logBuffer, - cost: result.cost, - runId, - durationMs, - ...postProcessed, - }; -} - // ============================================================================ // Main Lifecycle // ============================================================================ @@ -230,68 +196,69 @@ function buildAgentResult( export async function executeAgentLifecycle( options: ExecuteAgentOptions, ): Promise { - let repoDir: string | null = null; - let runId: string | undefined; - const startTime = Date.now(); const llmCallAccumulator: AccumulatedLlmCall[] = []; - const fileLogger = createFileLogger(`cascade-${options.loggerIdentifier}`); - const log = createAgentLogger(fileLogger); - - setWatchdogCleanup(async () => { - const durationMs = Date.now() - startTime; - captureException(new Error('Agent watchdog timeout'), { - tags: { source: 'watchdog_timeout', agent: options.loggerIdentifier }, - extra: { runId, durationMs }, - }); - fileLogger.close(); - await finalizeRun( - runId, - fileLogger, - llmCallAccumulator, - { - status: 'timed_out', - durationMs, - success: false, - error: 'Watchdog timeout', - }, - !!runId, - ); - await options.onWatchdogTimeout(fileLogger, runId); - }); + // Build the finalizeRun callback with access to llmCallAccumulator + const buildFinalizeRun = + (finalizeRunFn: typeof finalizeRunWithLlmCalls) => + async ( + runId: string | undefined, + fileLogger: FileLogger, + outcome: FinalizeRunOutcome, + ): Promise => { + const meta = outcome.metadata as { llmIterations?: number; gadgetCalls?: number } | undefined; + + const completeInput: CompleteRunInput = { + status: outcome.status, + durationMs: outcome.durationMs, + success: outcome.success, + error: outcome.error, + costUsd: outcome.costUsd, + prUrl: outcome.prUrl, + outputSummary: outcome.outputSummary, + llmIterations: meta?.llmIterations, + gadgetCalls: meta?.gadgetCalls, + }; + await finalizeRunFn(runId, fileLogger, llmCallAccumulator, completeInput, !!runId); + }; - try { - repoDir = await options.setupRepoDir(log); - const envSnapshot = loadCascadeEnv(repoDir, log); - const squintCleanup = await setupRemoteSquintDb( - repoDir, - { squintDbUrl: options.squintDbUrl }, - log, - ); - - const ctx = await options.buildContext(repoDir, log); - - if (options.runTracking) { - runId = await tryCreateRun(options.runTracking, ctx.model, ctx.maxIterations); - } + return executeAgentPipeline({ + loggerIdentifier: options.loggerIdentifier, + setupRepoDir: options.setupRepoDir, + squintDbUrl: options.squintDbUrl, - const originalCwd = process.cwd(); - process.chdir(repoDir); + onWatchdogTimeout: async (fileLogger, runId) => { + await options.onWatchdogTimeout(fileLogger, runId); + }, - log.info('Starting llmist agent', { - model: ctx.model, - maxIterations: ctx.maxIterations, - promptLength: ctx.prompt.length, - runId, - }); + finalizeRun: buildFinalizeRun(finalizeRunWithLlmCalls), - addBreadcrumb({ - category: 'agent', - message: `Starting ${options.loggerIdentifier}`, - data: { model: ctx.model, maxIterations: ctx.maxIterations, runId }, - }); + execute: async (ctx: PipelineContext) => { + const { repoDir, fileLogger, setRunId } = ctx; + + const log = createAgentLogger(fileLogger); + const ctx_ = await options.buildContext(repoDir, log); + + // Create run record now that we have model and maxIterations + let runId: string | undefined; + if (options.runTracking) { + runId = await tryCreateRun(options.runTracking, ctx_.model, ctx_.maxIterations); + if (runId) setRunId(runId); + } + + log.info('Starting llmist agent', { + model: ctx_.model, + maxIterations: ctx_.maxIterations, + promptLength: ctx_.prompt.length, + runId, + }); + + addBreadcrumb({ + category: 'agent', + message: `Starting ${options.loggerIdentifier}`, + data: { model: ctx_.model, maxIterations: ctx_.maxIterations, runId }, + }); - try { process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; process.env.LLMIST_LOG_TEE = 'true'; @@ -304,7 +271,7 @@ export async function executeAgentLifecycle( let builder = options.createBuilder({ client, - ctx, + ctx: ctx_, llmistLogger, trackingContext, fileLogger, @@ -313,9 +280,14 @@ export async function executeAgentLifecycle( llmCallAccumulator, runId, }); - builder = await options.injectSyntheticCalls({ builder, ctx, trackingContext, repoDir }); + builder = await options.injectSyntheticCalls({ + builder, + ctx: ctx_, + trackingContext, + repoDir, + }); - const agent = builder.ask(ctx.prompt); + const agent = builder.ask(ctx_.prompt); progressMonitor?.start(); let result: Awaited>; @@ -338,79 +310,17 @@ export async function executeAgentLifecycle( loopTerminated: result.loopTerminated ?? false, }); - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - const completionInput: CompleteRunInput = result.loopTerminated - ? { - status: 'failed', - durationMs: Date.now() - startTime, - llmIterations: result.iterations, - gadgetCalls: result.gadgetCalls, - costUsd: result.cost, - success: false, - error: 'Agent terminated due to persistent loop', - outputSummary: result.output.slice(0, 500), - } - : { - status: 'completed', - durationMs: Date.now() - startTime, - llmIterations: result.iterations, - gadgetCalls: result.gadgetCalls, - costUsd: result.cost, - success: true, - prUrl: options.postProcess?.(result.output)?.prUrl, - outputSummary: result.output.slice(0, 500), - }; - - await finalizeRun(runId, fileLogger, llmCallAccumulator, completionInput, !!runId); - - return buildAgentResult( - result, - logBuffer, - runId, - Date.now() - startTime, - options.postProcess, - ); - } finally { - process.chdir(originalCwd); - squintCleanup?.(); - unloadCascadeEnv(envSnapshot); - } - } catch (err) { - logger.error('Agent execution failed', { - identifier: options.loggerIdentifier, - error: String(err), - }); - captureException(err, { - tags: { source: 'agent_lifecycle', agent: options.loggerIdentifier }, - extra: { runId, durationMs: Date.now() - startTime }, - }); - - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - const durationMs = Date.now() - startTime; - await finalizeRun( - runId, - fileLogger, - llmCallAccumulator, - { - status: 'failed', - durationMs, - success: false, - error: String(err), - }, - !!runId, - ); - - return { success: false, output: '', error: String(err), logBuffer, runId, durationMs }; - } finally { - cleanupAgentResources(repoDir, fileLogger); - } + return { + success: !result.loopTerminated, + output: result.output, + error: result.loopTerminated ? 'Agent terminated due to persistent loop' : undefined, + cost: result.cost, + prUrl: options.postProcess?.(result.output)?.prUrl, + finalizeMetadata: { + llmIterations: result.iterations, + gadgetCalls: result.gadgetCalls, + }, + }; + }, + }); } diff --git a/src/api/router.ts b/src/api/router.ts index 3672e085..6c496442 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { authRouter } from './routers/auth.js'; import { credentialsRouter } from './routers/credentials.js'; import { defaultsRouter } from './routers/defaults.js'; +import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; import { promptsRouter } from './routers/prompts.js'; @@ -21,6 +22,7 @@ export const appRouter = router({ prompts: promptsRouter, webhooks: webhooksRouter, webhookLogs: webhookLogsRouter, + integrationsDiscovery: integrationsDiscoveryRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts new file mode 100644 index 00000000..8249fdc2 --- /dev/null +++ b/src/api/routers/integrationsDiscovery.ts @@ -0,0 +1,183 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { decryptCredential } from '../../db/crypto.js'; +import { credentials } from '../../db/schema/index.js'; +import { jiraClient, withJiraCredentials } from '../../jira/client.js'; +import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; +import { logger } from '../../utils/logging.js'; +import { protectedProcedure, router } from '../trpc.js'; + +async function resolveCredentialValue(credentialId: number, orgId: string): Promise { + const db = getDb(); + const [cred] = await db + .select({ orgId: credentials.orgId, value: credentials.value }) + .from(credentials) + .where(eq(credentials.id, credentialId)); + if (!cred || cred.orgId !== orgId) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Credential ${credentialId} not found` }); + } + return decryptCredential(cred.value, cred.orgId); +} + +const trelloCredsInput = z.object({ + apiKeyCredentialId: z.number(), + tokenCredentialId: z.number(), +}); + +const jiraCredsInput = z.object({ + emailCredentialId: z.number(), + apiTokenCredentialId: z.number(), + baseUrl: z.string().url(), +}); + +async function resolveTrelloCreds(input: z.infer, orgId: string) { + const [apiKey, token] = await Promise.all([ + resolveCredentialValue(input.apiKeyCredentialId, orgId), + resolveCredentialValue(input.tokenCredentialId, orgId), + ]); + return { apiKey, token }; +} + +async function resolveJiraCreds(input: z.infer, orgId: string) { + const [email, apiToken] = await Promise.all([ + resolveCredentialValue(input.emailCredentialId, orgId), + resolveCredentialValue(input.apiTokenCredentialId, orgId), + ]); + return { email, apiToken, baseUrl: input.baseUrl }; +} + +export const integrationsDiscoveryRouter = router({ + verifyTrello: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyTrello called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + const me = await withTrelloCredentials(creds, () => trelloClient.getMe()); + return { id: me.id, fullName: me.fullName, username: me.username }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to verify Trello credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + verifyJira: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyJira called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + const me = await withJiraCredentials(creds, () => jiraClient.getMyself()); + return { + displayName: (me as { displayName?: string }).displayName ?? '', + emailAddress: (me as { emailAddress?: string }).emailAddress ?? '', + accountId: (me as { accountId?: string }).accountId ?? '', + }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to verify JIRA credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + trelloBoards: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.trelloBoards called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + return await withTrelloCredentials(creds, () => trelloClient.getBoards()); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch Trello boards: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + trelloBoardDetails: protectedProcedure + .input( + trelloCredsInput.extend({ + boardId: z + .string() + .regex(/^[a-zA-Z0-9]+$/) + .max(32), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.trelloBoardDetails called', { + orgId: ctx.effectiveOrgId, + boardId: input.boardId, + }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + const [lists, labels, customFields] = await withTrelloCredentials(creds, () => + Promise.all([ + trelloClient.getBoardLists(input.boardId), + trelloClient.getBoardLabels(input.boardId), + trelloClient.getBoardCustomFields(input.boardId), + ]), + ); + return { lists, labels, customFields }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch Trello board details: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + jiraProjects: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.jiraProjects called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + return await withJiraCredentials(creds, () => jiraClient.searchProjects()); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch JIRA projects: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + jiraProjectDetails: protectedProcedure + .input( + jiraCredsInput.extend({ + projectKey: z + .string() + .regex(/^[A-Z][A-Z0-9_]+$/) + .max(10), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.jiraProjectDetails called', { + orgId: ctx.effectiveOrgId, + projectKey: input.projectKey, + }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + const [statuses, issueTypes, fields] = await withJiraCredentials(creds, () => + Promise.all([ + jiraClient.getProjectStatuses(input.projectKey), + jiraClient.getIssueTypesForProject(input.projectKey), + jiraClient.getFields(), + ]), + ); + return { + statuses, + issueTypes, + fields: fields.filter((f) => f.custom), + }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch JIRA project details: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), +}); diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index df218c1c..1767ebd5 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -362,21 +362,60 @@ async function jiraEnsureLabels(ctx: ProjectContext): Promise { return labelsToSeed; } +// --- One-time token schema (shared by list/create/delete) --- + +const oneTimeTokensSchema = z + .object({ + github: z.string().optional(), + trelloApiKey: z.string().optional(), + trelloToken: z.string().optional(), + jiraEmail: z.string().optional(), + jiraApiToken: z.string().optional(), + }) + .optional(); + +function applyOneTimeTokens( + pctx: ProjectContext, + tokens: z.infer, +): void { + if (!tokens) return; + if (tokens.github) pctx.githubToken = tokens.github; + if (tokens.trelloApiKey) pctx.trelloApiKey = tokens.trelloApiKey; + if (tokens.trelloToken) pctx.trelloToken = tokens.trelloToken; + if (tokens.jiraEmail) pctx.jiraEmail = tokens.jiraEmail; + if (tokens.jiraApiToken) pctx.jiraApiToken = tokens.jiraApiToken; +} + // --- Router --- export const webhooksRouter = router({ list: protectedProcedure - .input(z.object({ projectId: z.string() })) + .input( + z.object({ + projectId: z.string(), + oneTimeTokens: oneTimeTokensSchema, + }), + ) .query(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); + applyOneTimeTokens(pctx, input.oneTimeTokens); - const [trello, github, jira] = await Promise.all([ + const [trelloResult, githubResult, jiraResult] = await Promise.allSettled([ trelloListWebhooks(pctx), githubListWebhooks(pctx), jiraListWebhooks(pctx), ]); - return { trello, github, jira }; + return { + trello: trelloResult.status === 'fulfilled' ? trelloResult.value : [], + github: githubResult.status === 'fulfilled' ? githubResult.value : [], + jira: jiraResult.status === 'fulfilled' ? jiraResult.value : [], + errors: { + trello: trelloResult.status === 'rejected' ? String(trelloResult.reason) : null, + github: githubResult.status === 'rejected' ? String(githubResult.reason) : null, + jira: jiraResult.status === 'rejected' ? String(jiraResult.reason) : null, + }, + }; }), create: protectedProcedure @@ -387,10 +426,12 @@ export const webhooksRouter = router({ trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), + oneTimeTokens: oneTimeTokensSchema, }), ) .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); + applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); const results: { trello?: TrelloWebhook | string; @@ -471,10 +512,12 @@ export const webhooksRouter = router({ trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), + oneTimeTokens: oneTimeTokensSchema, }), ) .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); + applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); const deleted: { trello: string[]; github: number[]; jira: number[] } = { trello: [], diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 4adea58a..e1912a8f 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -1,31 +1,26 @@ import type { ModelSpec } from 'llmist'; import type { PromptContext } from '../agents/prompts/index.js'; -import { cleanupAgentResources } from '../agents/shared/cleanup.js'; +import { + type LogWriter, + type PipelineContext, + executeAgentPipeline, +} from '../agents/shared/executionPipeline.js'; import { resolveModelConfig } from '../agents/shared/modelResolution.js'; import { buildPromptContext } from '../agents/shared/promptContext.js'; import { setupRepository } from '../agents/shared/repository.js'; -import { - type RunTrackingInput, - finalizeBackendRun, - tryCreateRun, -} from '../agents/shared/runTracking.js'; +import { finalizeBackendRun, tryCreateRun } from '../agents/shared/runTracking.js'; import { createAgentLogger } from '../agents/utils/logging.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; import { loadPartials } from '../db/repositories/partialsRepository.js'; import { withGitHubToken } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { loadCascadeEnv, unloadCascadeEnv } from '../utils/cascadeEnv.js'; -import { createFileLogger } from '../utils/fileLogger.js'; -import { setWatchdogCleanup } from '../utils/lifecycle.js'; -import { logger } from '../utils/logging.js'; -import { setupRemoteSquintDb } from '../utils/squintDb.js'; import { getAgentProfile } from './agent-profiles.js'; import { postProcessResult } from './postProcess.js'; import { createProgressMonitor } from './progress.js'; import { augmentProjectSecrets, resolveGitHubToken } from './secretBuilder.js'; import { getToolManifests } from './toolManifests.js'; -import type { AgentBackend, AgentBackendInput, LogWriter } from './types.js'; +import type { AgentBackend, AgentBackendInput } from './types.js'; /** * Resolve the working directory — either a pre-existing log dir or a fresh repo clone. @@ -47,24 +42,6 @@ async function resolveRepoDir( }); } -/** - * Create a LogWriter that writes to both the file logger and the structured logger. - */ -function createLogWriter(fileLogger: ReturnType): LogWriter { - return (level: string, message: string, context?: Record) => { - fileLogger.write(level, message, context); - const logFn = - level === 'ERROR' - ? logger.error - : level === 'WARN' - ? logger.warn - : level === 'DEBUG' - ? logger.debug - : logger.info; - logFn.call(logger, message, context); - }; -} - /** * Build the BackendInput by resolving model config, fetching context, etc. * Uses agent profiles to customize tools, context, and prompts per agent type. @@ -167,95 +144,90 @@ export async function executeWithBackend( input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, ): Promise { const { cardId } = input; - let repoDir: string | null = null; - let runId: string | undefined; - const startTime = Date.now(); - const identifier = `${agentType}-${cardId || 'unknown'}`; - const fileLogger = createFileLogger(`cascade-${identifier}`); - const log = createAgentLogger(fileLogger); - - setWatchdogCleanup(async () => { - fileLogger.close(); - await finalizeBackendRun(runId, fileLogger, { - status: 'timed_out', - durationMs: Date.now() - startTime, - success: false, - error: 'Watchdog timeout', - }); - }); - - try { - repoDir = await resolveRepoDir(input, log, agentType); - const envSnapshot = loadCascadeEnv(repoDir, log); - const squintCleanup = await setupRemoteSquintDb(repoDir, input.project, log); - const logWriter = createLogWriter(fileLogger); - - const profile = getAgentProfile(agentType); - const gitHubToken = await resolveGitHubToken(profile, input.project.id, agentType); - - // Build backend input wrapped in GitHub token scope if needed - const buildPartial = () => - buildBackendInput(agentType, input, repoDir as string, logWriter, log, gitHubToken); - - const partialInput = gitHubToken - ? await withGitHubToken(gitHubToken, buildPartial) - : await buildPartial(); - - const runTrackingInput: RunTrackingInput = { - projectId: input.project.id, - cardId: input.cardId, - prNumber: input.prNumber, - agentType, - backendName: backend.name, - triggerType: input.triggerType, - }; - runId = await tryCreateRun(runTrackingInput, partialInput.model, partialInput.maxIterations); - const monitor = createProgressMonitor({ - logWriter, - agentType, - taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', - progressModel: input.config.defaults.progressModel, - intervalMinutes: input.config.defaults.progressIntervalMinutes, - customModels: CUSTOM_MODELS as ModelSpec[], - repoDir: repoDir ?? undefined, - trello: cardId ? { cardId } : undefined, - preSeededCommentId: input.ackCommentId as string | undefined, - }); + return executeAgentPipeline({ + loggerIdentifier: identifier, + + setupRepoDir: (log) => resolveRepoDir(input, log, agentType), + + skipRepoDeletion: Boolean(input.logDir), + + squintDbUrl: input.project.squintDbUrl, + + finalizeRun: (runId, fileLogger, outcome) => + finalizeBackendRun(runId, fileLogger, { + status: outcome.status, + durationMs: outcome.durationMs, + success: outcome.success, + error: outcome.error, + costUsd: outcome.costUsd, + prUrl: outcome.prUrl, + outputSummary: outcome.outputSummary, + }), + + execute: async (ctx: PipelineContext) => { + const { repoDir, fileLogger, logWriter, setRunId } = ctx; + const log = createAgentLogger(fileLogger); + + const profile = getAgentProfile(agentType); + const gitHubToken = await resolveGitHubToken(profile, input.project.id, agentType); + + // Build backend input wrapped in GitHub token scope if needed + const buildPartial = () => + buildBackendInput(agentType, input, repoDir, logWriter, log, gitHubToken); + + const partialInput = gitHubToken + ? await withGitHubToken(gitHubToken, buildPartial) + : await buildPartial(); + + // Create run record now that we have model and maxIterations + const runId = await tryCreateRun( + { + projectId: input.project.id, + cardId: input.cardId, + prNumber: input.prNumber as number | undefined, + agentType, + backendName: backend.name, + triggerType: input.triggerType, + }, + partialInput.model, + partialInput.maxIterations, + ); + if (runId) setRunId(runId); + + const monitor = createProgressMonitor({ + logWriter, + agentType, + taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', + progressModel: input.config.defaults.progressModel, + intervalMinutes: input.config.defaults.progressIntervalMinutes, + customModels: CUSTOM_MODELS as ModelSpec[], + repoDir: repoDir ?? undefined, + trello: cardId ? { cardId } : undefined, + preSeededCommentId: input.ackCommentId as string | undefined, + }); - const backendInput: AgentBackendInput = { - ...partialInput, - progressReporter: monitor ?? { - onIteration: async () => {}, - onToolCall: () => {}, - onText: () => {}, - }, - runId, - }; + const backendInput: AgentBackendInput = { + ...partialInput, + progressReporter: monitor ?? { + onIteration: async () => {}, + onToolCall: () => {}, + onText: () => {}, + }, + runId, + }; - const originalCwd = process.cwd(); - process.chdir(repoDir); - monitor?.start(); + monitor?.start(); + let result: Awaited>; + try { + result = await backend.execute(backendInput); + } finally { + monitor?.stop(); + } - try { - const result = await backend.execute(backendInput); postProcessResult(result, agentType, backend, input, identifier); - fileLogger.close(); - const logBuffer = await fileLogger.getZippedBuffer(); - - const durationMs = Date.now() - startTime; - await finalizeBackendRun(runId, fileLogger, { - status: result.success ? 'completed' : 'failed', - durationMs, - costUsd: result.cost, - success: result.success, - error: result.error, - prUrl: result.prUrl, - outputSummary: result.output.slice(0, 500), - }); - return { success: result.success, output: result.output, @@ -263,41 +235,8 @@ export async function executeWithBackend( progressCommentId: monitor?.getProgressCommentId() ?? undefined, error: result.error, cost: result.cost, - logBuffer: logBuffer ?? result.logBuffer, - runId, - durationMs, + logBuffer: result.logBuffer, }; - } finally { - monitor?.stop(); - process.chdir(originalCwd); - squintCleanup?.(); - unloadCascadeEnv(envSnapshot); - } - } catch (err) { - logger.error('Backend execution failed', { - identifier, - backend: backend.name, - error: String(err), - }); - - let logBuffer: Buffer | undefined; - try { - fileLogger.close(); - logBuffer = await fileLogger.getZippedBuffer(); - } catch { - // Ignore log buffer errors - } - - const durationMs = Date.now() - startTime; - await finalizeBackendRun(runId, fileLogger, { - status: 'failed', - durationMs, - success: false, - error: String(err), - }); - - return { success: false, output: '', error: String(err), logBuffer, runId, durationMs }; - } finally { - cleanupAgentResources(repoDir, fileLogger, Boolean(input.logDir)); - } + }, + }); } diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 3222c19e..84c61381 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -89,6 +89,8 @@ function formatProgressUserPrompt(context: ProgressContext): string { return sections.join('\n'); } +const PROGRESS_TIMEOUT_MS = 10_000; + /** * Call a lightweight LLM to generate a natural-language progress summary. * @@ -102,6 +104,37 @@ export async function callProgressModel( model: string, context: ProgressContext, customModels: ModelSpec[], +): Promise { + let timeoutHandle: ReturnType | undefined; + + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error('Progress model call timed out')), + PROGRESS_TIMEOUT_MS, + ); + }); + + // Suppress unhandled rejection on the timeout promise — it may fire after + // the LLM promise wins the race, and its rejection would otherwise be unhandled. + timeoutPromise.catch(() => {}); + + try { + return await Promise.race([ + callProgressModelOnce(model, context, customModels), + timeoutPromise, + ]); + } finally { + clearTimeout(timeoutHandle); + } +} + +/** + * Make the actual single-shot LLM call to generate a progress summary. + */ +async function callProgressModelOnce( + model: string, + context: ProgressContext, + customModels: ModelSpec[], ): Promise { const client = new LLMist({ customModels }); @@ -109,8 +142,7 @@ export async function callProgressModel( .withModel(model) .withTemperature(0) .withSystem(PROGRESS_SYSTEM_PROMPT) - .withMaxIterations(1) - .withGadgets(); + .withMaxIterations(1); const agent = builder.ask(formatProgressUserPrompt(context)); diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index 36434ab2..dfe81755 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -19,8 +19,13 @@ import { getSessionState } from '../gadgets/sessionState.js'; import { loadTodos } from '../gadgets/todo/storage.js'; import { githubClient } from '../github/client.js'; import { getPMProviderOrNull } from '../pm/index.js'; +import { captureException } from '../sentry.js'; import { type ProgressContext, callProgressModel } from './progressModel.js'; -import { clearProgressCommentId, writeProgressCommentId } from './progressState.js'; +import { + clearProgressCommentId, + readProgressCommentId, + writeProgressCommentId, +} from './progressState.js'; import type { LogWriter, ProgressReporter } from './types.js'; export interface ProgressMonitorConfig { @@ -48,6 +53,7 @@ export interface ProgressMonitorConfig { /** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; +const PROGRESS_MODEL_TIMEOUT_MS = 20_000; const RING_BUFFER_MAX = 20; const TEXT_SNIPPETS_MAX = 10; const COMPLETED_TASKS_MAX = 5; @@ -274,11 +280,15 @@ export class ProgressMonitor implements ProgressReporter { let summary: string; try { - summary = await callProgressModel( - this.config.progressModel, - progressContext, - this.config.customModels, - ); + summary = await Promise.race([ + callProgressModel(this.config.progressModel, progressContext, this.config.customModels), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Progress model timed out')), + PROGRESS_MODEL_TIMEOUT_MS, + ), + ), + ]); this.config.logWriter('INFO', 'Progress model generated summary', { elapsedMinutes: Math.round(elapsedMinutes), summaryLength: summary.length, @@ -287,6 +297,9 @@ export class ProgressMonitor implements ProgressReporter { this.config.logWriter('WARN', 'Progress model failed, falling back to template', { error: String(err), }); + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: 'progress_model', agentType: this.config.agentType }, + }); summary = formatStatusMessage( this.currentIteration, this.maxIterations, @@ -318,6 +331,17 @@ export class ProgressMonitor implements ProgressReporter { if (!provider) return; if (this.progressCommentId) { + // If the PostComment gadget (subprocess) cleared the state file, + // the agent has posted its final comment to this ID — do not overwrite. + const stateFile = readProgressCommentId(this.config.repoDir); + if (!stateFile) { + this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', { + commentId: this.progressCommentId, + }); + this.progressCommentId = null; + return; + } + // Subsequent ticks: update the existing comment. // On success, the state file written by postInitialComment() remains // valid (same comment ID), so no need to rewrite it here. diff --git a/src/cli/dashboard/_shared/base.ts b/src/cli/dashboard/_shared/base.ts index d2f3f1b8..c0553b77 100644 --- a/src/cli/dashboard/_shared/base.ts +++ b/src/cli/dashboard/_shared/base.ts @@ -4,6 +4,26 @@ import { type DashboardClient, createDashboardClient } from './client.js'; import { type CliConfig, loadConfig } from './config.js'; import { printDetail, printTable } from './format.js'; +export function extractBaseFlags(argv: string[]): { server?: string; org?: string } | undefined { + let server: string | undefined; + let org: string | undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--') break; + if (arg === '--server' && i + 1 < argv.length) { + server = argv[++i]; + } else if (arg.startsWith('--server=')) { + server = arg.slice('--server='.length); + } else if (arg === '--org' && i + 1 < argv.length) { + org = argv[++i]; + } else if (arg.startsWith('--org=')) { + org = arg.slice('--org='.length); + } + } + if (!server && !org) return undefined; + return { server, org }; +} + export abstract class DashboardCommand extends Command { static override baseFlags = { json: Flags.boolean({ description: 'Output as JSON', default: false }), @@ -41,9 +61,8 @@ export abstract class DashboardCommand extends Command { return this._client; } - private parseBaseFlags(): { server?: string; json?: boolean; org?: string } | undefined { - // Base flags are parsed in run() — this is a fallback for the getter - return undefined; + private parseBaseFlags(): { server?: string; org?: string } | undefined { + return extractBaseFlags(this.argv); } protected outputJson(data: unknown): void { diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index efd1a4c3..e9638bf8 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -10,20 +10,40 @@ export default class WebhooksCreate extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, - 'callback-url': Flags.string({ description: 'Callback base URL', required: true }), + 'callback-url': Flags.string({ + description: 'Callback base URL (defaults to server URL)', + }), 'trello-only': Flags.boolean({ description: 'Only create Trello webhook', default: false }), 'github-only': Flags.boolean({ description: 'Only create GitHub webhook', default: false }), + 'github-token': Flags.string({ + description: 'One-time GitHub PAT with admin:repo_hook scope', + }), + 'trello-api-key': Flags.string({ description: 'One-time Trello API key' }), + 'trello-token': Flags.string({ description: 'One-time Trello token' }), + 'jira-email': Flags.string({ description: 'One-time JIRA email' }), + 'jira-api-token': Flags.string({ description: 'One-time JIRA API token' }), }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider output formatting async run(): Promise { const { args, flags } = await this.parse(WebhooksCreate); try { + const callbackBaseUrl = flags['callback-url'] || this.config_.serverUrl; + + const oneTimeTokens: Record = {}; + if (flags['github-token']) oneTimeTokens.github = flags['github-token']; + if (flags['trello-api-key']) oneTimeTokens.trelloApiKey = flags['trello-api-key']; + if (flags['trello-token']) oneTimeTokens.trelloToken = flags['trello-token']; + if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; + if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + const result = await this.client.webhooks.create.mutate({ projectId: args.projectId, - callbackBaseUrl: flags['callback-url'], + callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }); if (flags.json) { @@ -46,6 +66,14 @@ export default class WebhooksCreate extends DashboardCommand { this.log(`Created GitHub webhook: [${result.github.id}] ${result.github.config.url}`); } } + + if (result.jira) { + if (typeof result.jira === 'string') { + this.log(`JIRA: ${result.jira}`); + } else { + this.log(`Created JIRA webhook: [${result.jira.id}] ${result.jira.url}`); + } + } } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/webhooks/delete.ts b/src/cli/dashboard/webhooks/delete.ts index 79de244f..19116f96 100644 --- a/src/cli/dashboard/webhooks/delete.ts +++ b/src/cli/dashboard/webhooks/delete.ts @@ -10,20 +10,39 @@ export default class WebhooksDelete extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, - 'callback-url': Flags.string({ description: 'Callback base URL', required: true }), + 'callback-url': Flags.string({ + description: 'Callback base URL (defaults to server URL)', + }), 'trello-only': Flags.boolean({ description: 'Only delete Trello webhooks', default: false }), 'github-only': Flags.boolean({ description: 'Only delete GitHub webhooks', default: false }), + 'github-token': Flags.string({ + description: 'One-time GitHub PAT with admin:repo_hook scope', + }), + 'trello-api-key': Flags.string({ description: 'One-time Trello API key' }), + 'trello-token': Flags.string({ description: 'One-time Trello token' }), + 'jira-email': Flags.string({ description: 'One-time JIRA email' }), + 'jira-api-token': Flags.string({ description: 'One-time JIRA API token' }), }; async run(): Promise { const { args, flags } = await this.parse(WebhooksDelete); try { + const callbackBaseUrl = flags['callback-url'] || this.config_.serverUrl; + + const oneTimeTokens: Record = {}; + if (flags['github-token']) oneTimeTokens.github = flags['github-token']; + if (flags['trello-api-key']) oneTimeTokens.trelloApiKey = flags['trello-api-key']; + if (flags['trello-token']) oneTimeTokens.trelloToken = flags['trello-token']; + if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; + if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + const result = await this.client.webhooks.delete.mutate({ projectId: args.projectId, - callbackBaseUrl: flags['callback-url'], + callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }); if (flags.json) { @@ -42,6 +61,12 @@ export default class WebhooksDelete extends DashboardCommand { } else { this.log('No matching GitHub webhooks found.'); } + + if (result.jira.length > 0) { + this.log(`Deleted ${result.jira.length} JIRA webhook(s): ${result.jira.join(', ')}`); + } else { + this.log('No matching JIRA webhooks found.'); + } } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts index c9c50ce5..eb78ace3 100644 --- a/src/cli/dashboard/webhooks/list.ts +++ b/src/cli/dashboard/webhooks/list.ts @@ -1,8 +1,8 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class WebhooksList extends DashboardCommand { - static override description = 'List Trello and GitHub webhooks for a project.'; + static override description = 'List Trello, GitHub, and JIRA webhooks for a project.'; static override args = { projectId: Args.string({ description: 'Project ID', required: true }), @@ -10,19 +10,46 @@ export default class WebhooksList extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, + 'github-token': Flags.string({ + description: 'One-time GitHub PAT with admin:repo_hook scope', + }), + 'trello-api-key': Flags.string({ description: 'One-time Trello API key' }), + 'trello-token': Flags.string({ description: 'One-time Trello token' }), + 'jira-email': Flags.string({ description: 'One-time JIRA email' }), + 'jira-api-token': Flags.string({ description: 'One-time JIRA API token' }), }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider output formatting async run(): Promise { const { args, flags } = await this.parse(WebhooksList); try { - const result = await this.client.webhooks.list.query({ projectId: args.projectId }); + const oneTimeTokens: Record = {}; + if (flags['github-token']) oneTimeTokens.github = flags['github-token']; + if (flags['trello-api-key']) oneTimeTokens.trelloApiKey = flags['trello-api-key']; + if (flags['trello-token']) oneTimeTokens.trelloToken = flags['trello-token']; + if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; + if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + + const result = await this.client.webhooks.list.query({ + projectId: args.projectId, + oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, + }); if (flags.json) { this.outputJson(result); return; } + // Per-provider errors + if (result.errors) { + for (const [provider, err] of Object.entries(result.errors)) { + if (err) { + this.warn(`${provider}: ${err}`); + } + } + } + this.log('Trello webhooks:'); if (result.trello.length === 0) { this.log(' (none)'); @@ -44,6 +71,16 @@ export default class WebhooksList extends DashboardCommand { ); } } + + this.log(''); + this.log('JIRA webhooks:'); + if (result.jira.length === 0) { + this.log(' (none)'); + } else { + for (const w of result.jira) { + this.log(` [${w.id}] ${w.url} (active: ${w.enabled})`); + } + } } catch (err) { this.handleError(err); } diff --git a/src/config/provider.ts b/src/config/provider.ts index 175a5a38..fd853809 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -106,33 +106,51 @@ async function getOrgIdForProject(projectId: string): Promise { } // ============================================================================ -// Integration credentials — direct by category + role +// Internal: 3-step env/worker/DB resolution helper // ============================================================================ /** - * Resolve an integration credential for a project by category and role. - * Throws if the credential is not found. + * Resolve a credential value using the standard 3-step pattern: + * 1. Check process.env (populated at worker startup from router-supplied credentials) + * 2. If in worker context (CASCADE_CREDENTIAL_KEYS set), credential is absent → return notFoundValue + * 3. Otherwise resolve from DB via the provided async lookup */ -export async function getIntegrationCredential( - projectId: string, - category: string, - role: string, -): Promise { +async function resolveFromEnvOrDb( + envKey: string | undefined, + notFoundValue: T, + dbLookup: () => Promise, +): Promise { // Check process.env first (populated at worker startup from router-supplied credentials) - const envKey = roleToEnvVarKey(category, role); if (envKey && process.env[envKey]) { - return process.env[envKey]; + return process.env[envKey] as T; } // Worker context: all credentials set by router, this one doesn't exist if (process.env.CASCADE_CREDENTIAL_KEYS) { - throw new Error( - `Integration credential '${category}/${role}' not found for project '${projectId}'`, - ); + return notFoundValue; } // Router/dashboard context: resolve from DB - const value = await resolveIntegrationCredential(projectId, category, role); + return dbLookup(); +} + +// ============================================================================ +// Integration credentials — direct by category + role +// ============================================================================ + +/** + * Resolve an integration credential for a project by category and role. + * Throws if the credential is not found. + */ +export async function getIntegrationCredential( + projectId: string, + category: string, + role: string, +): Promise { + const envKey = roleToEnvVarKey(category, role); + const value = await resolveFromEnvOrDb(envKey, null, () => + resolveIntegrationCredential(projectId, category, role), + ); if (value) return value; throw new Error( @@ -148,19 +166,10 @@ export async function getIntegrationCredentialOrNull( category: string, role: string, ): Promise { - // Check process.env first (populated at worker startup from router-supplied credentials) const envKey = roleToEnvVarKey(category, role); - if (envKey && process.env[envKey]) { - return process.env[envKey]; - } - - // Worker context: all credentials set by router, this one doesn't exist - if (process.env.CASCADE_CREDENTIAL_KEYS) { - return null; - } - - // Router/dashboard context: resolve from DB - return resolveIntegrationCredential(projectId, category, role); + return resolveFromEnvOrDb(envKey, null, () => + resolveIntegrationCredential(projectId, category, role), + ); } // ============================================================================ @@ -175,19 +184,10 @@ export async function getOrgCredential( projectId: string, envVarKey: string, ): Promise { - // Check process.env first (populated at worker startup from router-supplied credentials) - if (process.env[envVarKey]) { - return process.env[envVarKey]; - } - - // Worker context: all credentials set by router, this one doesn't exist - if (process.env.CASCADE_CREDENTIAL_KEYS) { - return null; - } - - // Router/dashboard context: resolve from DB - const orgId = await getOrgIdForProject(projectId); - return resolveOrgCredential(orgId, envVarKey); + return resolveFromEnvOrDb(envVarKey, null, async () => { + const orgId = await getOrgIdForProject(projectId); + return resolveOrgCredential(orgId, envVarKey); + }); } // ============================================================================ diff --git a/src/jira/client.ts b/src/jira/client.ts index 0fc0e1fb..007cdff0 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -114,6 +114,48 @@ export const jiraClient = { })); }, + async searchProjects(): Promise> { + logger.debug('Searching JIRA projects'); + const result = await getClient().projects.searchProjects({ maxResults: 100 }); + const values = (result.values ?? []) as Array<{ key?: string; name?: string }>; + return values.map((p) => ({ + key: p.key ?? '', + name: p.name ?? '', + })); + }, + + async getProjectStatuses(projectKey: string): Promise> { + logger.debug('Fetching JIRA project statuses', { projectKey }); + const result = await getClient().projects.getAllStatuses({ + projectIdOrKey: projectKey, + }); + // getAllStatuses returns issueType-grouped statuses; flatten and deduplicate + const seen = new Set(); + const statuses: Array<{ name: string; id: string }> = []; + for (const issueType of result as Array<{ + statuses?: Array<{ name?: string; id?: string }>; + }>) { + for (const status of issueType.statuses ?? []) { + const name = status.name ?? ''; + if (name && !seen.has(name)) { + seen.add(name); + statuses.push({ name, id: status.id ?? '' }); + } + } + } + return statuses; + }, + + async getFields(): Promise> { + logger.debug('Fetching JIRA fields'); + const fields = await getClient().issueFields.getFields(); + return (fields as Array<{ id?: string; name?: string; custom?: boolean }>).map((f) => ({ + id: f.id ?? '', + name: f.name ?? '', + custom: f.custom ?? false, + })); + }, + async createIssue(fields: Record) { logger.debug('Creating JIRA issue', { project: (fields.project as { key?: string })?.key, diff --git a/src/queue/client.ts b/src/queue/client.ts index ebac700b..74cb99f0 100644 --- a/src/queue/client.ts +++ b/src/queue/client.ts @@ -5,7 +5,8 @@ * Only loaded when REDIS_URL is set (production dashboard container). */ -import { type ConnectionOptions, Queue } from 'bullmq'; +import { Queue } from 'bullmq'; +import { parseRedisUrl } from '../utils/redis.js'; // ── Job types ──────────────────────────────────────────────────────────────── @@ -41,15 +42,6 @@ export type DashboardJob = ManualRunJob | RetryRunJob | DebugAnalysisJob; const QUEUE_NAME = 'cascade-dashboard-jobs'; -function parseRedisUrl(url: string): ConnectionOptions { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: Number(parsed.port) || 6379, - password: parsed.password || undefined, - }; -} - let queue: Queue | null = null; function getQueue(): Queue { diff --git a/src/router/bullmq-workers.ts b/src/router/bullmq-workers.ts new file mode 100644 index 00000000..4a7aa659 --- /dev/null +++ b/src/router/bullmq-workers.ts @@ -0,0 +1,65 @@ +/** + * BullMQ worker factory for CASCADE queue consumers. + * + * Provides a `createQueueWorker` factory that de-duplicates the event handler + * boilerplate shared across all queue workers (completed/failed/error logging + * and Sentry capture). + */ + +import { type ConnectionOptions, type Job, Worker } from 'bullmq'; +import { captureException } from '../sentry.js'; +import { parseRedisUrl } from '../utils/redis.js'; + +// Re-export so existing callers (worker-manager.ts) don't need to change imports. +export { parseRedisUrl }; + +export interface QueueWorkerConfig { + queueName: string; + /** Human-readable label used in log messages and Sentry tags */ + label: string; + connection: ConnectionOptions; + concurrency: number; + lockDuration: number; + processFn: (job: Job) => Promise; +} + +/** + * Factory that creates a BullMQ Worker with standard event handlers. + * + * All cascade queue workers share the same completed/failed/error handling + * pattern — this factory de-duplicates that boilerplate while keeping + * per-queue differences (name, label, processFn) configurable. + */ +export function createQueueWorker(config: QueueWorkerConfig): Worker { + const { queueName, label, connection, concurrency, lockDuration, processFn } = config; + + const worker = new Worker(queueName, processFn, { + connection, + concurrency, + lockDuration, + }); + + worker.on('completed', (job) => { + console.log(`[WorkerManager] ${label} dispatched:`, { jobId: job.id }); + }); + + worker.on('failed', (job, err) => { + console.error(`[WorkerManager] ${label} failed to dispatch:`, { + jobId: job?.id, + error: String(err), + }); + captureException(err, { + tags: { source: 'bullmq_dispatch', queue: queueName }, + extra: { jobId: job?.id }, + }); + }); + + worker.on('error', (err) => { + console.error(`[WorkerManager] ${label} worker error:`, err); + captureException(err, { + tags: { source: 'bullmq_error', queue: queueName }, + }); + }); + + return worker; +} diff --git a/src/router/container-manager.ts b/src/router/container-manager.ts new file mode 100644 index 00000000..a1fbf469 --- /dev/null +++ b/src/router/container-manager.ts @@ -0,0 +1,323 @@ +/** + * Docker container lifecycle management for CASCADE worker processes. + * + * Handles spawning, monitoring, killing, and tracking of worker containers. + * Each BullMQ job gets its own isolated Docker container. + */ + +import type { Job } from 'bullmq'; +import Docker from 'dockerode'; +import { findProjectByRepo, getAllProjectCredentials } from '../config/provider.js'; +import { captureException } from '../sentry.js'; +import { routerConfig } from './config.js'; +import { notifyTimeout } from './notifications.js'; +import type { CascadeJob } from './queue.js'; + +const docker = new Docker(); + +export interface ActiveWorker { + containerId: string; + jobId: string; + startedAt: Date; + timeoutHandle: NodeJS.Timeout; + job: CascadeJob; +} + +const activeWorkers = new Map(); + +/** + * Extract projectId from job data for credential resolution. + * Different job types have the projectId in different locations. + * + * Note: Dashboard jobs (manual-run, retry-run, debug-analysis) come through + * cascade-dashboard-jobs queue and are cast to CascadeJob for spawning. + */ +export async function extractProjectIdFromJob(data: CascadeJob): Promise { + // Use type assertion since dashboard jobs are cast to CascadeJob + const jobData = data as unknown as { type: string; projectId?: string; repoFullName?: string }; + + if (jobData.type === 'trello' || jobData.type === 'jira') { + return jobData.projectId ?? null; + } + if (jobData.type === 'github') { + if (!jobData.repoFullName) return null; + const project = await findProjectByRepo(jobData.repoFullName); + return project?.id ?? null; + } + if (jobData.type === 'manual-run' || jobData.type === 'debug-analysis') { + return jobData.projectId ?? null; + } + if (jobData.type === 'retry-run') { + // Retry jobs now include projectId from the API + return jobData.projectId ?? null; + } + return null; +} + +/** + * Build environment variables for a worker container. + * Resolves project credentials and forwards required infrastructure env vars. + */ +export async function buildWorkerEnv(job: Job): Promise { + const env: string[] = [ + `JOB_ID=${job.id}`, + `JOB_TYPE=${job.data.type}`, + `JOB_DATA=${JSON.stringify(job.data)}`, + // Redis for job completion reporting + `REDIS_URL=${routerConfig.redisUrl}`, + // Database connection + `CASCADE_POSTGRES_HOST=${process.env.CASCADE_POSTGRES_HOST || 'postgres'}`, + `CASCADE_POSTGRES_PORT=${process.env.CASCADE_POSTGRES_PORT || '5432'}`, + // Database connection for config + `DATABASE_URL=${process.env.DATABASE_URL || ''}`, + // Logging + `LOG_LEVEL=${process.env.LOG_LEVEL || 'info'}`, + ]; + + // Resolve project credentials in the router and set as individual env vars. + // NOTE: CREDENTIAL_MASTER_KEY is intentionally NOT passed to workers. + const projectId = await extractProjectIdFromJob(job.data); + if (projectId) { + try { + const secrets = await getAllProjectCredentials(projectId); + for (const [key, value] of Object.entries(secrets)) { + env.push(`${key}=${value}`); + } + env.push(`CASCADE_CREDENTIAL_KEYS=${Object.keys(secrets).join(',')}`); + } catch (err) { + console.warn('[WorkerManager] Failed to resolve credentials for project:', { + projectId, + error: String(err), + }); + captureException(err, { + tags: { source: 'credential_resolution' }, + extra: { projectId }, + level: 'warning', + }); + } + } + + // CLAUDE_CODE_OAUTH_TOKEN is for the Claude Code backend (subscription auth). + if (process.env.CLAUDE_CODE_OAUTH_TOKEN) + env.push(`CLAUDE_CODE_OAUTH_TOKEN=${process.env.CLAUDE_CODE_OAUTH_TOKEN}`); + + // Forward Sentry env vars so worker containers report to the same project. + if (process.env.SENTRY_DSN) env.push(`SENTRY_DSN=${process.env.SENTRY_DSN}`); + if (process.env.SENTRY_ENVIRONMENT) + env.push(`SENTRY_ENVIRONMENT=${process.env.SENTRY_ENVIRONMENT}`); + if (process.env.SENTRY_RELEASE) env.push(`SENTRY_RELEASE=${process.env.SENTRY_RELEASE}`); + + return env; +} + +/** + * Spawn a worker container for a job. + * Sets up timeout tracking and monitors container exit asynchronously. + */ +export async function spawnWorker(job: Job): Promise { + const jobId = job.id ?? `unknown-${Date.now()}`; + const containerName = `cascade-worker-${jobId}`; + + const workerEnv = await buildWorkerEnv(job); + const hasCredentials = workerEnv.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS=')); + + console.log('[WorkerManager] Spawning worker:', { + jobId, + type: job.data.type, + containerName, + hasCredentials, + }); + + try { + const container = await docker.createContainer({ + Image: routerConfig.workerImage, + name: containerName, + Env: workerEnv, + HostConfig: { + Memory: routerConfig.workerMemoryMb * 1024 * 1024, + MemorySwap: routerConfig.workerMemoryMb * 1024 * 1024, // No swap + NetworkMode: routerConfig.dockerNetwork, + AutoRemove: true, // Clean up container on exit + }, + Labels: { + 'cascade.job.id': jobId, + 'cascade.job.type': job.data.type, + 'cascade.managed': 'true', + }, + }); + + await container.start(); + + // Set up timeout + const startedAt = new Date(); + const timeoutHandle = setTimeout(() => { + const durationMs = Date.now() - startedAt.getTime(); + console.warn('[WorkerManager] Worker timeout, killing:', { + jobId, + durationMs, + }); + captureException(new Error(`Worker timeout after ${durationMs}ms`), { + tags: { source: 'worker_timeout', jobType: job.data.type }, + extra: { jobId, durationMs }, + level: 'warning', + }); + killWorker(jobId).catch((err) => { + console.error('[WorkerManager] Failed to kill timed-out worker:', err); + }); + }, routerConfig.workerTimeoutMs); + + // Track the worker + activeWorkers.set(jobId, { + containerId: container.id, + jobId, + startedAt, + timeoutHandle, + job: job.data, + }); + + console.log('[WorkerManager] Worker started:', { + jobId, + containerId: container.id.slice(0, 12), + }); + + // Monitor container exit + container + .wait() + .then(async (result) => { + // Collect worker logs before auto-removal + try { + const logs = await container.logs({ + stdout: true, + stderr: true, + follow: false, + }); + const logText = logs.toString('utf-8'); + if (logText.trim()) { + const lines = logText.trim().split('\n'); + const tail = lines.slice(-50).join('\n'); + console.log( + `[WorkerManager] Worker logs (last ${Math.min(lines.length, 50)} of ${lines.length} lines):\n${tail}`, + ); + } + } catch { + // Container may already be removed — expected with AutoRemove + } + + if (result.StatusCode !== 0) { + captureException(new Error(`Worker exited with status ${result.StatusCode}`), { + tags: { source: 'worker_exit', jobType: job.data.type }, + extra: { jobId, statusCode: result.StatusCode }, + }); + } + console.log('[WorkerManager] Worker exited:', { + jobId, + statusCode: result.StatusCode, + }); + cleanupWorker(jobId); + }) + .catch((err) => { + console.error('[WorkerManager] Error waiting for container:', err); + captureException(err, { + tags: { source: 'worker_wait', jobType: job.data.type }, + extra: { jobId }, + }); + cleanupWorker(jobId); + }); + } catch (err) { + console.error('[WorkerManager] Failed to spawn worker:', { + jobId, + error: String(err), + }); + captureException(err, { + tags: { source: 'worker_spawn', jobType: job.data.type }, + extra: { jobId }, + }); + throw err; + } +} + +/** + * Kill a worker container with two-phase shutdown: + * 1. SIGTERM via container.stop(t=15) — gives agent watchdog 15s to clean up + * 2. Docker auto-escalates to SIGKILL after 15s + * 3. Router posts its own timeout notification + */ +export async function killWorker(jobId: string): Promise { + const worker = activeWorkers.get(jobId); + if (!worker) return; + + try { + const container = docker.getContainer(worker.containerId); + await container.stop({ t: 15 }); + console.log('[WorkerManager] Worker stopped:', { jobId }); + } catch (err) { + // Container might already be stopped + console.warn('[WorkerManager] Error stopping worker (may already be stopped):', { + jobId, + error: String(err), + }); + } + + // Send timeout notification (fire-and-forget) + const durationMs = Date.now() - worker.startedAt.getTime(); + notifyTimeout(worker.job, { + jobId: worker.jobId, + startedAt: worker.startedAt, + durationMs, + }).catch((err) => { + console.error('[WorkerManager] Timeout notification error:', String(err)); + }); + + cleanupWorker(jobId); +} + +/** + * Clean up worker tracking state (timeout handle + map entry). + */ +export function cleanupWorker(jobId: string): void { + const worker = activeWorkers.get(jobId); + if (worker) { + clearTimeout(worker.timeoutHandle); + activeWorkers.delete(jobId); + console.log('[WorkerManager] Worker cleaned up:', { + jobId, + activeWorkers: activeWorkers.size, + }); + } +} + +/** + * Get number of currently active worker containers. + */ +export function getActiveWorkerCount(): number { + return activeWorkers.size; +} + +/** + * Get summary info for currently active workers. + */ +export function getActiveWorkers(): Array<{ jobId: string; startedAt: Date }> { + return Array.from(activeWorkers.values()).map((w) => ({ + jobId: w.jobId, + startedAt: w.startedAt, + })); +} + +/** + * Detach from all active workers on shutdown. + * Workers continue running as independent containers. + * Clears timeout handles so the router process can exit cleanly. + */ +export function detachAll(): void { + if (activeWorkers.size > 0) { + console.log('[WorkerManager] Detaching from active workers (will continue running):', { + count: activeWorkers.size, + workers: Array.from(activeWorkers.keys()), + }); + } + + for (const [, worker] of activeWorkers) { + clearTimeout(worker.timeoutHandle); + } + activeWorkers.clear(); +} diff --git a/src/router/queue.ts b/src/router/queue.ts index 92dbc92a..9437bde0 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -1,19 +1,10 @@ -import { type ConnectionOptions, Queue } from 'bullmq'; +import { Queue } from 'bullmq'; import { captureException } from '../sentry.js'; import type { TriggerResult } from '../types/index.js'; import { logger } from '../utils/logging.js'; +import { parseRedisUrl } from '../utils/redis.js'; import { routerConfig } from './config.js'; -// Parse Redis URL to connection options -function parseRedisUrl(url: string): ConnectionOptions { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: Number(parsed.port) || 6379, - password: parsed.password || undefined, - }; -} - const connection = parseRedisUrl(routerConfig.redisUrl); // Job types diff --git a/src/router/worker-manager.ts b/src/router/worker-manager.ts index 558e3638..080282d0 100644 --- a/src/router/worker-manager.ts +++ b/src/router/worker-manager.ts @@ -1,387 +1,70 @@ -import { type Job, Worker } from 'bullmq'; -import Docker from 'dockerode'; -import { findProjectByRepo, getAllProjectCredentials } from '../config/provider.js'; -import { captureException } from '../sentry.js'; -import { routerConfig } from './config.js'; -import { notifyTimeout } from './notifications.js'; -import type { CascadeJob } from './queue.js'; - -const docker = new Docker(); - -interface ActiveWorker { - containerId: string; - jobId: string; - startedAt: Date; - timeoutHandle: NodeJS.Timeout; - job: CascadeJob; -} - -const activeWorkers = new Map(); - /** - * Extract projectId from job data for credential resolution. - * Different job types have the projectId in different locations. + * Orchestrator for CASCADE worker processing. + * + * Wires together BullMQ queue consumers (bullmq-workers.ts) and Docker + * container lifecycle management (container-manager.ts). * - * Note: Dashboard jobs (manual-run, retry-run, debug-analysis) come through - * cascade-dashboard-jobs queue and are cast to CascadeJob for spawning. + * Public API is unchanged — all consumers continue importing from this module. */ -async function extractProjectIdFromJob(data: CascadeJob): Promise { - // Use type assertion since dashboard jobs are cast to CascadeJob - const jobData = data as unknown as { type: string; projectId?: string; repoFullName?: string }; - - if (jobData.type === 'trello' || jobData.type === 'jira') { - return jobData.projectId ?? null; - } - if (jobData.type === 'github') { - if (!jobData.repoFullName) return null; - const project = await findProjectByRepo(jobData.repoFullName); - return project?.id ?? null; - } - if (jobData.type === 'manual-run' || jobData.type === 'debug-analysis') { - return jobData.projectId ?? null; - } - if (jobData.type === 'retry-run') { - // Retry jobs now include projectId from the API - return jobData.projectId ?? null; - } - return null; -} - -// Build environment variables for worker container -async function buildWorkerEnv(job: Job): Promise { - const env: string[] = [ - `JOB_ID=${job.id}`, - `JOB_TYPE=${job.data.type}`, - `JOB_DATA=${JSON.stringify(job.data)}`, - // Redis for job completion reporting - `REDIS_URL=${routerConfig.redisUrl}`, - // Database connection - `CASCADE_POSTGRES_HOST=${process.env.CASCADE_POSTGRES_HOST || 'postgres'}`, - `CASCADE_POSTGRES_PORT=${process.env.CASCADE_POSTGRES_PORT || '5432'}`, - // Database connection for config - `DATABASE_URL=${process.env.DATABASE_URL || ''}`, - // Logging - `LOG_LEVEL=${process.env.LOG_LEVEL || 'info'}`, - ]; - - // Resolve project credentials in the router and set as individual env vars. - // NOTE: CREDENTIAL_MASTER_KEY is intentionally NOT passed to workers. - const projectId = await extractProjectIdFromJob(job.data); - if (projectId) { - try { - const secrets = await getAllProjectCredentials(projectId); - for (const [key, value] of Object.entries(secrets)) { - env.push(`${key}=${value}`); - } - env.push(`CASCADE_CREDENTIAL_KEYS=${Object.keys(secrets).join(',')}`); - } catch (err) { - console.warn('[WorkerManager] Failed to resolve credentials for project:', { - projectId, - error: String(err), - }); - captureException(err, { - tags: { source: 'credential_resolution' }, - extra: { projectId }, - level: 'warning', - }); - } - } - - // CLAUDE_CODE_OAUTH_TOKEN is for the Claude Code backend (subscription auth). - if (process.env.CLAUDE_CODE_OAUTH_TOKEN) - env.push(`CLAUDE_CODE_OAUTH_TOKEN=${process.env.CLAUDE_CODE_OAUTH_TOKEN}`); - - // Forward Sentry env vars so worker containers report to the same project. - if (process.env.SENTRY_DSN) env.push(`SENTRY_DSN=${process.env.SENTRY_DSN}`); - if (process.env.SENTRY_ENVIRONMENT) - env.push(`SENTRY_ENVIRONMENT=${process.env.SENTRY_ENVIRONMENT}`); - if (process.env.SENTRY_RELEASE) env.push(`SENTRY_RELEASE=${process.env.SENTRY_RELEASE}`); - - return env; -} - -// Spawn a worker container for a job -async function spawnWorker(job: Job): Promise { - const jobId = job.id ?? `unknown-${Date.now()}`; - const containerName = `cascade-worker-${jobId}`; - - const workerEnv = await buildWorkerEnv(job); - const hasCredentials = workerEnv.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS=')); - - console.log('[WorkerManager] Spawning worker:', { - jobId, - type: job.data.type, - containerName, - hasCredentials, - }); - - try { - const container = await docker.createContainer({ - Image: routerConfig.workerImage, - name: containerName, - Env: workerEnv, - HostConfig: { - Memory: routerConfig.workerMemoryMb * 1024 * 1024, - MemorySwap: routerConfig.workerMemoryMb * 1024 * 1024, // No swap - NetworkMode: routerConfig.dockerNetwork, - AutoRemove: true, // Clean up container on exit - }, - Labels: { - 'cascade.job.id': jobId, - 'cascade.job.type': job.data.type, - 'cascade.managed': 'true', - }, - }); - - await container.start(); - - // Set up timeout - const startedAt = new Date(); - const timeoutHandle = setTimeout(() => { - const durationMs = Date.now() - startedAt.getTime(); - console.warn('[WorkerManager] Worker timeout, killing:', { - jobId, - durationMs, - }); - captureException(new Error(`Worker timeout after ${durationMs}ms`), { - tags: { source: 'worker_timeout', jobType: job.data.type }, - extra: { jobId, durationMs }, - level: 'warning', - }); - killWorker(jobId).catch((err) => { - console.error('[WorkerManager] Failed to kill timed-out worker:', err); - }); - }, routerConfig.workerTimeoutMs); - - // Track the worker - activeWorkers.set(jobId, { - containerId: container.id, - jobId, - startedAt, - timeoutHandle, - job: job.data, - }); - - console.log('[WorkerManager] Worker started:', { - jobId, - containerId: container.id.slice(0, 12), - }); - - // Monitor container exit - container - .wait() - .then(async (result) => { - // Collect worker logs before auto-removal - try { - const logs = await container.logs({ - stdout: true, - stderr: true, - follow: false, - }); - const logText = logs.toString('utf-8'); - if (logText.trim()) { - const lines = logText.trim().split('\n'); - const tail = lines.slice(-50).join('\n'); - console.log( - `[WorkerManager] Worker logs (last ${Math.min(lines.length, 50)} of ${lines.length} lines):\n${tail}`, - ); - } - } catch { - // Container may already be removed — expected with AutoRemove - } - - if (result.StatusCode !== 0) { - captureException(new Error(`Worker exited with status ${result.StatusCode}`), { - tags: { source: 'worker_exit', jobType: job.data.type }, - extra: { jobId, statusCode: result.StatusCode }, - }); - } - console.log('[WorkerManager] Worker exited:', { - jobId, - statusCode: result.StatusCode, - }); - cleanupWorker(jobId); - }) - .catch((err) => { - console.error('[WorkerManager] Error waiting for container:', err); - captureException(err, { - tags: { source: 'worker_wait', jobType: job.data.type }, - extra: { jobId }, - }); - cleanupWorker(jobId); - }); - } catch (err) { - console.error('[WorkerManager] Failed to spawn worker:', { - jobId, - error: String(err), - }); - captureException(err, { - tags: { source: 'worker_spawn', jobType: job.data.type }, - extra: { jobId }, - }); - throw err; - } -} -// Kill a worker container with two-phase shutdown: -// 1. SIGTERM via container.stop(t=15) — gives agent watchdog 15s to clean up -// 2. Docker auto-escalates to SIGKILL after 15s -// 3. Router posts its own timeout notification -async function killWorker(jobId: string): Promise { - const worker = activeWorkers.get(jobId); - if (!worker) return; - - try { - const container = docker.getContainer(worker.containerId); - await container.stop({ t: 15 }); - console.log('[WorkerManager] Worker stopped:', { jobId }); - } catch (err) { - // Container might already be stopped - console.warn('[WorkerManager] Error stopping worker (may already be stopped):', { - jobId, - error: String(err), - }); - } +import type { Job, Worker } from 'bullmq'; +import { createQueueWorker, parseRedisUrl } from './bullmq-workers.js'; +import { routerConfig } from './config.js'; +import { + detachAll, + getActiveWorkerCount, + getActiveWorkers, + spawnWorker, +} from './container-manager.js'; +import type { CascadeJob } from './queue.js'; - // Send timeout notification (fire-and-forget) - const durationMs = Date.now() - worker.startedAt.getTime(); - notifyTimeout(worker.job, { - jobId: worker.jobId, - startedAt: worker.startedAt, - durationMs, - }).catch((err) => { - console.error('[WorkerManager] Timeout notification error:', String(err)); - }); +// Re-export container-manager public API so existing callers are unaffected. +export { getActiveWorkerCount, getActiveWorkers }; - cleanupWorker(jobId); -} +// BullMQ Workers that process jobs by spawning containers +let bullWorker: Worker | null = null; +let dashboardWorker: Worker | null = null; -// Clean up worker tracking -function cleanupWorker(jobId: string): void { - const worker = activeWorkers.get(jobId); - if (worker) { - clearTimeout(worker.timeoutHandle); - activeWorkers.delete(jobId); - console.log('[WorkerManager] Worker cleaned up:', { - jobId, - activeWorkers: activeWorkers.size, - }); +/** Guard that enforces the per-router concurrency cap before spawning. */ +async function guardedSpawn(job: Job): Promise { + // Check if we have capacity. + // This shouldn't happen with proper concurrency settings, + // but just in case, throw to retry later. + if (getActiveWorkerCount() >= routerConfig.maxWorkers) { + throw new Error('No worker slots available'); } + await spawnWorker(job); + // Note: We don't wait for the container to complete here. + // The job is considered "processed" once the container starts. + // Container exit is handled asynchronously. } -// Get active worker count -export function getActiveWorkerCount(): number { - return activeWorkers.size; -} - -// Get active worker info -export function getActiveWorkers(): Array<{ jobId: string; startedAt: Date }> { - return Array.from(activeWorkers.values()).map((w) => ({ - jobId: w.jobId, - startedAt: w.startedAt, - })); -} - -// BullMQ Worker that processes jobs by spawning containers -let bullWorker: Worker | null = null; -let dashboardWorker: Worker | null = null; - export function startWorkerProcessor(): void { if (bullWorker) { console.warn('[WorkerManager] Worker processor already started'); return; } - const redisConnection = { - host: new URL(routerConfig.redisUrl).hostname, - port: Number(new URL(routerConfig.redisUrl).port) || 6379, - }; - - bullWorker = new Worker( - 'cascade-jobs', - async (job) => { - // Check if we have capacity - if (activeWorkers.size >= routerConfig.maxWorkers) { - // This shouldn't happen with proper concurrency settings, - // but just in case, throw to retry later - throw new Error('No worker slots available'); - } - - await spawnWorker(job); - - // Note: We don't wait for the container to complete here. - // The job is considered "processed" once the container starts. - // Container exit is handled asynchronously. - }, - { - connection: redisConnection, - concurrency: routerConfig.maxWorkers, - // Lock jobs for the timeout duration plus buffer - lockDuration: routerConfig.workerTimeoutMs + 60000, - }, - ); - - bullWorker.on('completed', (job) => { - console.log('[WorkerManager] Job dispatched:', { jobId: job.id }); - }); - - bullWorker.on('failed', (job, err) => { - console.error('[WorkerManager] Job failed to dispatch:', { - jobId: job?.id, - error: String(err), - }); - captureException(err, { - tags: { source: 'bullmq_dispatch', queue: 'cascade-jobs' }, - extra: { jobId: job?.id }, - }); - }); + const connection = parseRedisUrl(routerConfig.redisUrl); - bullWorker.on('error', (err) => { - console.error('[WorkerManager] Worker error:', err); - captureException(err, { - tags: { source: 'bullmq_error', queue: 'cascade-jobs' }, - }); + bullWorker = createQueueWorker({ + queueName: 'cascade-jobs', + label: 'Job', + connection, + concurrency: routerConfig.maxWorkers, + lockDuration: routerConfig.workerTimeoutMs + 60000, + processFn: guardedSpawn, }); // Dashboard jobs queue — manual runs, retries, debug analyses submitted - // from the dashboard API container - dashboardWorker = new Worker( - 'cascade-dashboard-jobs', - async (job) => { - if (activeWorkers.size >= routerConfig.maxWorkers) { - throw new Error('No worker slots available'); - } - // Dashboard jobs are forwarded as worker containers with the same - // JOB_TYPE / JOB_DATA protocol that worker-entry.ts understands. - await spawnWorker(job as Job); - }, - { - connection: redisConnection, - concurrency: routerConfig.maxWorkers, - lockDuration: routerConfig.workerTimeoutMs + 60000, - }, - ); - - dashboardWorker.on('completed', (job) => { - console.log('[WorkerManager] Dashboard job dispatched:', { jobId: job.id }); - }); - - dashboardWorker.on('failed', (job, err) => { - console.error('[WorkerManager] Dashboard job failed to dispatch:', { - jobId: job?.id, - error: String(err), - }); - captureException(err, { - tags: { source: 'bullmq_dispatch', queue: 'cascade-dashboard-jobs' }, - extra: { jobId: job?.id }, - }); - }); - - dashboardWorker.on('error', (err) => { - console.error('[WorkerManager] Dashboard worker error:', err); - captureException(err, { - tags: { source: 'bullmq_error', queue: 'cascade-dashboard-jobs' }, - }); + // from the dashboard API container. + dashboardWorker = createQueueWorker({ + queueName: 'cascade-dashboard-jobs', + label: 'Dashboard job', + connection, + concurrency: routerConfig.maxWorkers, + lockDuration: routerConfig.workerTimeoutMs + 60000, + processFn: (job) => guardedSpawn(job as Job), }); console.log('[WorkerManager] Started with max', routerConfig.maxWorkers, 'concurrent workers'); @@ -401,18 +84,7 @@ export async function stopWorkerProcessor(): Promise { // Don't kill active workers — they're independent containers that will // finish their jobs and auto-remove. Workers have their own internal // watchdog (src/utils/lifecycle.ts) for timeout enforcement. - if (activeWorkers.size > 0) { - console.log('[WorkerManager] Detaching from active workers (will continue running):', { - count: activeWorkers.size, - workers: Array.from(activeWorkers.keys()), - }); - } - - // Clear timeout handles so the router process can exit cleanly - for (const [, worker] of activeWorkers) { - clearTimeout(worker.timeoutHandle); - } - activeWorkers.clear(); + detachAll(); console.log('[WorkerManager] Stopped'); } diff --git a/src/trello/client.ts b/src/trello/client.ts index decb279f..98a926e6 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -31,6 +31,52 @@ function getClient(): TrelloJsClient { return new TrelloJsClient({ key: creds.apiKey, token: creds.token }); } +/** + * Make an authenticated request to the Trello REST API. + * Handles credential injection, URL construction, error checking, and JSON parsing. + * + * @param path - The API path, e.g. `/cards/${cardId}/attachments`. Query params may be + * included in the path itself (e.g. `?filter=open`). + * @param opts - Optional method, headers, and body for non-GET requests. + */ +async function trelloFetch( + path: string, + opts?: { method?: string; headers?: Record; body?: unknown }, +): Promise { + const { apiKey, token } = getTrelloCredentials(); + const separator = path.includes('?') ? '&' : '?'; + const url = `https://api.trello.com/1${path}${separator}key=${apiKey}&token=${token}`; + + const fetchOpts: RequestInit = {}; + if (opts?.method) fetchOpts.method = opts.method; + if (opts?.headers) fetchOpts.headers = opts.headers; + if (opts?.body !== undefined) fetchOpts.body = JSON.stringify(opts.body); + + const response = await fetch(url, fetchOpts); + if (!response.ok) { + throw new Error(`Trello API error ${response.status} for ${path.split('?')[0]}`); + } + return response.json() as Promise; +} + +// ============================================================================ +// Shared utilities +// ============================================================================ + +function mapLabels( + labels: Array<{ id?: string; name?: string; color?: string }> | undefined, +): Array<{ id: string; name: string; color: string }> { + return (labels || []).map((l) => ({ + id: l.id || '', + name: l.name || '', + color: l.color || '', + })); +} + +// ============================================================================ +// Types +// ============================================================================ + export interface TrelloCard { id: string; name: string; @@ -94,7 +140,13 @@ export interface TrelloAttachment { date: string; } +// ============================================================================ +// Trello client +// ============================================================================ + export const trelloClient = { + // ===== Card Ops ===== + async getCard(cardId: string): Promise { logger.debug('Fetching Trello card', { cardId }); const card = await getClient().cards.getCard({ id: cardId }); @@ -106,14 +158,72 @@ export const trelloClient = { url: card.url || '', shortUrl: card.shortUrl || '', idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), + labels: mapLabels(labels), }; }, + async updateCard(cardId: string, updates: { name?: string; desc?: string }): Promise { + logger.debug('Updating card', { cardId, hasName: !!updates.name, hasDesc: !!updates.desc }); + await getClient().cards.updateCard({ + id: cardId, + name: updates.name, + desc: updates.desc, + }); + }, + + async moveCardToList(cardId: string, listId: string): Promise { + logger.debug('Moving card to list', { cardId, listId }); + await getClient().cards.updateCard({ + id: cardId, + idList: listId, + }); + }, + + async createCard( + listId: string, + data: { name: string; desc?: string; idLabels?: string[] }, + ): Promise { + logger.debug('Creating card', { listId, name: data.name }); + const card = await getClient().cards.createCard({ + idList: listId, + name: data.name, + desc: data.desc, + idLabels: data.idLabels, + pos: 'bottom', + }); + const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; + return { + id: card.id, + name: card.name || '', + desc: card.desc || '', + url: card.url || '', + shortUrl: card.shortUrl || '', + idList: card.idList || '', + labels: mapLabels(labels), + }; + }, + + async getListCards(listId: string): Promise { + logger.debug('Fetching cards from list', { listId }); + const cards = await getClient().lists.getListCards({ id: listId }); + return cards.map((card) => { + const labels = card.labels as + | Array<{ id?: string; name?: string; color?: string }> + | undefined; + return { + id: card.id, + name: card.name || '', + desc: card.desc || '', + url: card.url || '', + shortUrl: card.shortUrl || '', + idList: card.idList || '', + labels: mapLabels(labels), + }; + }); + }, + + // ===== Comments ===== + async getCardComments(cardId: string): Promise { logger.debug('Fetching card comments', { cardId }); const actions = await getClient().cards.getCardActions({ @@ -135,15 +245,6 @@ export const trelloClient = { })); }, - async updateCard(cardId: string, updates: { name?: string; desc?: string }): Promise { - logger.debug('Updating card', { cardId, hasName: !!updates.name, hasDesc: !!updates.desc }); - await getClient().cards.updateCard({ - id: cardId, - name: updates.name, - desc: updates.desc, - }); - }, - async addComment(cardId: string, text: string): Promise { logger.debug('Adding comment', { cardId, textLength: text.length }); const result = (await getClient().cards.addCardComment({ @@ -155,20 +256,15 @@ export const trelloClient = { async updateComment(actionId: string, text: string): Promise { logger.debug('Updating comment', { actionId, textLength: text.length }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/actions/${actionId}?key=${apiKey}&token=${token}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }, - ); - if (!response.ok) { - throw new Error(`Failed to update comment: ${response.status}`); - } + await trelloFetch(`/actions/${actionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { text }, + }); }, + // ===== Labels ===== + async addLabelToCard(cardId: string, labelId: string): Promise { logger.debug('Adding label to card', { cardId, labelId }); await getClient().cards.addCardLabel({ @@ -185,13 +281,7 @@ export const trelloClient = { }); }, - async moveCardToList(cardId: string, listId: string): Promise { - logger.debug('Moving card to list', { cardId, listId }); - await getClient().cards.updateCard({ - id: cardId, - idList: listId, - }); - }, + // ===== Attachments ===== async addAttachment(cardId: string, url: string, name: string): Promise { logger.debug('Adding attachment', { cardId, name }); @@ -218,127 +308,29 @@ export const trelloClient = { }); }, - async getMyActions(limit = 20): Promise { - logger.debug('Fetching my recent actions', { limit }); - // Use raw fetch since trello.js types don't expose 'limit' parameter - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/members/me/actions?key=${apiKey}&token=${token}&limit=${limit}`, - ); - if (!response.ok) { - throw new Error(`Failed to fetch actions: ${response.status}`); - } - const actions = (await response.json()) as Array<{ - id?: string; - type?: string; - date?: string; - data?: { - card?: { id?: string; name?: string; shortLink?: string }; - list?: { id?: string; name?: string }; - board?: { id?: string; name?: string }; - text?: string; - }; - }>; - return actions.map((a) => ({ + async getCardAttachments(cardId: string): Promise { + logger.debug('Fetching card attachments', { cardId }); + const attachments = await trelloFetch< + Array<{ + id?: string; + name?: string; + url?: string; + mimeType?: string; + bytes?: number; + date?: string; + }> + >(`/cards/${cardId}/attachments`); + return attachments.map((a) => ({ id: a.id || '', - type: a.type || '', + name: a.name || '', + url: a.url || '', + mimeType: a.mimeType || '', + bytes: a.bytes || 0, date: a.date || '', - data: { - card: a.data?.card - ? { - id: a.data.card.id || '', - name: a.data.card.name || '', - shortLink: a.data.card.shortLink, - } - : undefined, - list: a.data?.list - ? { - id: a.data.list.id || '', - name: a.data.list.name || '', - } - : undefined, - board: a.data?.board - ? { - id: a.data.board.id || '', - name: a.data.board.name || '', - } - : undefined, - text: a.data?.text, - }, })); }, - async getMe(): Promise<{ id: string; fullName: string; username: string }> { - logger.debug('Fetching authenticated member info'); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to fetch member: ${response.status}`); - } - const member = (await response.json()) as { - id?: string; - fullName?: string; - username?: string; - }; - return { - id: member.id || '', - fullName: member.fullName || '', - username: member.username || '', - }; - }, - - async getListCards(listId: string): Promise { - logger.debug('Fetching cards from list', { listId }); - const cards = await getClient().lists.getListCards({ id: listId }); - return cards.map((card) => { - const labels = card.labels as - | Array<{ id?: string; name?: string; color?: string }> - | undefined; - return { - id: card.id, - name: card.name || '', - desc: card.desc || '', - url: card.url || '', - shortUrl: card.shortUrl || '', - idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), - }; - }); - }, - - async createCard( - listId: string, - data: { name: string; desc?: string; idLabels?: string[] }, - ): Promise { - logger.debug('Creating card', { listId, name: data.name }); - const card = await getClient().cards.createCard({ - idList: listId, - name: data.name, - desc: data.desc, - idLabels: data.idLabels, - pos: 'bottom', - }); - const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; - return { - id: card.id, - name: card.name || '', - desc: card.desc || '', - url: card.url || '', - shortUrl: card.shortUrl || '', - idList: card.idList || '', - labels: (labels || []).map((l) => ({ - id: l.id || '', - name: l.name || '', - color: l.color || '', - })), - }; - }, + // ===== Checklists ===== async createChecklist(cardId: string, name: string): Promise { logger.debug('Creating checklist', { cardId, name }); @@ -413,39 +405,17 @@ export const trelloClient = { }); }, - async addActionReaction( - actionId: string, - emoji: { shortName: string; native: string; unified: string }, - ): Promise { - logger.debug('Adding reaction to Trello action', { actionId, emoji: emoji.shortName }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/actions/${actionId}/reactions?key=${apiKey}&token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ emoji }), - }, - ); - if (!response.ok) { - throw new Error(`Failed to add reaction to action: ${response.status}`); - } - }, + // ===== Custom Fields ===== async getCardCustomFieldItems(cardId: string): Promise { logger.debug('Fetching card custom field items', { cardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/customFieldItems?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to get custom fields: ${response.status}`); - } - const items = (await response.json()) as Array<{ - id?: string; - idCustomField?: string; - value?: { number?: string; text?: string; checked?: string }; - }>; + const items = await trelloFetch< + Array<{ + id?: string; + idCustomField?: string; + value?: { number?: string; text?: string; checked?: string }; + }> + >(`/cards/${cardId}/customFieldItems`); return items.map((item) => ({ id: item.id || '', idCustomField: item.idCustomField || '', @@ -453,50 +423,140 @@ export const trelloClient = { })); }, - async getCardAttachments(cardId: string): Promise { - logger.debug('Fetching card attachments', { cardId }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/attachments?key=${apiKey}&token=${token}`, - ); - if (!response.ok) { - throw new Error(`Failed to get attachments: ${response.status}`); - } - const attachments = (await response.json()) as Array<{ - id?: string; - name?: string; - url?: string; - mimeType?: string; - bytes?: number; - date?: string; - }>; - return attachments.map((a) => ({ - id: a.id || '', - name: a.name || '', - url: a.url || '', - mimeType: a.mimeType || '', - bytes: a.bytes || 0, - date: a.date || '', - })); - }, - async updateCardCustomFieldNumber( cardId: string, customFieldId: string, value: number, ): Promise { logger.debug('Updating card custom field', { cardId, customFieldId, value }); - const { apiKey, token } = getTrelloCredentials(); - const response = await fetch( - `https://api.trello.com/1/cards/${cardId}/customField/${customFieldId}/item?key=${apiKey}&token=${token}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: { number: value.toString() } }), + await trelloFetch(`/cards/${cardId}/customField/${customFieldId}/item`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { value: { number: value.toString() } }, + }); + }, + + // ===== Board Ops ===== + + async getBoards(): Promise> { + logger.debug('Fetching boards for authenticated member'); + const boards = await trelloFetch>( + '/members/me/boards?filter=open&fields=id,name,url', + ); + return boards.map((b) => ({ + id: b.id || '', + name: b.name || '', + url: b.url || '', + })); + }, + + async getBoardLists(boardId: string): Promise> { + logger.debug('Fetching board lists', { boardId }); + const lists = await trelloFetch>( + `/boards/${boardId}/lists?filter=open`, + ); + return lists.map((l) => ({ + id: l.id || '', + name: l.name || '', + })); + }, + + async getBoardLabels( + boardId: string, + ): Promise> { + logger.debug('Fetching board labels', { boardId }); + const labels = await trelloFetch>( + `/boards/${boardId}/labels`, + ); + return labels.map((l) => ({ + id: l.id || '', + name: l.name || '', + color: l.color || '', + })); + }, + + async getBoardCustomFields( + boardId: string, + ): Promise> { + logger.debug('Fetching board custom fields', { boardId }); + const fields = await trelloFetch>( + `/boards/${boardId}/customFields`, + ); + return fields.map((f) => ({ + id: f.id || '', + name: f.name || '', + type: f.type || '', + })); + }, + + // ===== Member / Actions ===== + + async getMyActions(limit = 20): Promise { + logger.debug('Fetching my recent actions', { limit }); + // Use raw fetch since trello.js types don't expose 'limit' parameter + const actions = await trelloFetch< + Array<{ + id?: string; + type?: string; + date?: string; + data?: { + card?: { id?: string; name?: string; shortLink?: string }; + list?: { id?: string; name?: string }; + board?: { id?: string; name?: string }; + text?: string; + }; + }> + >(`/members/me/actions?limit=${limit}`); + return actions.map((a) => ({ + id: a.id || '', + type: a.type || '', + date: a.date || '', + data: { + card: a.data?.card + ? { + id: a.data.card.id || '', + name: a.data.card.name || '', + shortLink: a.data.card.shortLink, + } + : undefined, + list: a.data?.list + ? { + id: a.data.list.id || '', + name: a.data.list.name || '', + } + : undefined, + board: a.data?.board + ? { + id: a.data.board.id || '', + name: a.data.board.name || '', + } + : undefined, + text: a.data?.text, }, + })); + }, + + async getMe(): Promise<{ id: string; fullName: string; username: string }> { + logger.debug('Fetching authenticated member info'); + const member = await trelloFetch<{ id?: string; fullName?: string; username?: string }>( + '/members/me', ); - if (!response.ok) { - throw new Error(`Failed to update custom field: ${response.status}`); - } + return { + id: member.id || '', + fullName: member.fullName || '', + username: member.username || '', + }; + }, + + async addActionReaction( + actionId: string, + emoji: { shortName: string; native: string; unified: string }, + ): Promise { + logger.debug('Adding reaction to Trello action', { actionId, emoji: emoji.shortName }); + await trelloFetch(`/actions/${actionId}/reactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { emoji }, + }); }, }; diff --git a/src/triggers/github/pr-opened.ts b/src/triggers/github/pr-opened.ts index daba972d..c2193753 100644 --- a/src/triggers/github/pr-opened.ts +++ b/src/triggers/github/pr-opened.ts @@ -1,4 +1,5 @@ import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { isCascadeBot } from '../../github/personas.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isGitHubPullRequestPayload } from './types.js'; @@ -38,11 +39,20 @@ export class PROpenedTrigger implements TriggerHandler { body: string | null; html_url: string; head: { ref: string }; + user: { login: string }; }; repository: { full_name: string }; }; const prNumber = payload.pull_request.number; + const prAuthor = payload.pull_request.user.login; + + // Skip PRs authored by CASCADE bots — nothing to "respond to" on our own PRs + if (ctx.personaIdentities && isCascadeBot(prAuthor, ctx.personaIdentities)) { + logger.info('Skipping PR opened by CASCADE bot', { prNumber, prAuthor }); + return null; + } + const prBody = payload.pull_request.body || ''; // Resolve work item from DB (with PR body fallback) diff --git a/src/utils/redis.ts b/src/utils/redis.ts new file mode 100644 index 00000000..50d46c93 --- /dev/null +++ b/src/utils/redis.ts @@ -0,0 +1,20 @@ +/** + * Shared Redis utility functions. + * + * Provides a single implementation of Redis URL parsing used by BullMQ + * consumers across the codebase (router queues, dashboard queue, worker manager). + */ + +import type { ConnectionOptions } from 'bullmq'; + +/** + * Parse a Redis URL string into BullMQ ConnectionOptions. + */ +export function parseRedisUrl(url: string): ConnectionOptions { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: Number(parsed.port) || 6379, + password: parsed.password || undefined, + }; +} diff --git a/tests/unit/agents/shared/builderFactory.test.ts b/tests/unit/agents/shared/builderFactory.test.ts new file mode 100644 index 00000000..7ea267c9 --- /dev/null +++ b/tests/unit/agents/shared/builderFactory.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/squintDb.js', () => ({ + resolveSquintDbPath: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/config/compactionConfig.js', () => ({ + getCompactionConfig: vi.fn().mockReturnValue({ maxTokens: 100000, strategy: 'hybrid' }), +})); + +vi.mock('../../../../src/config/hintConfig.js', () => ({ + getIterationTrailingMessage: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/config/rateLimits.js', () => ({ + getRateLimitForModel: vi.fn().mockReturnValue({ rpm: 60, tpm: 100000 }), +})); + +vi.mock('../../../../src/config/retryConfig.js', () => ({ + getRetryConfig: vi.fn().mockReturnValue({ maxRetries: 3 }), +})); + +vi.mock('../../../../src/gadgets/sessionState.js', () => ({ + initSessionState: vi.fn(), +})); + +vi.mock('../../../../src/agents/utils/hooks.js', () => ({ + createObserverHooks: vi.fn().mockReturnValue({ onIteration: vi.fn() }), +})); + +// Mock llmist +const mockBuilderInstance = { + withModel: vi.fn(), + withTemperature: vi.fn(), + withSystem: vi.fn(), + withMaxIterations: vi.fn(), + withLogger: vi.fn(), + withRateLimits: vi.fn(), + withRetry: vi.fn(), + withCompaction: vi.fn(), + withTrailingMessage: vi.fn(), + withTextOnlyHandler: vi.fn(), + withHooks: vi.fn(), + withGadgets: vi.fn(), + withMaxGadgetsPerResponse: vi.fn(), + withBudget: vi.fn(), +}; + +// Each method returns the builder for chaining +for (const key of Object.keys(mockBuilderInstance)) { + (mockBuilderInstance as Record)[key] = vi + .fn() + .mockReturnValue(mockBuilderInstance); +} + +vi.mock('llmist', () => ({ + AgentBuilder: vi.fn().mockImplementation(() => mockBuilderInstance), + BudgetPricingUnavailableError: class BudgetPricingUnavailableError extends Error {}, +})); + +import { AgentBuilder, BudgetPricingUnavailableError } from 'llmist'; +import { + createConfiguredBuilder, + isSquintEnabled, +} from '../../../../src/agents/shared/builderFactory.js'; +import { initSessionState } from '../../../../src/gadgets/sessionState.js'; +import { resolveSquintDbPath } from '../../../../src/utils/squintDb.js'; + +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockInitSessionState = vi.mocked(initSessionState); +const MockAgentBuilder = vi.mocked(AgentBuilder); + +function createBaseOptions(overrides?: object) { + return { + client: {} as never, + agentType: 'implementation', + model: 'claude-sonnet-4', + systemPrompt: 'You are a helpful assistant', + maxIterations: 20, + llmistLogger: {} as never, + trackingContext: { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + } as never, + logWriter: vi.fn(), + llmCallLogger: {} as never, + repoDir: '/repo', + gadgets: [] as never, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResolveSquintDbPath.mockReturnValue(null); + + // Reset all mock builder methods to return the builder instance + for (const key of Object.keys(mockBuilderInstance)) { + (mockBuilderInstance as Record>)[key].mockReturnValue( + mockBuilderInstance, + ); + } +}); + +// ============================================================================ +// isSquintEnabled +// ============================================================================ + +describe('isSquintEnabled', () => { + it('returns false when resolveSquintDbPath returns null', () => { + mockResolveSquintDbPath.mockReturnValue(null); + expect(isSquintEnabled('/repo')).toBe(false); + }); + + it('returns true when resolveSquintDbPath returns a path', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + expect(isSquintEnabled('/repo')).toBe(true); + }); +}); + +// ============================================================================ +// createConfiguredBuilder +// ============================================================================ + +describe('createConfiguredBuilder', () => { + it('creates an AgentBuilder with the given client', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(MockAgentBuilder).toHaveBeenCalledWith(options.client); + }); + + it('configures the model', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withModel).toHaveBeenCalledWith('claude-sonnet-4'); + }); + + it('configures the system prompt', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withSystem).toHaveBeenCalledWith('You are a helpful assistant'); + }); + + it('configures max iterations', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withMaxIterations).toHaveBeenCalledWith(20); + }); + + it('sets temperature to 0', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withTemperature).toHaveBeenCalledWith(0); + }); + + it('calls initSessionState when skipSessionState is not set', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockInitSessionState).toHaveBeenCalledWith( + 'implementation', + undefined, + undefined, + undefined, + ); + }); + + it('skips initSessionState when skipSessionState is true', () => { + const options = createBaseOptions({ skipSessionState: true }); + createConfiguredBuilder(options); + expect(mockInitSessionState).not.toHaveBeenCalled(); + }); + + it('passes baseBranch, projectId, cardId to initSessionState', () => { + const options = createBaseOptions({ + baseBranch: 'main', + projectId: 'project-1', + cardId: 'card-123', + }); + createConfiguredBuilder(options); + expect(mockInitSessionState).toHaveBeenCalledWith( + 'implementation', + 'main', + 'project-1', + 'card-123', + ); + }); + + it('calls withBudget when remainingBudgetUsd is positive', () => { + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).toHaveBeenCalledWith(5.0); + }); + + it('does not call withBudget when remainingBudgetUsd is undefined', () => { + const options = createBaseOptions({ remainingBudgetUsd: undefined }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).not.toHaveBeenCalled(); + }); + + it('does not call withBudget when remainingBudgetUsd is 0', () => { + const options = createBaseOptions({ remainingBudgetUsd: 0 }); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withBudget).not.toHaveBeenCalled(); + }); + + it('handles BudgetPricingUnavailableError gracefully', () => { + mockBuilderInstance.withBudget.mockImplementationOnce(() => { + throw new BudgetPricingUnavailableError('Budget unavailable'); + }); + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + + // Should not throw + expect(() => createConfiguredBuilder(options)).not.toThrow(); + }); + + it('rethrows non-BudgetPricingUnavailableError errors from withBudget', () => { + mockBuilderInstance.withBudget.mockImplementationOnce(() => { + throw new Error('Unexpected budget error'); + }); + const options = createBaseOptions({ remainingBudgetUsd: 5.0 }); + + expect(() => createConfiguredBuilder(options)).toThrow('Unexpected budget error'); + }); + + it('calls postConfigure callback when provided', () => { + const customBuilder = { ...mockBuilderInstance, custom: true }; + const postConfigure = vi.fn().mockReturnValue(customBuilder); + const options = createBaseOptions({ postConfigure }); + + const result = createConfiguredBuilder(options); + + expect(postConfigure).toHaveBeenCalled(); + expect(result).toBe(customBuilder); + }); + + it('does not call postConfigure when not provided', () => { + const options = createBaseOptions({ postConfigure: undefined }); + + // Should not throw and returns builder + expect(() => createConfiguredBuilder(options)).not.toThrow(); + }); + + it('returns a builder with max gadgets per response set', () => { + const options = createBaseOptions(); + createConfiguredBuilder(options); + expect(mockBuilderInstance.withMaxGadgetsPerResponse).toHaveBeenCalledWith(25); + }); +}); diff --git a/tests/unit/agents/shared/executionPipeline.test.ts b/tests/unit/agents/shared/executionPipeline.test.ts new file mode 100644 index 00000000..042d0978 --- /dev/null +++ b/tests/unit/agents/shared/executionPipeline.test.ts @@ -0,0 +1,524 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock all external dependencies +vi.mock('../../../../src/utils/fileLogger.js', () => ({ + createFileLogger: vi.fn(), + cleanupLogFile: vi.fn(), + cleanupLogDirectory: vi.fn(), +})); + +vi.mock('../../../../src/agents/utils/logging.js', () => ({ + createAgentLogger: vi.fn(), +})); + +vi.mock('../../../../src/utils/cascadeEnv.js', () => ({ + loadCascadeEnv: vi.fn(), + unloadCascadeEnv: vi.fn(), +})); + +vi.mock('../../../../src/utils/repo.js', () => ({ + cleanupTempDir: vi.fn(), +})); + +vi.mock('../../../../src/utils/lifecycle.js', () => ({ + setWatchdogCleanup: vi.fn(), + clearWatchdogCleanup: vi.fn(), +})); + +vi.mock('../../../../src/utils/squintDb.js', () => ({ + setupRemoteSquintDb: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +const mockCaptureException = vi.fn(); +vi.mock('../../../../src/sentry.js', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + +vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ + createRun: vi.fn(), + completeRun: vi.fn(), + storeRunLogs: vi.fn(), +})); + +import { + createLogWriter, + executeAgentPipeline, +} from '../../../../src/agents/shared/executionPipeline.js'; +import { createAgentLogger } from '../../../../src/agents/utils/logging.js'; +import { loadCascadeEnv, unloadCascadeEnv } from '../../../../src/utils/cascadeEnv.js'; +import { + cleanupLogDirectory, + cleanupLogFile, + createFileLogger, +} from '../../../../src/utils/fileLogger.js'; +import { clearWatchdogCleanup, setWatchdogCleanup } from '../../../../src/utils/lifecycle.js'; +import { logger } from '../../../../src/utils/logging.js'; +import { cleanupTempDir } from '../../../../src/utils/repo.js'; +import { setupRemoteSquintDb } from '../../../../src/utils/squintDb.js'; + +const mockCreateFileLogger = vi.mocked(createFileLogger); +const mockCreateAgentLogger = vi.mocked(createAgentLogger); +const mockLoadCascadeEnv = vi.mocked(loadCascadeEnv); +const mockUnloadCascadeEnv = vi.mocked(unloadCascadeEnv); +const mockCleanupTempDir = vi.mocked(cleanupTempDir); +const mockCleanupLogFile = vi.mocked(cleanupLogFile); +const mockCleanupLogDirectory = vi.mocked(cleanupLogDirectory); +const mockClearWatchdogCleanup = vi.mocked(clearWatchdogCleanup); +const mockSetWatchdogCleanup = vi.mocked(setWatchdogCleanup); +const mockSetupRemoteSquintDb = vi.mocked(setupRemoteSquintDb); + +function setupMocks() { + const mockLoggerInstance = { + write: vi.fn(), + close: vi.fn(), + getZippedBuffer: vi.fn().mockResolvedValue(Buffer.from('logs')), + logPath: '/tmp/test.log', + llmistLogPath: '/tmp/test-llmist.log', + llmCallLogger: { logDir: '/tmp/llm-calls' }, + }; + mockCreateFileLogger.mockReturnValue(mockLoggerInstance as never); + mockCreateAgentLogger.mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never); + mockLoadCascadeEnv.mockReturnValue({}); + mockSetupRemoteSquintDb.mockResolvedValue(null); + return mockLoggerInstance; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.CASCADE_LOCAL_MODE = ''; +}); + +describe('executeAgentPipeline', () => { + it('returns successful result from execute callback', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done', cost: 0.5 }), + }); + + expect(result.success).toBe(true); + expect(result.output).toBe('Done'); + expect(result.cost).toBe(0.5); + }); + + it('returns error result when execute callback throws', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => { + throw new Error('Execute failed'); + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Execute failed'); + }); + + it('returns error result when setupRepoDir throws', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockRejectedValue(new Error('Repo setup failed')), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Repo setup failed'); + }); + + it('loads and unloads cascade env around execution', async () => { + setupMocks(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(mockLoadCascadeEnv).toHaveBeenCalled(); + expect(mockUnloadCascadeEnv).toHaveBeenCalled(); + }); + + it('restores CWD even when execute throws', async () => { + setupMocks(); + const originalCwd = process.cwd(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => { + throw new Error('Failed mid-execution'); + }, + }); + + expect(process.cwd()).toBe(originalCwd); + }); + + it('calls setWatchdogCleanup with a cleanup function', async () => { + setupMocks(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(mockSetWatchdogCleanup).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('cleans up resources in finally block', async () => { + setupMocks(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(mockClearWatchdogCleanup).toHaveBeenCalled(); + expect(mockCleanupTempDir).toHaveBeenCalled(); + expect(mockCleanupLogFile).toHaveBeenCalled(); + expect(mockCleanupLogDirectory).toHaveBeenCalled(); + }); + + it('skips temp dir cleanup when skipRepoDeletion is true', async () => { + setupMocks(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + skipRepoDeletion: true, + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(mockCleanupTempDir).not.toHaveBeenCalled(); + expect(mockCleanupLogFile).toHaveBeenCalled(); + }); + + it('skips cleanup in CASCADE_LOCAL_MODE', async () => { + process.env.CASCADE_LOCAL_MODE = 'true'; + setupMocks(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(mockCleanupTempDir).not.toHaveBeenCalled(); + expect(mockCleanupLogFile).not.toHaveBeenCalled(); + expect(mockCleanupLogDirectory).not.toHaveBeenCalled(); + }); + + it('calls finalizeRun with completed status on success', async () => { + setupMocks(); + const mockFinalizeRun = vi.fn(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: mockFinalizeRun, + execute: async () => ({ success: true, output: 'Done', cost: 1.0 }), + }); + + expect(mockFinalizeRun).toHaveBeenCalledWith( + undefined, + expect.anything(), + expect.objectContaining({ + status: 'completed', + success: true, + durationMs: expect.any(Number), + costUsd: 1.0, + }), + ); + }); + + it('calls finalizeRun with failed status when execute returns failure', async () => { + setupMocks(); + const mockFinalizeRun = vi.fn(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: mockFinalizeRun, + execute: async () => ({ success: false, output: '', error: 'Agent failed' }), + }); + + expect(mockFinalizeRun).toHaveBeenCalledWith( + undefined, + expect.anything(), + expect.objectContaining({ + status: 'failed', + success: false, + error: 'Agent failed', + }), + ); + }); + + it('calls finalizeRun with failed status when execute throws', async () => { + setupMocks(); + const mockFinalizeRun = vi.fn(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: mockFinalizeRun, + execute: async () => { + throw new Error('Unexpected crash'); + }, + }); + + expect(mockFinalizeRun).toHaveBeenCalledWith( + undefined, + expect.anything(), + expect.objectContaining({ + status: 'failed', + success: false, + error: expect.stringContaining('Unexpected crash'), + }), + ); + }); + + it('reports errors to Sentry when execute throws', async () => { + setupMocks(); + const error = new Error('Test error'); + + await executeAgentPipeline({ + loggerIdentifier: 'test-agent', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => { + throw error; + }, + }); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { + source: 'agent_execution', + agent: 'test-agent', + }, + extra: { + runId: undefined, + durationMs: expect.any(Number), + }, + }); + }); + + it('returns durationMs in successful result', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(result.durationMs).toBeDefined(); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(typeof result.durationMs).toBe('number'); + }); + + it('returns durationMs in error result', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => { + throw new Error('Failed'); + }, + }); + + expect(result.durationMs).toBeDefined(); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(typeof result.durationMs).toBe('number'); + }); + + it('provides logBuffer from fileLogger.getZippedBuffer', async () => { + const mockLogger = setupMocks(); + mockLogger.getZippedBuffer.mockResolvedValue(Buffer.from('log-data')); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done' }), + }); + + expect(result.logBuffer).toEqual(Buffer.from('log-data')); + }); + + it('uses logBuffer from execute result if provided', async () => { + setupMocks(); + const customBuffer = Buffer.from('custom-log'); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async () => ({ success: true, output: 'Done', logBuffer: customBuffer }), + }); + + expect(result.logBuffer).toEqual(customBuffer); + }); + + it('passes runId to finalizeRun when setRunId is called in execute', async () => { + setupMocks(); + const mockFinalizeRun = vi.fn(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: mockFinalizeRun, + execute: async (ctx) => { + ctx.setRunId('my-run-id'); + return { success: true, output: 'Done' }; + }, + }); + + expect(mockFinalizeRun).toHaveBeenCalledWith( + 'my-run-id', + expect.anything(), + expect.any(Object), + ); + }); + + it('returns runId in result when setRunId is called in execute', async () => { + setupMocks(); + + const result = await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + execute: async (ctx) => { + ctx.setRunId('test-run-id'); + return { success: true, output: 'Done' }; + }, + }); + + expect(result.runId).toBe('test-run-id'); + }); + + it('calls onWatchdogTimeout when watchdog fires', async () => { + setupMocks(); + const mockOnWatchdog = vi.fn(); + + // Capture the watchdog cleanup function + let watchdogCleanup: (() => Promise) | undefined; + mockSetWatchdogCleanup.mockImplementation((fn) => { + watchdogCleanup = fn; + }); + + // Start the pipeline but don't wait for it — we'll fire the watchdog manually + const pipelinePromise = executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: vi.fn(), + onWatchdogTimeout: mockOnWatchdog, + execute: vi.fn().mockResolvedValue({ success: true, output: 'Done' }), + }); + + await pipelinePromise; + + // Manually invoke the captured watchdog cleanup + expect(watchdogCleanup).toBeDefined(); + await watchdogCleanup?.(); + + expect(mockOnWatchdog).toHaveBeenCalled(); + }); + + it('passes finalizeMetadata to finalizeRun', async () => { + setupMocks(); + const mockFinalizeRun = vi.fn(); + + await executeAgentPipeline({ + loggerIdentifier: 'test-run', + setupRepoDir: vi.fn().mockResolvedValue(process.cwd()), + finalizeRun: mockFinalizeRun, + execute: async () => ({ + success: true, + output: 'Done', + finalizeMetadata: { llmIterations: 10, gadgetCalls: 25 }, + }), + }); + + expect(mockFinalizeRun).toHaveBeenCalledWith( + undefined, + expect.anything(), + expect.objectContaining({ + metadata: { llmIterations: 10, gadgetCalls: 25 }, + }), + ); + }); +}); + +describe('createLogWriter', () => { + it('writes to file logger and structured logger', () => { + const mockFileLogger = { + write: vi.fn(), + close: vi.fn(), + getZippedBuffer: vi.fn(), + logPath: '/tmp/test.log', + llmistLogPath: '/tmp/test-llmist.log', + llmCallLogger: { logDir: '/tmp/llm-calls' }, + }; + + const logWriter = createLogWriter(mockFileLogger as never); + logWriter('INFO', 'Test message', { key: 'value' }); + + expect(mockFileLogger.write).toHaveBeenCalledWith('INFO', 'Test message', { key: 'value' }); + expect(logger.info).toHaveBeenCalledWith('Test message', { key: 'value' }); + }); + + it('routes ERROR level to logger.error', () => { + const mockFileLogger = { write: vi.fn() }; + const logWriter = createLogWriter(mockFileLogger as never); + + logWriter('ERROR', 'Error message'); + + expect(logger.error).toHaveBeenCalledWith('Error message', undefined); + }); + + it('routes WARN level to logger.warn', () => { + const mockFileLogger = { write: vi.fn() }; + const logWriter = createLogWriter(mockFileLogger as never); + + logWriter('WARN', 'Warning message'); + + expect(logger.warn).toHaveBeenCalledWith('Warning message', undefined); + }); + + it('routes DEBUG level to logger.debug', () => { + const mockFileLogger = { write: vi.fn() }; + const logWriter = createLogWriter(mockFileLogger as never); + + logWriter('DEBUG', 'Debug message'); + + expect(logger.debug).toHaveBeenCalledWith('Debug message', undefined); + }); +}); diff --git a/tests/unit/agents/shared/syntheticCalls.test.ts b/tests/unit/agents/shared/syntheticCalls.test.ts new file mode 100644 index 00000000..80a2ab13 --- /dev/null +++ b/tests/unit/agents/shared/syntheticCalls.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/squintDb.js', () => ({ + resolveSquintDbPath: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../../../src/agents/utils/tracking.js', () => ({ + recordSyntheticInvocationId: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +// Mock ListDirectory gadget +vi.mock('../../../../src/gadgets/ListDirectory.js', () => ({ + ListDirectory: vi.fn().mockImplementation(() => ({ + execute: vi.fn().mockReturnValue('mocked directory listing output'), + })), +})); + +import { execFileSync } from 'node:child_process'; +import { + injectContextFiles, + injectDirectoryListing, + injectSquintContext, + injectSyntheticCall, +} from '../../../../src/agents/shared/syntheticCalls.js'; +import { recordSyntheticInvocationId } from '../../../../src/agents/utils/tracking.js'; +import { resolveSquintDbPath } from '../../../../src/utils/squintDb.js'; + +const mockResolveSquintDbPath = vi.mocked(resolveSquintDbPath); +const mockExecFileSync = vi.mocked(execFileSync); +const mockRecordSyntheticInvocationId = vi.mocked(recordSyntheticInvocationId); + +function createMockBuilder() { + const builder = { + withSyntheticGadgetCall: vi.fn(), + }; + builder.withSyntheticGadgetCall.mockReturnValue(builder); + return builder; +} + +function createTrackingContext() { + return { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResolveSquintDbPath.mockReturnValue(null); +}); + +describe('injectSyntheticCall', () => { + it('records the invocation ID for tracking', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + { filePath: '/foo.ts' }, + 'content', + 'gc_test', + ); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_test'); + }); + + it('calls withSyntheticGadgetCall on builder with correct params', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + { filePath: '/foo.ts' }, + 'file content', + 'gc_1', + ); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + { filePath: '/foo.ts' }, + 'file content', + 'gc_1', + ); + }); + + it('returns the result of withSyntheticGadgetCall', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSyntheticCall( + builder as never, + ctx as never, + 'ReadFile', + {}, + 'result', + 'gc_2', + ); + + expect(result).toBe(builder); + }); +}); + +describe('injectDirectoryListing', () => { + it('calls injectSyntheticCall with ListDirectory gadget name', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ListDirectory', + expect.objectContaining({ directoryPath: '.', maxDepth: 3 }), + 'mocked directory listing output', + 'gc_dir', + ); + }); + + it('uses custom maxDepth when provided', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never, 5); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ListDirectory', + expect.objectContaining({ maxDepth: 5 }), + expect.any(String), + 'gc_dir', + ); + }); + + it('records the invocation ID gc_dir', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectDirectoryListing(builder as never, ctx as never); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_dir'); + }); +}); + +describe('injectContextFiles', () => { + it('injects multiple context files with sequential IDs', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [ + { path: 'CLAUDE.md', content: '# Project docs' }, + { path: 'AGENTS.md', content: '# Agent docs' }, + ]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledTimes(2); + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ filePath: 'CLAUDE.md' }), + '# Project docs', + 'gc_init_1', + ); + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ filePath: 'AGENTS.md' }), + '# Agent docs', + 'gc_init_2', + ); + }); + + it('returns builder unchanged when contextFiles is empty', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectContextFiles(builder as never, ctx as never, []); + + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + expect(result).toBe(builder); + }); + + it('records synthetic invocation ID for each file', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [ + { path: 'CLAUDE.md', content: 'docs' }, + { path: 'AGENTS.md', content: 'agents' }, + ]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_1'); + expect(mockRecordSyntheticInvocationId).toHaveBeenCalledWith(ctx, 'gc_init_2'); + }); + + it('includes comment describing the file in ReadFile params', () => { + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + const files = [{ path: 'CLAUDE.md', content: 'docs' }]; + + injectContextFiles(builder as never, ctx as never, files); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'ReadFile', + expect.objectContaining({ comment: expect.stringContaining('CLAUDE.md') }), + 'docs', + 'gc_init_1', + ); + }); +}); + +describe('injectSquintContext', () => { + it('returns builder unchanged when squint DB not found', () => { + mockResolveSquintDbPath.mockReturnValue(null); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); + + it('calls squint overview command when DB is found', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('squint overview output' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'squint', + ['overview', '-d', '/repo/.squint.db'], + { + encoding: 'utf-8', + timeout: 30_000, + }, + ); + }); + + it('injects squint overview as synthetic SquintOverview call', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('# Squint Overview\n- modules: 5' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(builder.withSyntheticGadgetCall).toHaveBeenCalledWith( + 'SquintOverview', + expect.objectContaining({ database: '/repo/.squint.db' }), + '# Squint Overview\n- modules: 5', + 'gc_squint_overview', + ); + }); + + it('returns builder unchanged when squint output is empty', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockReturnValue('' as never); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); + + it('returns builder unchanged when squint command throws', () => { + mockResolveSquintDbPath.mockReturnValue('/repo/.squint.db'); + mockExecFileSync.mockImplementation(() => { + throw new Error('squint not found'); + }); + const builder = createMockBuilder(); + const ctx = createTrackingContext(); + + const result = injectSquintContext(builder as never, ctx as never, '/repo'); + + expect(result).toBe(builder); + expect(builder.withSyntheticGadgetCall).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/agents/utils/agentLoop.test.ts b/tests/unit/agents/utils/agentLoop.test.ts new file mode 100644 index 00000000..dd06b6c9 --- /dev/null +++ b/tests/unit/agents/utils/agentLoop.test.ts @@ -0,0 +1,486 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock external dependencies +vi.mock('../../../../src/gadgets/tmux.js', () => ({ + consumePendingSessionNotices: vi.fn().mockReturnValue(new Map()), +})); + +vi.mock('../../../../src/utils/interactive.js', () => ({ + displayGadgetCall: vi.fn(), + displayGadgetResult: vi.fn(), + displayLLMText: vi.fn(), + waitForEnter: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../../src/agents/utils/logging.js', () => ({ + createAgentLogger: vi.fn(), +})); + +vi.mock('../../../../src/agents/utils/tracking.js', () => ({ + consumeLoopAction: vi.fn().mockReturnValue(null), + consumeLoopWarning: vi.fn().mockReturnValue(null), + incrementGadgetCall: vi.fn(), + isSyntheticCall: vi.fn().mockReturnValue(false), + recordGadgetCallForLoop: vi.fn(), +})); + +import { runAgentLoop, truncateContent } from '../../../../src/agents/utils/agentLoop.js'; +import { + consumeLoopAction, + consumeLoopWarning, + incrementGadgetCall, + isSyntheticCall, + recordGadgetCallForLoop, +} from '../../../../src/agents/utils/tracking.js'; +import { consumePendingSessionNotices } from '../../../../src/gadgets/tmux.js'; +import { + displayGadgetCall, + displayGadgetResult, + displayLLMText, + waitForEnter, +} from '../../../../src/utils/interactive.js'; + +const mockConsumePendingSessionNotices = vi.mocked(consumePendingSessionNotices); +const mockDisplayGadgetCall = vi.mocked(displayGadgetCall); +const mockDisplayGadgetResult = vi.mocked(displayGadgetResult); +const mockDisplayLLMText = vi.mocked(displayLLMText); +const mockWaitForEnter = vi.mocked(waitForEnter); +const mockConsumeLoopAction = vi.mocked(consumeLoopAction); +const mockConsumeLoopWarning = vi.mocked(consumeLoopWarning); +const mockIncrementGadgetCall = vi.mocked(incrementGadgetCall); +const mockIsSyntheticCall = vi.mocked(isSyntheticCall); +const mockRecordGadgetCallForLoop = vi.mocked(recordGadgetCallForLoop); + +function createMockLog() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createTrackingContext(overrides?: object) { + return { + metrics: { llmIterations: 0, gadgetCalls: 0 }, + syntheticInvocationIds: new Set(), + loopDetection: { + previousIterationCalls: [], + currentIterationCalls: [], + repeatCount: 1, + repeatedPattern: null, + pendingWarning: null, + nameOnlyRepeatCount: 1, + pendingAction: null, + }, + ...overrides, + }; +} + +function createMockAgent(events: object[]) { + return { + run: async function* () { + for (const event of events) { + yield event; + } + }, + getTree: vi.fn().mockReturnValue({ getTotalCost: vi.fn().mockReturnValue(1.5) }), + injectUserMessage: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockConsumePendingSessionNotices.mockReturnValue(new Map()); + mockConsumeLoopWarning.mockReturnValue(null); + mockConsumeLoopAction.mockReturnValue(null); + mockIsSyntheticCall.mockReturnValue(false); +}); + +// ============================================================================ +// truncateContent +// ============================================================================ + +describe('truncateContent', () => { + it('returns content unchanged if within maxLen', () => { + const content = 'hello world'; + expect(truncateContent(content, 400)).toBe('hello world'); + }); + + it('truncates content that exceeds maxLen', () => { + const content = 'a'.repeat(500); + const result = truncateContent(content, 400); + expect(result).toContain('[100 truncated]'); + expect(result.length).toBeLessThan(500); + }); + + it('uses default maxLen of 400', () => { + const content = 'x'.repeat(500); + const result = truncateContent(content); + expect(result).toContain('truncated'); + }); + + it('preserves first and last half of content', () => { + const content = `FIRST${'x'.repeat(500)}LAST`; + const result = truncateContent(content, 400); + expect(result.startsWith('FIRST')).toBe(true); + expect(result.endsWith('LAST')).toBe(true); + }); + + it('returns content exactly at maxLen unchanged', () => { + const content = 'a'.repeat(400); + expect(truncateContent(content, 400)).toBe(content); + }); +}); + +// ============================================================================ +// runAgentLoop +// ============================================================================ + +describe('runAgentLoop', () => { + it('processes text events and accumulates output', async () => { + const agent = createMockAgent([ + { type: 'text', content: 'Hello' }, + { type: 'text', content: 'World' }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.output).toBe('Hello\nWorld'); + }); + + it('returns cost from getTree().getTotalCost()', async () => { + const agent = createMockAgent([]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.cost).toBe(1.5); + }); + + it('returns zero cost if getTree() returns null', async () => { + const agent = createMockAgent([]); + agent.getTree.mockReturnValue(null); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.cost).toBe(0); + }); + + it('tracks gadget calls via incrementGadgetCall for non-synthetic calls', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_1', parameters: { filePath: '/foo.ts' } }, + }, + ]); + mockIsSyntheticCall.mockReturnValue(false); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(mockIncrementGadgetCall).toHaveBeenCalled(); + expect(mockRecordGadgetCallForLoop).toHaveBeenCalled(); + }); + + it('does not call incrementGadgetCall for synthetic calls', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_init_1', parameters: {} }, + }, + ]); + mockIsSyntheticCall.mockReturnValue(true); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(mockIncrementGadgetCall).not.toHaveBeenCalled(); + }); + + it('handles gadget_result events and logs them', async () => { + const agent = createMockAgent([ + { + type: 'gadget_result', + result: { gadgetName: 'ReadFile', executionTimeMs: 50, result: 'file contents' }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget result]', + expect.objectContaining({ name: 'ReadFile', ms: 50 }), + ); + }); + + it('logs error for gadget_result with error field', async () => { + const agent = createMockAgent([ + { + type: 'gadget_result', + result: { gadgetName: 'WriteFile', executionTimeMs: 10, error: 'Permission denied' }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.error).toHaveBeenCalledWith( + '[Gadget result]', + expect.objectContaining({ error: 'Permission denied' }), + ); + }); + + it('handles stream_complete event by logging info', async () => { + const agent = createMockAgent([{ type: 'stream_complete' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith('Stream complete', expect.any(Object)); + }); + + it('calls injectUserMessage when session completions are pending', async () => { + const notice = { exitCode: 0, tailOutput: 'output' }; + mockConsumePendingSessionNotices.mockReturnValue(new Map([['session1', notice]])); + + const agent = createMockAgent([{ type: 'text', content: 'Hello' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(agent.injectUserMessage).toHaveBeenCalledWith(expect.stringContaining('session1')); + expect(agent.injectUserMessage).toHaveBeenCalledWith(expect.stringContaining('exit code 0')); + }); + + it('injects loop warning messages when pending', async () => { + mockConsumeLoopWarning.mockReturnValue('⚠️ LOOP DETECTED — please change approach'); + + const agent = createMockAgent([{ type: 'text', content: 'Hello' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(agent.injectUserMessage).toHaveBeenCalledWith( + '⚠️ LOOP DETECTED — please change approach', + ); + }); + + it('terminates with hard_stop when loop action says hard_stop', async () => { + mockConsumeLoopAction.mockReturnValue({ + type: 'hard_stop', + message: '[System] 🛑 SEMANTIC LOOP — FORCED TERMINATION', + }); + + // Create a generator that yields multiple events + const events = [ + { type: 'text', content: 'First' }, + { type: 'text', content: 'Second' }, + ]; + const agent = createMockAgent(events); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.loopTerminated).toBe(true); + expect(log.error).toHaveBeenCalledWith( + '[Loop Hard Stop] Agent terminated due to persistent semantic loop', + expect.any(Object), + ); + }); + + it('does not set loopTerminated when normal completion', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Done' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.loopTerminated).toBe(false); + }); + + it('displays text in interactive mode', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Interactive output' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true); + + expect(mockDisplayLLMText).toHaveBeenCalledWith('Interactive output'); + }); + + it('does not display text in non-interactive mode', async () => { + const agent = createMockAgent([{ type: 'text', content: 'Silent output' }]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, false); + + expect(mockDisplayLLMText).not.toHaveBeenCalled(); + }); + + it('displays gadget calls in interactive mode', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_1', parameters: { filePath: '/foo.ts' } }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true); + + expect(mockDisplayGadgetCall).toHaveBeenCalledWith('ReadFile', { filePath: '/foo.ts' }, false); + }); + + it('waits for enter on non-synthetic gadget call in interactive non-autoAccept mode', async () => { + mockIsSyntheticCall.mockReturnValue(false); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'WriteFile', invocationId: 'gc_2', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, false); + + expect(mockWaitForEnter).toHaveBeenCalled(); + }); + + it('skips waitForEnter for synthetic calls even in interactive mode', async () => { + mockIsSyntheticCall.mockReturnValue(true); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'ReadFile', invocationId: 'gc_init_1', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, false); + + expect(mockWaitForEnter).not.toHaveBeenCalled(); + }); + + it('skips waitForEnter in autoAccept mode', async () => { + mockIsSyntheticCall.mockReturnValue(false); + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { gadgetName: 'WriteFile', invocationId: 'gc_2', parameters: {} }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never, true, true); + + expect(mockWaitForEnter).not.toHaveBeenCalled(); + }); + + it('returns gadgetCalls and iterations from tracking context', async () => { + const ctx = createTrackingContext({ metrics: { llmIterations: 5, gadgetCalls: 12 } }); + const agent = createMockAgent([]); + const log = createMockLog(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.iterations).toBe(5); + expect(result.gadgetCalls).toBe(12); + }); + + it('adds comment to log context when present in parameters', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'Bash', + invocationId: 'gc_1', + parameters: { comment: 'Running tests', command: 'npm test' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ comment: 'Running tests' }), + ); + }); + + it('adds path to log context for gadgets with filePath parameter', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'ReadFile', + invocationId: 'gc_1', + parameters: { filePath: '/src/foo.ts' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ path: '/src/foo.ts' }), + ); + }); + + it('adds params to log context for Tmux gadget', async () => { + const agent = createMockAgent([ + { + type: 'gadget_call', + call: { + gadgetName: 'Tmux', + invocationId: 'gc_1', + parameters: { session: 'test', command: 'npm test' }, + }, + }, + ]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + await runAgentLoop(agent as never, log as never, ctx as never); + + expect(log.info).toHaveBeenCalledWith( + '[Gadget]', + expect.objectContaining({ params: { session: 'test', command: 'npm test' } }), + ); + }); + + it('handles empty events gracefully', async () => { + const agent = createMockAgent([]); + const log = createMockLog(); + const ctx = createTrackingContext(); + + const result = await runAgentLoop(agent as never, log as never, ctx as never); + + expect(result.output).toBe(''); + expect(result.loopTerminated).toBe(false); + }); +}); diff --git a/tests/unit/agents/utils/logging.test.ts b/tests/unit/agents/utils/logging.test.ts new file mode 100644 index 00000000..35cb1c00 --- /dev/null +++ b/tests/unit/agents/utils/logging.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { createAgentLogger } from '../../../../src/agents/utils/logging.js'; +import { logger } from '../../../../src/utils/logging.js'; + +const mockLogger = vi.mocked(logger); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createAgentLogger', () => { + it('debug writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.debug('test debug', { key: 'value' }); + + expect(mockLogger.debug).toHaveBeenCalledWith('test debug', { key: 'value' }); + expect(fileLogger.write).toHaveBeenCalledWith('DEBUG', 'test debug', { key: 'value' }); + }); + + it('info writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.info('test info', { foo: 'bar' }); + + expect(mockLogger.info).toHaveBeenCalledWith('test info', { foo: 'bar' }); + expect(fileLogger.write).toHaveBeenCalledWith('INFO', 'test info', { foo: 'bar' }); + }); + + it('warn writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.warn('test warn'); + + expect(mockLogger.warn).toHaveBeenCalledWith('test warn', undefined); + expect(fileLogger.write).toHaveBeenCalledWith('WARN', 'test warn', undefined); + }); + + it('error writes to both console logger and file logger', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.error('test error', { errCode: 42 }); + + expect(mockLogger.error).toHaveBeenCalledWith('test error', { errCode: 42 }); + expect(fileLogger.write).toHaveBeenCalledWith('ERROR', 'test error', { errCode: 42 }); + }); + + it('works with null fileLogger — only writes to console logger', () => { + const agentLogger = createAgentLogger(null); + + agentLogger.info('no file logger', { x: 1 }); + + expect(mockLogger.info).toHaveBeenCalledWith('no file logger', { x: 1 }); + }); + + it('does not throw when fileLogger is null for all log levels', () => { + const agentLogger = createAgentLogger(null); + + expect(() => agentLogger.debug('d')).not.toThrow(); + expect(() => agentLogger.info('i')).not.toThrow(); + expect(() => agentLogger.warn('w')).not.toThrow(); + expect(() => agentLogger.error('e')).not.toThrow(); + }); + + it('works with no context argument', () => { + const fileLogger = { write: vi.fn() }; + const agentLogger = createAgentLogger(fileLogger as never); + + agentLogger.info('no context'); + + expect(mockLogger.info).toHaveBeenCalledWith('no context', undefined); + expect(fileLogger.write).toHaveBeenCalledWith('INFO', 'no context', undefined); + }); +}); diff --git a/tests/unit/agents/utils/setup.test.ts b/tests/unit/agents/utils/setup.test.ts new file mode 100644 index 00000000..576eff93 --- /dev/null +++ b/tests/unit/agents/utils/setup.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +vi.mock('../../../../src/utils/repo.js', () => ({ + runCommand: vi.fn(), +})); + +import { existsSync, readFileSync } from 'node:fs'; +import { + LOG_LEVELS, + getLogLevel, + installDependencies, + readContextFiles, + warmTypeScriptCache, +} from '../../../../src/agents/utils/setup.js'; +import { runCommand } from '../../../../src/utils/repo.js'; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockRunCommand = vi.mocked(runCommand); + +beforeEach(() => { + vi.clearAllMocks(); + Reflect.deleteProperty(process.env, 'LLMIST_LOG_LEVEL'); + Reflect.deleteProperty(process.env, 'LOG_LEVEL'); +}); + +afterEach(() => { + Reflect.deleteProperty(process.env, 'LLMIST_LOG_LEVEL'); + Reflect.deleteProperty(process.env, 'LOG_LEVEL'); +}); + +// ============================================================================ +// getLogLevel +// ============================================================================ + +describe('getLogLevel', () => { + it('returns debug level (2) by default when no env vars set', () => { + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('reads from LLMIST_LOG_LEVEL env var first', () => { + process.env.LLMIST_LOG_LEVEL = 'info'; + expect(getLogLevel()).toBe(LOG_LEVELS.info); + }); + + it('reads from LOG_LEVEL env var when LLMIST_LOG_LEVEL is not set', () => { + process.env.LOG_LEVEL = 'warn'; + expect(getLogLevel()).toBe(LOG_LEVELS.warn); + }); + + it('LLMIST_LOG_LEVEL takes precedence over LOG_LEVEL', () => { + process.env.LLMIST_LOG_LEVEL = 'error'; + process.env.LOG_LEVEL = 'info'; + expect(getLogLevel()).toBe(LOG_LEVELS.error); + }); + + it('is case-insensitive', () => { + process.env.LOG_LEVEL = 'DEBUG'; + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('returns debug level for unknown log level strings', () => { + process.env.LOG_LEVEL = 'unknown-level'; + expect(getLogLevel()).toBe(LOG_LEVELS.debug); + }); + + it('has correct numeric values for standard log levels', () => { + expect(LOG_LEVELS.silly).toBe(0); + expect(LOG_LEVELS.trace).toBe(1); + expect(LOG_LEVELS.debug).toBe(2); + expect(LOG_LEVELS.info).toBe(3); + expect(LOG_LEVELS.warn).toBe(4); + expect(LOG_LEVELS.error).toBe(5); + expect(LOG_LEVELS.fatal).toBe(6); + }); +}); + +// ============================================================================ +// readContextFiles +// ============================================================================ + +describe('readContextFiles', () => { + it('returns CLAUDE.md and AGENTS.md content when both exist', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '# Claude docs', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([ + { path: 'CLAUDE.md', content: '# Claude docs' }, + { path: 'AGENTS.md', content: '# Agents docs' }, + ]); + }); + + it('skips files that produce empty stdout', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([{ path: 'AGENTS.md', content: '# Agents docs' }]); + }); + + it('skips files that throw (file not found)', async () => { + mockRunCommand + .mockRejectedValueOnce(new Error('ENOENT')) + .mockResolvedValueOnce({ stdout: '# Agents docs', stderr: '' }); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([{ path: 'AGENTS.md', content: '# Agents docs' }]); + }); + + it('returns empty array when all files are missing', async () => { + mockRunCommand.mockRejectedValue(new Error('ENOENT')); + + const result = await readContextFiles('/repo'); + + expect(result).toEqual([]); + }); + + it('trims whitespace from file content', async () => { + mockRunCommand + .mockResolvedValueOnce({ stdout: ' # Claude docs \n', stderr: '' }) + .mockRejectedValueOnce(new Error('ENOENT')); + + const result = await readContextFiles('/repo'); + + expect(result[0].content).toBe('# Claude docs'); + }); +}); + +// ============================================================================ +// installDependencies +// ============================================================================ + +describe('installDependencies', () => { + it('returns null when package.json does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await installDependencies('/repo'); + + expect(result).toBeNull(); + }); + + it('uses npm by default when no lockfile found', async () => { + // package.json exists + mockExistsSync.mockImplementation((path) => { + return String(path).endsWith('package.json'); + }); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: 'installed', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('npm'); + expect(mockRunCommand).toHaveBeenCalledWith('npm', ['install'], '/repo', expect.any(Object)); + }); + + it('detects pnpm from pnpm-lock.yaml', async () => { + mockExistsSync.mockImplementation((path) => { + const p = String(path); + return p.endsWith('package.json') || p.endsWith('pnpm-lock.yaml'); + }); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('pnpm'); + }); + + it('detects yarn from yarn.lock', async () => { + mockExistsSync.mockImplementation((path) => { + const p = String(path); + return p.endsWith('package.json') || p.endsWith('yarn.lock'); + }); + // pnpm-lock.yaml should not exist (checked first) + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('yarn'); + }); + + it('returns success=true when install succeeds', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: 'ok', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.success).toBe(true); + }); + + it('returns success=false when install throws', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockRejectedValue(new Error('install failed')); + + const result = await installDependencies('/repo'); + + expect(result?.success).toBe(false); + expect(result?.error).toContain('install failed'); + }); + + it('passes CI=true environment variable to install', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue('{}' as never); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + await installDependencies('/repo'); + + expect(mockRunCommand).toHaveBeenCalledWith( + expect.any(String), + ['install'], + '/repo', + expect.objectContaining({ CI: 'true' }), + ); + }); + + it('reads packageManager field from package.json as fallback', async () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith('package.json')); + mockReadFileSync.mockReturnValue(JSON.stringify({ packageManager: 'pnpm@8.0.0' }) as never); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await installDependencies('/repo'); + + expect(result?.packageManager).toBe('pnpm'); + }); +}); + +// ============================================================================ +// warmTypeScriptCache +// ============================================================================ + +describe('warmTypeScriptCache', () => { + it('returns null when tsconfig.json does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await warmTypeScriptCache('/repo'); + + expect(result).toBeNull(); + }); + + it('runs tsc --noEmit when tsconfig.json exists', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + await warmTypeScriptCache('/repo'); + + expect(mockRunCommand).toHaveBeenCalledWith('npx', ['tsc', '--noEmit'], '/repo'); + }); + + it('returns success=true when tsc succeeds', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await warmTypeScriptCache('/repo'); + + expect(result?.success).toBe(true); + expect(result?.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('returns success=true even when tsc fails (type errors expected)', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockRejectedValue(new Error('Type error in foo.ts')); + + const result = await warmTypeScriptCache('/repo'); + + expect(result?.success).toBe(true); + expect(result?.error).toContain('Type error in foo.ts'); + }); + + it('includes durationMs in the result', async () => { + mockExistsSync.mockReturnValue(true); + mockRunCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await warmTypeScriptCache('/repo'); + + expect(typeof result?.durationMs).toBe('number'); + }); +}); diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index 105fd882..22858e19 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -76,6 +76,32 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ findProjectByIdFromDb: vi.fn(), })); +vi.mock('../../../src/db/crypto.js', () => ({ + decryptCredential: vi.fn((v: string) => v), +})); + +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(), + trelloClient: { + getMe: vi.fn(), + getBoards: vi.fn(), + getBoardLists: vi.fn(), + getBoardLabels: vi.fn(), + getBoardCustomFields: vi.fn(), + }, +})); + +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: { + getMyself: vi.fn(), + searchProjects: vi.fn(), + getProjectStatuses: vi.fn(), + getIssueTypesForProject: vi.fn(), + getFields: vi.fn(), + }, +})); + vi.mock('@octokit/rest', () => ({ Octokit: vi.fn(() => ({ repos: { @@ -157,4 +183,14 @@ describe('appRouter', () => { expect(procedures).toContain('webhooks.create'); expect(procedures).toContain('webhooks.delete'); }); + + it('has integrationsDiscovery sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('integrationsDiscovery.verifyTrello'); + expect(procedures).toContain('integrationsDiscovery.verifyJira'); + expect(procedures).toContain('integrationsDiscovery.trelloBoards'); + expect(procedures).toContain('integrationsDiscovery.trelloBoardDetails'); + expect(procedures).toContain('integrationsDiscovery.jiraProjects'); + expect(procedures).toContain('integrationsDiscovery.jiraProjectDetails'); + }); }); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts new file mode 100644 index 00000000..5827aef0 --- /dev/null +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -0,0 +1,491 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockDecryptCredential = vi.fn((value: string) => value); + +vi.mock('../../../../src/db/crypto.js', () => ({ + decryptCredential: (...args: unknown[]) => mockDecryptCredential(...args), +})); + +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + credentials: { id: 'id', orgId: 'org_id', value: 'value' }, +})); + +const mockTrelloGetMe = vi.fn(); +const mockTrelloGetBoards = vi.fn(); +const mockTrelloGetBoardLists = vi.fn(); +const mockTrelloGetBoardLabels = vi.fn(); +const mockTrelloGetBoardCustomFields = vi.fn(); + +vi.mock('../../../../src/trello/client.js', () => ({ + withTrelloCredentials: (...args: unknown[]) => { + const cb = args[1] as () => unknown; + return cb(); + }, + trelloClient: { + getMe: (...args: unknown[]) => mockTrelloGetMe(...args), + getBoards: (...args: unknown[]) => mockTrelloGetBoards(...args), + getBoardLists: (...args: unknown[]) => mockTrelloGetBoardLists(...args), + getBoardLabels: (...args: unknown[]) => mockTrelloGetBoardLabels(...args), + getBoardCustomFields: (...args: unknown[]) => mockTrelloGetBoardCustomFields(...args), + }, +})); + +const mockJiraGetMyself = vi.fn(); +const mockJiraSearchProjects = vi.fn(); +const mockJiraGetProjectStatuses = vi.fn(); +const mockJiraGetIssueTypesForProject = vi.fn(); +const mockJiraGetFields = vi.fn(); + +vi.mock('../../../../src/jira/client.js', () => ({ + withJiraCredentials: (...args: unknown[]) => { + const cb = args[1] as () => unknown; + return cb(); + }, + jiraClient: { + getMyself: (...args: unknown[]) => mockJiraGetMyself(...args), + searchProjects: (...args: unknown[]) => mockJiraSearchProjects(...args), + getProjectStatuses: (...args: unknown[]) => mockJiraGetProjectStatuses(...args), + getIssueTypesForProject: (...args: unknown[]) => mockJiraGetIssueTypesForProject(...args), + getFields: (...args: unknown[]) => mockJiraGetFields(...args), + }, +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { integrationsDiscoveryRouter } from '../../../../src/api/routers/integrationsDiscovery.js'; + +function createCaller(ctx: TRPCContext) { + return integrationsDiscoveryRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +const trelloCredsInput = { apiKeyCredentialId: 1, tokenCredentialId: 2 }; +const jiraCredsInput = { + emailCredentialId: 3, + apiTokenCredentialId: 4, + baseUrl: 'https://myorg.atlassian.net', +}; + +/** + * Helper: set up the DB mock chain so that resolveCredentialValue succeeds. + * Each call to getDb().select().from().where() resolves with the given rows. + * Because procedures resolve two credentials via Promise.all, we queue multiple + * return values on mockDbWhere. + */ +function setupDbCredentials(rows: Array<{ orgId: string; value: string }>) { + for (const row of rows) { + mockDbWhere.mockResolvedValueOnce([row]); + } +} + +describe('integrationsDiscoveryRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + }); + + // ── Auth ───────────────────────────────────────────────────────────── + + describe('auth', () => { + it('verifyTrello throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('verifyJira throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.verifyJira(jiraCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('trelloBoards throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.trelloBoards(trelloCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('trelloBoardDetails throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc123' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('jiraProjects throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.jiraProjects(jiraCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('jiraProjectDetails throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + // ── Credential resolution ──────────────────────────────────────────── + + describe('credential resolution', () => { + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValueOnce([]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when credential belongs to different org', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'different-org', value: 'some-key' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('calls decryptCredential with value and orgId', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'enc:v1:api-key' }, + { orgId: 'org-1', value: 'enc:v1:token' }, + ]); + mockDecryptCredential.mockReturnValueOnce('decrypted-api-key'); + mockDecryptCredential.mockReturnValueOnce('decrypted-token'); + mockTrelloGetMe.mockResolvedValue({ id: '1', fullName: 'Me', username: 'me' }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifyTrello(trelloCredsInput); + + expect(mockDecryptCredential).toHaveBeenCalledWith('enc:v1:api-key', 'org-1'); + expect(mockDecryptCredential).toHaveBeenCalledWith('enc:v1:token', 'org-1'); + }); + }); + + // ── verifyTrello ───────────────────────────────────────────────────── + + describe('verifyTrello', () => { + it('returns username, fullName, and id on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetMe.mockResolvedValue({ + id: 'trello-123', + fullName: 'Trello User', + username: 'trellouser', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyTrello(trelloCredsInput); + + expect(result).toEqual({ + id: 'trello-123', + fullName: 'Trello User', + username: 'trellouser', + }); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'bad-key' }, + { orgId: 'org-1', value: 'bad-token' }, + ]); + mockTrelloGetMe.mockRejectedValue(new Error('Invalid API key')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── verifyJira ─────────────────────────────────────────────────────── + + describe('verifyJira', () => { + it('returns displayName, emailAddress, and accountId on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email@example.com' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraGetMyself.mockResolvedValue({ + displayName: 'Jira User', + emailAddress: 'jira@example.com', + accountId: 'acct-456', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyJira(jiraCredsInput); + + expect(result).toEqual({ + displayName: 'Jira User', + emailAddress: 'jira@example.com', + accountId: 'acct-456', + }); + }); + + it('returns empty strings when JIRA response fields are missing', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockJiraGetMyself.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyJira(jiraCredsInput); + + expect(result).toEqual({ + displayName: '', + emailAddress: '', + accountId: '', + }); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'bad-token' }, + ]); + mockJiraGetMyself.mockRejectedValue(new Error('Unauthorized')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyJira(jiraCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── trelloBoards ───────────────────────────────────────────────────── + + describe('trelloBoards', () => { + it('returns boards list on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + const boards = [ + { id: 'board-1', name: 'Board One', url: 'https://trello.com/b/1' }, + { id: 'board-2', name: 'Board Two', url: 'https://trello.com/b/2' }, + ]; + mockTrelloGetBoards.mockResolvedValue(boards); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.trelloBoards(trelloCredsInput); + + expect(result).toEqual(boards); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetBoards.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.trelloBoards(trelloCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── trelloBoardDetails ─────────────────────────────────────────────── + + describe('trelloBoardDetails', () => { + it('returns lists, labels, and customFields on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + const lists = [{ id: 'list-1', name: 'Backlog' }]; + const labels = [{ id: 'label-1', name: 'Bug', color: 'red' }]; + const customFields = [{ id: 'cf-1', name: 'Priority', type: 'list' }]; + mockTrelloGetBoardLists.mockResolvedValue(lists); + mockTrelloGetBoardLabels.mockResolvedValue(labels); + mockTrelloGetBoardCustomFields.mockResolvedValue(customFields); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.trelloBoardDetails({ + ...trelloCredsInput, + boardId: 'abc123', + }); + + expect(result).toEqual({ lists, labels, customFields }); + expect(mockTrelloGetBoardLists).toHaveBeenCalledWith('abc123'); + expect(mockTrelloGetBoardLabels).toHaveBeenCalledWith('abc123'); + expect(mockTrelloGetBoardCustomFields).toHaveBeenCalledWith('abc123'); + }); + + it('rejects boardId with hyphens', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc-def' }), + ).rejects.toThrow(); + }); + + it('rejects boardId longer than 32 characters', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ + ...trelloCredsInput, + boardId: 'a'.repeat(33), + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetBoardLists.mockRejectedValue(new Error('Board not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc123' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + + // ── jiraProjects ───────────────────────────────────────────────────── + + describe('jiraProjects', () => { + it('returns project list on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + const projects = [ + { key: 'PROJ', name: 'Project One' }, + { key: 'TEST', name: 'Test Project' }, + ]; + mockJiraSearchProjects.mockResolvedValue(projects); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.jiraProjects(jiraCredsInput); + + expect(result).toEqual(projects); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraSearchProjects.mockRejectedValue(new Error('Connection refused')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.jiraProjects(jiraCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── jiraProjectDetails ─────────────────────────────────────────────── + + describe('jiraProjectDetails', () => { + it('returns statuses, issueTypes, and only custom fields', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + const statuses = [ + { name: 'To Do', id: 'status-1' }, + { name: 'Done', id: 'status-2' }, + ]; + const issueTypes = [ + { name: 'Story', subtask: false }, + { name: 'Bug', subtask: false }, + ]; + const fields = [ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + { id: 'description', name: 'Description', custom: false }, + { id: 'customfield_10002', name: 'Sprint', custom: true }, + ]; + mockJiraGetProjectStatuses.mockResolvedValue(statuses); + mockJiraGetIssueTypesForProject.mockResolvedValue(issueTypes); + mockJiraGetFields.mockResolvedValue(fields); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.jiraProjectDetails({ + ...jiraCredsInput, + projectKey: 'PROJ', + }); + + expect(result.statuses).toEqual(statuses); + expect(result.issueTypes).toEqual(issueTypes); + expect(result.fields).toEqual([ + { id: 'customfield_10001', name: 'Story Points', custom: true }, + { id: 'customfield_10002', name: 'Sprint', custom: true }, + ]); + expect(mockJiraGetProjectStatuses).toHaveBeenCalledWith('PROJ'); + expect(mockJiraGetIssueTypesForProject).toHaveBeenCalledWith('PROJ'); + }); + + it('rejects lowercase projectKey', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'proj' }), + ).rejects.toThrow(); + }); + + it('rejects projectKey starting with number', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: '1TEST' }), + ).rejects.toThrow(); + }); + + it('rejects projectKey longer than 10 characters', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ + ...jiraCredsInput, + projectKey: 'ABCDEFGHIJK', + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraGetProjectStatuses.mockRejectedValue(new Error('Project not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); +}); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 0ffbc95f..96ed1170 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -604,5 +604,193 @@ describe('webhooksRouter', () => { expect(result.trello).toEqual(['tw-1', 'tw-2']); }); + + it('uses oneTimeTokens to override credentials', async () => { + setupProjectContext({ noGithub: true }); + + // The DB has no GitHub token, but we provide one via oneTimeTokens + mockListWebhooks.mockResolvedValue({ data: [] }); + + // Trello list call (project has Trello creds from DB) + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + + // Provide a one-time GitHub token; delete should use it and call GitHub + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + oneTimeTokens: { github: 'ghp_one_time_admin' }, + }); + + // GitHub was called because oneTimeTokens overrode the missing credential + expect(mockListWebhooks).toHaveBeenCalled(); + expect(result.github).toEqual([]); + }); + }); + + describe('per-provider errors', () => { + it('list returns errors object with null values when all providers succeed', async () => { + setupProjectContext(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.errors).toEqual({ + trello: null, + github: null, + jira: null, + }); + }); + + it('list captures github error while trello still succeeds', async () => { + setupProjectContext(); + + // Trello succeeds + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-1', + idModel: 'board-123', + callbackURL: 'http://x', + active: true, + }, + ]), + }); + + // GitHub fails with 404 + mockListWebhooks.mockRejectedValue(new Error('Not Found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.trello).toHaveLength(1); + expect(result.github).toEqual([]); + expect(result.errors.trello).toBeNull(); + expect(result.errors.github).toContain('Not Found'); + expect(result.errors.jira).toBeNull(); + }); + + it('list captures trello error while github still succeeds', async () => { + setupProjectContext(); + + // Trello fails + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + + // GitHub succeeds + mockListWebhooks.mockResolvedValue({ + data: [{ id: 1, name: 'web', active: true, events: [], config: { url: 'http://y' } }], + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.trello).toEqual([]); + expect(result.github).toHaveLength(1); + expect(result.errors.trello).toBeTruthy(); + expect(result.errors.github).toBeNull(); + }); + }); + + describe('oneTimeTokens', () => { + it('list uses oneTimeTokens to override github credential', async () => { + setupProjectContext({ noGithub: true }); + + // Trello succeeds with DB creds + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + + // GitHub should now be called because we provide oneTimeTokens + const ghWebhooks = [ + { id: 5, name: 'web', active: true, events: ['push'], config: { url: 'http://z' } }, + ]; + mockListWebhooks.mockResolvedValue({ data: ghWebhooks }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'my-project', + oneTimeTokens: { github: 'ghp_admin_token' }, + }); + + // GitHub was called because oneTimeTokens overrode the missing DB credential + expect(mockListWebhooks).toHaveBeenCalled(); + expect(result.github).toHaveLength(1); + expect(result.github[0].id).toBe(5); + }); + + it('create uses oneTimeTokens for github', async () => { + setupProjectContext({ noGithub: true }); + + // Trello: skip (no Trello flag), but we have creds from DB so it'll try + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'tw-new', + callbackURL: 'http://example.com/trello/webhook', + idModel: 'board-123', + active: true, + }), + }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 77, + config: { url: 'http://example.com/github/webhook' }, + events: ['push'], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + oneTimeTokens: { github: 'ghp_one_time' }, + }); + + expect(mockCreateWebhook).toHaveBeenCalled(); + expect(result.github).toMatchObject({ id: 77 }); + }); + + it('list passes oneTimeTokens without affecting DB credentials', async () => { + setupProjectContext(); + + // Both Trello and GitHub succeed with DB creds + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'my-project', + oneTimeTokens: { github: 'ghp_override' }, + }); + + expect(result.errors.trello).toBeNull(); + expect(result.errors.github).toBeNull(); + expect(result.errors.jira).toBeNull(); + }); }); }); diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index a8dfa645..5536cdfa 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -65,6 +65,11 @@ vi.mock('../../../src/backends/agent-profiles.js', () => ({ getAgentProfile: vi.fn(), })); +const mockCaptureException = vi.fn(); +vi.mock('../../../src/sentry.js', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + vi.mock('../../../src/agents/prompts/index.js', () => ({})); vi.mock('../../../src/agents/shared/promptContext.js', () => ({ @@ -277,6 +282,27 @@ describe('executeWithBackend', () => { expect(result.error).toContain('Backend crashed'); }); + it('reports backend errors to Sentry via captureException', async () => { + setupMocks(); + const backend = makeMockBackend(); + const error = new Error('HttpError: Not Found'); + vi.mocked(backend.execute).mockRejectedValue(error); + const input = makeInput(); + + await executeWithBackend(backend, 'review', input); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { + source: 'agent_execution', + agent: expect.stringContaining('review'), + }, + extra: { + runId: 'run-uuid-123', + durationMs: expect.any(Number), + }, + }); + }); + it('includes log buffer in result', async () => { const loggerInstance = setupMocks(); const backend = makeMockBackend(); diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 7168f16f..7cb97e9d 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -35,6 +35,7 @@ vi.mock('../../../src/config/statusUpdateConfig.js', () => ({ vi.mock('../../../src/backends/progressState.js', () => ({ writeProgressCommentId: vi.fn(), clearProgressCommentId: vi.fn(), + readProgressCommentId: vi.fn(), })); import { syncCompletedTodosToChecklist } from '../../../src/agents/utils/checklistSync.js'; @@ -43,6 +44,7 @@ import { callProgressModel } from '../../../src/backends/progressModel.js'; import { ProgressMonitor } from '../../../src/backends/progressMonitor.js'; import { clearProgressCommentId, + readProgressCommentId, writeProgressCommentId, } from '../../../src/backends/progressState.js'; import { @@ -59,6 +61,7 @@ import { getPMProviderOrNull } from '../../../src/pm/index.js'; const mockGetPMProvider = vi.mocked(getPMProviderOrNull); const mockWriteProgressCommentId = vi.mocked(writeProgressCommentId); const mockClearProgressCommentId = vi.mocked(clearProgressCommentId); +const mockReadProgressCommentId = vi.mocked(readProgressCommentId); const mockPMProvider = { addComment: vi.fn(), updateComment: vi.fn() }; const mockGithub = vi.mocked(githubClient); const mockGetStatusConfig = vi.mocked(getStatusUpdateConfig); @@ -74,6 +77,8 @@ beforeEach(() => { vi.useFakeTimers(); mockLoadTodos.mockReturnValue([]); mockGetPMProvider.mockReturnValue(null); + // Default: state file exists (not cleared by agent subprocess) + mockReadProgressCommentId.mockReturnValue({ workItemId: 'card1', commentId: 'comment-id-1' }); }); afterEach(() => { @@ -1125,6 +1130,46 @@ describe('ProgressMonitor — state file integration', () => { ); }); + it('skips progress update when state file is cleared by agent subprocess', async () => { + const logWriter = vi.fn(); + const monitor = new ProgressMonitor({ + agentType: 'respond-to-planning-comment', + taskDescription: 'Test task', + intervalMinutes: 5, + progressModel: 'test-model', + customModels: [], + logWriter, + repoDir: '/tmp/test-repo', + trello: { cardId: 'card1' }, + }); + + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockCallProgressModel.mockResolvedValue('Progress update'); + mockPMProvider.addComment.mockResolvedValue('comment-id-initial'); + mockPMProvider.updateComment.mockResolvedValue(undefined); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + // Simulate the PostComment gadget clearing the state file + mockReadProgressCommentId.mockReturnValue(null); + + // First tick fires at 1 minute — should detect cleared state file and skip + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + monitor.stop(); + + // updateComment should NOT have been called (state file was cleared) + expect(mockPMProvider.updateComment).not.toHaveBeenCalled(); + // Should log the skip + expect(logWriter).toHaveBeenCalledWith( + 'DEBUG', + 'State file cleared by agent — skipping progress update', + expect.objectContaining({ commentId: 'comment-id-initial' }), + ); + // progressCommentId should be cleared + expect(monitor.getProgressCommentId()).toBeNull(); + }); + it('updates state file when new comment is created after update failure', async () => { const logWriter = vi.fn(); const monitor = new ProgressMonitor({ diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts new file mode 100644 index 00000000..43e1f2ea --- /dev/null +++ b/tests/unit/backends/progressModel.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('llmist', () => { + const mockRun = vi.fn(); + const mockAsk = vi.fn().mockReturnValue({ run: mockRun }); + const MockAgentBuilder = vi.fn().mockImplementation(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: mockAsk, + })); + + return { + AgentBuilder: MockAgentBuilder, + LLMist: vi.fn().mockImplementation(() => ({})), + }; +}); + +import { AgentBuilder } from 'llmist'; +import { type ProgressContext, callProgressModel } from '../../../src/backends/progressModel.js'; + +const MockAgentBuilder = vi.mocked(AgentBuilder); + +function makeContext(overrides: Partial = {}): ProgressContext { + return { + agentType: 'implementation', + taskDescription: 'Implement the feature', + elapsedMinutes: 5, + iteration: 3, + maxIterations: 20, + todos: [], + recentToolCalls: [], + ...overrides, + }; +} + +function getMockRun(): ReturnType { + const instance = MockAgentBuilder.mock.results[MockAgentBuilder.mock.results.length - 1]?.value; + return instance?.ask.mock.results[0]?.value?.run; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('callProgressModel', () => { + it('returns text output from LLM on success', async () => { + async function* fakeRun() { + yield { + type: 'text', + content: '**🚀 Implementation Update** (5 min)\n\nWorking on the feature.', + }; + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + const result = await callProgressModel('test-model', makeContext(), []); + expect(result).toBe('**🚀 Implementation Update** (5 min)\n\nWorking on the feature.'); + }); + + it('concatenates multiple text events', async () => { + async function* fakeRun() { + yield { type: 'text', content: 'Part 1. ' }; + yield { type: 'text', content: 'Part 2.' }; + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + const result = await callProgressModel('test-model', makeContext(), []); + expect(result).toBe('Part 1. \nPart 2.'); + }); + + it('ignores non-text events', async () => { + async function* fakeRun() { + yield { type: 'tool_call', content: 'some tool' }; + yield { type: 'text', content: 'Valid text output.' }; + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + const result = await callProgressModel('test-model', makeContext(), []); + expect(result).toBe('Valid text output.'); + }); + + it('throws when LLM returns empty output', async () => { + async function* fakeRun() { + yield { type: 'text', content: '' }; + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( + 'Progress model returned empty output', + ); + }); + + it('throws when LLM returns no events', async () => { + async function* fakeRun() { + // yields nothing + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( + 'Progress model returned empty output', + ); + }); + + it('throws when LLM call times out (races against a slow call)', async () => { + // We can't easily test the real 10s timeout without fake timers. + // Instead, verify the timeout mechanism works by inspecting that + // callProgressModel uses Promise.race — the implementation is verified + // structurally by the fact that it wraps callProgressModelOnce in a race. + // This test verifies that the error thrown matches the expected message. + // + // We verify the timeout throws by mocking LLMist so the async generator + // never completes, using a spy to observe the race setup. + // A simpler approach: wrap in a real short timeout and ensure fast rejection. + + // Use a promise that rejects with the exact timeout error to simulate + // the timeout branch winning the race. + let rejectFn!: (err: Error) => void; + const hangPromise = new Promise((_res, rej) => { + rejectFn = rej; + }); + + async function* fakeRun() { + await hangPromise; + yield { type: 'text', content: 'never reached' }; + } + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + const callPromise = callProgressModel('test-model', makeContext(), []); + + // Trigger the hang to fail fast with a timeout-like error + rejectFn(new Error('Progress model call timed out')); + + await expect(callPromise).rejects.toThrow('Progress model call timed out'); + }); + + it('rejects before timeout when LLM throws', async () => { + const fakeRun = () => ({ + [Symbol.asyncIterator]() { + return { + next: async () => { + throw new Error('LLM network error'); + }, + return: async () => ({ value: undefined, done: true as const }), + }; + }, + }); + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( + 'LLM network error', + ); + }); + + it('does not call withGadgets() — stripped from builder chain', async () => { + async function* fakeRun() { + yield { type: 'text', content: 'Output.' }; + } + + const withGadgets = vi.fn().mockReturnThis(); + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + withGadgets, + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + await callProgressModel('test-model', makeContext(), []); + expect(withGadgets).not.toHaveBeenCalled(); + }); + + it('uses maxIterations(1) for single-shot call', async () => { + async function* fakeRun() { + yield { type: 'text', content: 'Output.' }; + } + + const withMaxIterations = vi.fn().mockReturnThis(); + + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations, + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + + await callProgressModel('test-model', makeContext(), []); + expect(withMaxIterations).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/unit/cli/dashboard/base.test.ts b/tests/unit/cli/dashboard/base.test.ts index 6291db81..0cd0fff1 100644 --- a/tests/unit/cli/dashboard/base.test.ts +++ b/tests/unit/cli/dashboard/base.test.ts @@ -22,7 +22,7 @@ vi.mock('chalk', () => ({ }, })); -import { DashboardCommand } from '../../../../src/cli/dashboard/_shared/base.js'; +import { DashboardCommand, extractBaseFlags } from '../../../../src/cli/dashboard/_shared/base.js'; // Concrete subclass for testing class TestCommand extends DashboardCommand { @@ -46,6 +46,53 @@ class TestErrorCommand extends DashboardCommand { } } +describe('extractBaseFlags', () => { + it('returns undefined when no overrides present', () => { + expect(extractBaseFlags([])).toBeUndefined(); + expect(extractBaseFlags(['--json', 'list'])).toBeUndefined(); + }); + + it('extracts --org value', () => { + expect(extractBaseFlags(['--org', 'test-org'])).toEqual({ org: 'test-org' }); + }); + + it('extracts --server value', () => { + expect(extractBaseFlags(['--server', 'http://localhost:4000'])).toEqual({ + server: 'http://localhost:4000', + }); + }); + + it('extracts both flags together', () => { + expect(extractBaseFlags(['--org', 'my-org', '--server', 'http://x'])).toEqual({ + org: 'my-org', + server: 'http://x', + }); + }); + + it('handles --org=value equals syntax', () => { + expect(extractBaseFlags(['--org=my-org'])).toEqual({ org: 'my-org' }); + }); + + it('handles --server=value equals syntax', () => { + expect(extractBaseFlags(['--server=http://x'])).toEqual({ server: 'http://x' }); + }); + + it('ignores flag at end without value', () => { + expect(extractBaseFlags(['--org'])).toBeUndefined(); + expect(extractBaseFlags(['--server'])).toBeUndefined(); + }); + + it('stops parsing at --', () => { + expect(extractBaseFlags(['--', '--org', 'test-org'])).toBeUndefined(); + }); + + it('extracts base flags mixed with other flags', () => { + expect(extractBaseFlags(['--json', '--org', 'my-org', '--limit', '20'])).toEqual({ + org: 'my-org', + }); + }); +}); + describe('DashboardCommand', () => { beforeEach(() => { vi.clearAllMocks(); @@ -84,6 +131,50 @@ describe('DashboardCommand', () => { }); }); + describe('--org flag integration', () => { + it('passes orgId override to createDashboardClient', async () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + mockLoadConfig.mockReturnValue(config); + mockCreateDashboardClient.mockReturnValue({}); + + const cmd = new TestCommand(['--org', 'my-org'], {} as never); + await cmd.run(); + + expect(mockCreateDashboardClient).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 'my-org' }), + ); + }); + + it('passes server override to createDashboardClient', async () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + mockLoadConfig.mockReturnValue(config); + mockCreateDashboardClient.mockReturnValue({}); + + const cmd = new TestCommand(['--server', 'http://other:4000'], {} as never); + await cmd.run(); + + expect(mockCreateDashboardClient).toHaveBeenCalledWith( + expect.objectContaining({ serverUrl: 'http://other:4000' }), + ); + }); + + it('passes both --org and --server overrides', async () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + mockLoadConfig.mockReturnValue(config); + mockCreateDashboardClient.mockReturnValue({}); + + const cmd = new TestCommand( + ['--org', 'my-org', '--server', 'http://other:4000'], + {} as never, + ); + await cmd.run(); + + expect(mockCreateDashboardClient).toHaveBeenCalledWith( + expect.objectContaining({ serverUrl: 'http://other:4000', orgId: 'my-org' }), + ); + }); + }); + describe('handleError', () => { it('shows login message for UNAUTHORIZED tRPC errors', async () => { mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); diff --git a/tests/unit/cli/dashboard/client.test.ts b/tests/unit/cli/dashboard/client.test.ts index 0fdc8f47..ff558e8c 100644 --- a/tests/unit/cli/dashboard/client.test.ts +++ b/tests/unit/cli/dashboard/client.test.ts @@ -49,6 +49,21 @@ describe('createDashboardClient', () => { }); }); + it('includes x-org-context header when orgId is set', () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok', orgId: 'my-org' }; + + createDashboardClient(config); + + const linkOpts = vi.mocked(httpBatchLink).mock.calls[0][0] as { + headers: () => Record; + }; + const headers = linkOpts.headers(); + expect(headers).toEqual({ + Cookie: 'cascade_session=tok', + 'x-org-context': 'my-org', + }); + }); + it('returns the created client', () => { const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index db763e9d..758b8e15 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -18,6 +18,7 @@ const { mockIssueRemoteLinks, mockMyself, mockProjects, + mockIssueFields, } = vi.hoisted(() => ({ mockIssues: { getIssue: vi.fn(), @@ -46,6 +47,11 @@ const { }, mockProjects: { getProject: vi.fn(), + searchProjects: vi.fn(), + getAllStatuses: vi.fn(), + }, + mockIssueFields: { + getFields: vi.fn(), }, })); @@ -58,6 +64,7 @@ vi.mock('jira.js', () => ({ issueRemoteLinks: mockIssueRemoteLinks, myself: mockMyself, projects: mockProjects, + issueFields: mockIssueFields, })), })); @@ -92,6 +99,9 @@ describe('jiraClient', () => { mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockReset(); mockMyself.getCurrentUser.mockReset(); mockProjects.getProject.mockReset(); + mockProjects.searchProjects.mockReset(); + mockProjects.getAllStatuses.mockReset(); + mockIssueFields.getFields.mockReset(); _resetCloudIdCache(); }); @@ -657,6 +667,129 @@ describe('jiraClient', () => { }); }); + describe('searchProjects', () => { + it('returns project keys and names', async () => { + mockProjects.searchProjects.mockResolvedValue({ + values: [ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ], + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ]); + expect(mockProjects.searchProjects).toHaveBeenCalledWith({ maxResults: 100 }); + }); + + it('handles missing fields gracefully', async () => { + mockProjects.searchProjects.mockResolvedValue({ + values: [{}, { key: 'X' }], + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: '', name: '' }, + { key: 'X', name: '' }, + ]); + }); + + it('returns empty array when values is missing', async () => { + mockProjects.searchProjects.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([]); + }); + }); + + describe('getProjectStatuses', () => { + it('flattens and deduplicates statuses across issue types', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + { + statuses: [ + { name: 'To Do', id: '1' }, + { name: 'In Progress', id: '2' }, + ], + }, + { + statuses: [ + { name: 'In Progress', id: '2' }, + { name: 'Done', id: '3' }, + ], + }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([ + { name: 'To Do', id: '1' }, + { name: 'In Progress', id: '2' }, + { name: 'Done', id: '3' }, + ]); + expect(mockProjects.getAllStatuses).toHaveBeenCalledWith({ + projectIdOrKey: 'PROJ', + }); + }); + + it('skips statuses with empty names', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + { + statuses: [ + { name: '', id: '0' }, + { name: 'Open', id: '1' }, + ], + }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([{ name: 'Open', id: '1' }]); + }); + + it('handles missing statuses array in issue type', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + {}, + { statuses: [{ name: 'Open', id: '1' }] }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([{ name: 'Open', id: '1' }]); + }); + }); + + describe('getFields', () => { + it('returns all fields with custom flag', async () => { + mockIssueFields.getFields.mockResolvedValue([ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getFields()); + + expect(result).toEqual([ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + ]); + }); + + it('handles missing fields gracefully', async () => { + mockIssueFields.getFields.mockResolvedValue([{}, { id: 'x' }]); + + const result = await withJiraCredentials(creds, () => jiraClient.getFields()); + + expect(result).toEqual([ + { id: '', name: '', custom: false }, + { id: 'x', name: '', custom: false }, + ]); + }); + }); + describe('getJiraCredentials', () => { it('throws when called outside scope', () => { expect(() => getJiraCredentials()).toThrow('No JIRA credentials in scope'); diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts new file mode 100644 index 00000000..26a67ae1 --- /dev/null +++ b/tests/unit/pm/webhook-handler.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn().mockImplementation((_token, fn) => fn()), +})); + +vi.mock('../../../src/github/personas.js', () => ({ + getPersonaToken: vi.fn().mockResolvedValue('gh-token-xxx'), +})); + +vi.mock('../../../src/triggers/shared/agent-execution.js', () => ({ + runAgentExecutionPipeline: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../src/triggers/shared/webhook-queue.js', () => ({ + processNextQueuedWebhook: vi.fn(), +})); + +vi.mock('../../../src/utils/index.js', () => ({ + clearCardActive: vi.fn(), + enqueueWebhook: vi.fn().mockReturnValue(true), + getQueueLength: vi.fn().mockReturnValue(0), + isCardActive: vi.fn().mockReturnValue(false), + isCurrentlyProcessing: vi.fn().mockReturnValue(false), + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + setCardActive: vi.fn(), + setProcessing: vi.fn(), + startWatchdog: vi.fn(), +})); + +vi.mock('../../../src/utils/llmEnv.js', () => ({ + injectLlmApiKeys: vi.fn().mockResolvedValue(vi.fn()), +})); + +vi.mock('../../../src/pm/context.js', () => ({ + getPMProvider: vi.fn().mockReturnValue({}), + withPMProvider: vi.fn().mockImplementation((_provider, fn) => fn()), +})); + +vi.mock('../../../src/pm/lifecycle.js', () => ({ + PMLifecycleManager: vi.fn().mockImplementation(() => ({ + handleError: vi.fn().mockResolvedValue(undefined), + })), + resolveProjectPMConfig: vi.fn().mockReturnValue({ type: 'trello' }), +})); + +vi.mock('../../../src/pm/registry.js', () => ({ + pmRegistry: { + createProvider: vi.fn().mockReturnValue({}), + }, +})); + +import { processPMWebhook } from '../../../src/pm/webhook-handler.js'; +import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; +import { + clearCardActive, + enqueueWebhook, + isCardActive, + isCurrentlyProcessing, + setCardActive, + setProcessing, + startWatchdog, +} from '../../../src/utils/index.js'; + +const mockIsCurrentlyProcessing = vi.mocked(isCurrentlyProcessing); +const mockIsCardActive = vi.mocked(isCardActive); +const mockEnqueueWebhook = vi.mocked(enqueueWebhook); +const mockSetProcessing = vi.mocked(setProcessing); +const mockStartWatchdog = vi.mocked(startWatchdog); +const mockSetCardActive = vi.mocked(setCardActive); +const mockClearCardActive = vi.mocked(clearCardActive); +const mockRunAgentExecutionPipeline = vi.mocked(runAgentExecutionPipeline); + +// ============================================================================ +// PMIntegration factory +// ============================================================================ + +function createMockIntegration( + overrides?: Partial<{ + parseWebhookPayload: () => object | null; + lookupProject: () => object | null; + withCredentials: (projectId: string, fn: () => Promise) => Promise; + deleteAckComment: () => Promise; + type: string; + }>, +) { + const mockEvent = { + projectIdentifier: 'BOARD_123', + workItemId: 'card-abc', + eventType: 'card_moved', + }; + const mockProject = { + id: 'project-1', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + }; + const mockConfig = { + defaults: { watchdogTimeoutMs: 120000 }, + }; + + return { + type: 'trello', + parseWebhookPayload: vi.fn().mockReturnValue(mockEvent), + lookupProject: vi.fn().mockResolvedValue({ project: mockProject, config: mockConfig }), + withCredentials: vi + .fn() + .mockImplementation((_projectId: string, fn: () => Promise) => fn()), + deleteAckComment: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function createMockRegistry(result?: object | null) { + return { + dispatch: vi.fn().mockResolvedValue( + result === undefined + ? { + agentType: 'implementation', + workItemId: 'card-abc', + agentInput: { cardId: 'card-abc' }, + } + : result, + ), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockIsCurrentlyProcessing.mockReturnValue(false); + mockIsCardActive.mockReturnValue(false); + mockEnqueueWebhook.mockReturnValue(true); + mockRunAgentExecutionPipeline.mockResolvedValue(undefined); +}); + +// ============================================================================ +// processPMWebhook +// ============================================================================ + +describe('processPMWebhook', () => { + it('returns early when payload is invalid', async () => { + const integration = createMockIntegration({ + parseWebhookPayload: vi.fn().mockReturnValue(null), + }); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { invalid: true }, registry as never); + + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('enqueues webhook when currently processing', async () => { + mockIsCurrentlyProcessing.mockReturnValue(true); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockEnqueueWebhook).toHaveBeenCalled(); + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('returns early when no project found for identifier', async () => { + const integration = createMockIntegration({ + lookupProject: vi.fn().mockResolvedValue(null), + }); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(registry.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches to trigger registry when project found', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(registry.dispatch).toHaveBeenCalled(); + }); + + it('runs agent when trigger matches', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('sets card active and clears it after execution', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetCardActive).toHaveBeenCalledWith('card-abc'); + expect(mockClearCardActive).toHaveBeenCalledWith('card-abc'); + }); + + it('starts watchdog on trigger match', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockStartWatchdog).toHaveBeenCalledWith(120000); + }); + + it('sets processing to true on start and false when done', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetProcessing).toHaveBeenCalledWith(true); + expect(mockSetProcessing).toHaveBeenCalledWith(false); + }); + + it('skips agent execution when work item is already active', async () => { + mockIsCardActive.mockReturnValue(true); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockRunAgentExecutionPipeline).not.toHaveBeenCalled(); + }); + + it('still clears processing flag when agent throws', async () => { + mockRunAgentExecutionPipeline.mockRejectedValue(new Error('Agent failed')); + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetProcessing).toHaveBeenCalledWith(false); + }); + + it('uses pre-resolved trigger result when provided', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(null); // registry would return null + const preResolvedResult = { + agentType: 'briefing', + workItemId: 'card-pre', + agentInput: { cardId: 'card-pre' }, + }; + + await processPMWebhook( + integration as never, + { type: 'card_moved' }, + registry as never, + undefined, + preResolvedResult, + ); + + // Should use the pre-resolved result, not dispatch to registry + expect(registry.dispatch).not.toHaveBeenCalled(); + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('passes ackCommentId into agentInput when provided', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook( + integration as never, + { type: 'card_moved' }, + registry as never, + 'ack-comment-123', + ); + + // Verify ackCommentId was injected — the agent pipeline was called + expect(mockRunAgentExecutionPipeline).toHaveBeenCalled(); + }); + + it('does not set card active when workItemId is undefined', async () => { + const integration = createMockIntegration(); + const registry = { + dispatch: vi.fn().mockResolvedValue({ + agentType: 'implementation', + workItemId: undefined, // no workItemId + agentInput: {}, + }), + }; + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockSetCardActive).not.toHaveBeenCalled(); + }); + + it('calls withCredentials on integration during execution', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(integration.withCredentials).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/router/bullmq-workers.test.ts b/tests/unit/router/bullmq-workers.test.ts new file mode 100644 index 00000000..26d07f82 --- /dev/null +++ b/tests/unit/router/bullmq-workers.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks — factories use vi.fn() directly (no external variable refs) +// --------------------------------------------------------------------------- + +vi.mock('bullmq', () => ({ + Worker: vi.fn().mockImplementation((_queueName, _processFn, _opts) => ({ + on: vi.fn(), + })), +})); + +vi.mock('../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { Worker } from 'bullmq'; +import { createQueueWorker, parseRedisUrl } from '../../../src/router/bullmq-workers.js'; +import { captureException } from '../../../src/sentry.js'; + +const MockWorker = vi.mocked(Worker); +const mockCaptureException = vi.mocked(captureException); + +beforeEach(() => { + MockWorker.mockClear(); + mockCaptureException.mockClear(); + // Re-establish default mock so each test gets a fresh mock worker + MockWorker.mockImplementation( + (_queueName, _processFn, _opts) => + ({ + on: vi.fn(), + }) as never, + ); +}); + +// --------------------------------------------------------------------------- +// parseRedisUrl (re-exported from utils/redis.ts) +// --------------------------------------------------------------------------- + +describe('parseRedisUrl', () => { + it('parses a simple redis URL', () => { + const conn = parseRedisUrl('redis://localhost:6379'); + expect(conn).toEqual({ host: 'localhost', port: 6379, password: undefined }); + }); + + it('defaults to port 6379 when no port specified', () => { + const conn = parseRedisUrl('redis://localhost'); + expect(conn).toEqual({ host: 'localhost', port: 6379, password: undefined }); + }); + + it('extracts password from URL', () => { + const conn = parseRedisUrl('redis://:secret@localhost:6379'); + expect(conn.password).toBe('secret'); + expect(conn.host).toBe('localhost'); + expect(conn.port).toBe(6379); + }); +}); + +// --------------------------------------------------------------------------- +// createQueueWorker +// --------------------------------------------------------------------------- + +describe('createQueueWorker', () => { + const processFn = vi.fn().mockResolvedValue(undefined); + const baseConfig = { + queueName: 'test-queue', + label: 'Test job', + connection: { host: 'localhost', port: 6379 }, + concurrency: 3, + lockDuration: 60000, + processFn, + }; + + it('creates a Worker with the supplied config', () => { + createQueueWorker(baseConfig); + + expect(MockWorker).toHaveBeenCalledWith( + 'test-queue', + processFn, + expect.objectContaining({ + connection: { host: 'localhost', port: 6379 }, + concurrency: 3, + lockDuration: 60000, + }), + ); + }); + + it('registers completed, failed, and error event handlers', () => { + const worker = createQueueWorker(baseConfig); + const mockOn = vi.mocked(worker.on); + + const registeredEvents = mockOn.mock.calls.map((call) => call[0]); + expect(registeredEvents).toContain('completed'); + expect(registeredEvents).toContain('failed'); + expect(registeredEvents).toContain('error'); + }); + + it('returns the created Worker instance', () => { + const worker = createQueueWorker(baseConfig); + expect(worker).toBeDefined(); + expect(typeof worker.on).toBe('function'); + }); + + it('completed handler logs with label', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const worker = createQueueWorker(baseConfig); + const mockOn = vi.mocked(worker.on); + + // Find and invoke the completed handler + const completedCall = mockOn.mock.calls.find((call) => call[0] === 'completed'); + expect(completedCall).toBeDefined(); + const completedHandler = completedCall?.[1] as (job: { id: string }) => void; + completedHandler({ id: 'job-42' }); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Test job'), + expect.objectContaining({ jobId: 'job-42' }), + ); + logSpy.mockRestore(); + }); + + it('failed handler logs error and calls captureException', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const worker = createQueueWorker(baseConfig); + const mockOn = vi.mocked(worker.on); + + const failedCall = mockOn.mock.calls.find((call) => call[0] === 'failed'); + expect(failedCall).toBeDefined(); + const failedHandler = failedCall?.[1] as (job: { id: string } | undefined, err: Error) => void; + const err = new Error('dispatch failed'); + failedHandler({ id: 'job-7' }, err); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Test job'), + expect.objectContaining({ jobId: 'job-7' }), + ); + expect(mockCaptureException).toHaveBeenCalledWith( + err, + expect.objectContaining({ + tags: expect.objectContaining({ queue: 'test-queue' }), + }), + ); + errorSpy.mockRestore(); + }); + + it('error handler logs and calls captureException', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const worker = createQueueWorker(baseConfig); + const mockOn = vi.mocked(worker.on); + + const errorCall = mockOn.mock.calls.find((call) => call[0] === 'error'); + expect(errorCall).toBeDefined(); + const errorHandler = errorCall?.[1] as (err: Error) => void; + const err = new Error('worker crashed'); + errorHandler(err); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Test job'), err); + expect(mockCaptureException).toHaveBeenCalledWith( + err, + expect.objectContaining({ + tags: expect.objectContaining({ source: 'bullmq_error', queue: 'test-queue' }), + }), + ); + errorSpy.mockRestore(); + }); + + it('uses queue name in Sentry tags for failed handler', () => { + const worker = createQueueWorker({ ...baseConfig, queueName: 'my-special-queue' }); + const mockOn = vi.mocked(worker.on); + + const failedCall = mockOn.mock.calls.find((call) => call[0] === 'failed'); + const handler = failedCall?.[1] as (job: { id: string }, err: Error) => void; + handler({ id: 'x' }, new Error('oops')); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ queue: 'my-special-queue' }), + }), + ); + }); +}); diff --git a/tests/unit/router/container-manager.test.ts b/tests/unit/router/container-manager.test.ts new file mode 100644 index 00000000..4a526b81 --- /dev/null +++ b/tests/unit/router/container-manager.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Hoisted mock state — vi.hoisted creates variables before vi.mock factories run +// --------------------------------------------------------------------------- + +const { mockDockerCreateContainer, mockDockerGetContainer } = vi.hoisted(() => ({ + mockDockerCreateContainer: vi.fn(), + mockDockerGetContainer: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Module-level mocks +// --------------------------------------------------------------------------- + +vi.mock('dockerode', () => ({ + default: vi.fn().mockImplementation(() => ({ + createContainer: mockDockerCreateContainer, + getContainer: mockDockerGetContainer, + })), +})); + +vi.mock('../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + +vi.mock('../../../src/config/provider.js', () => ({ + findProjectByRepo: vi.fn(), + getAllProjectCredentials: vi.fn(), +})); + +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + invalidate: vi.fn(), + }, +})); + +vi.mock('../../../src/router/notifications.js', () => ({ + notifyTimeout: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../src/router/config.js', () => ({ + routerConfig: { + redisUrl: 'redis://localhost:6379', + maxWorkers: 3, + workerImage: 'test-worker:latest', + workerMemoryMb: 512, + workerTimeoutMs: 5000, + dockerNetwork: 'test-network', + }, +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { findProjectByRepo, getAllProjectCredentials } from '../../../src/config/provider.js'; +import { + buildWorkerEnv, + cleanupWorker, + detachAll, + extractProjectIdFromJob, + getActiveWorkerCount, + getActiveWorkers, + killWorker, + spawnWorker, +} from '../../../src/router/container-manager.js'; +import { notifyTimeout } from '../../../src/router/notifications.js'; +import type { CascadeJob } from '../../../src/router/queue.js'; + +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); +const mockGetAllProjectCredentials = vi.mocked(getAllProjectCredentials); +const mockNotifyTimeout = vi.mocked(notifyTimeout); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeJob(overrides: Partial<{ id: string; data: CascadeJob }> = {}) { + return { + id: overrides.id ?? 'job-1', + data: overrides.data ?? ({ type: 'trello', projectId: 'proj-1' } as CascadeJob), + }; +} + +function setupMockContainer(exitCode = 0) { + let resolveWait!: (v: { StatusCode: number }) => void; + const waitPromise = new Promise<{ StatusCode: number }>((res) => { + resolveWait = res; + }); + + const container = { + id: 'container-abc123def456', + start: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockReturnValue(waitPromise), + logs: vi.fn().mockResolvedValue(Buffer.from('')), + stop: vi.fn().mockResolvedValue(undefined), + }; + + mockDockerCreateContainer.mockResolvedValue(container); + mockDockerGetContainer.mockReturnValue(container); + + return { + container, + resolveWait: (code = exitCode) => resolveWait({ StatusCode: code }), + }; +} + +// --------------------------------------------------------------------------- +// extractProjectIdFromJob +// --------------------------------------------------------------------------- + +describe('extractProjectIdFromJob', () => { + it('returns projectId for trello jobs', async () => { + const job = { type: 'trello', projectId: 'proj-trello' } as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBe('proj-trello'); + }); + + it('returns projectId for jira jobs', async () => { + const job = { type: 'jira', projectId: 'proj-jira' } as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBe('proj-jira'); + }); + + it('returns projectId resolved from repo for github jobs', async () => { + const job = { type: 'github', repoFullName: 'owner/repo' } as CascadeJob; + mockFindProjectByRepo.mockResolvedValue({ id: 'proj-gh' } as never); + expect(await extractProjectIdFromJob(job)).toBe('proj-gh'); + }); + + it('returns null for github jobs with no repoFullName', async () => { + const job = { type: 'github' } as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBeNull(); + }); + + it('returns projectId for manual-run jobs', async () => { + const job = { type: 'manual-run', projectId: 'proj-m' } as unknown as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBe('proj-m'); + }); + + it('returns projectId for retry-run jobs', async () => { + const job = { type: 'retry-run', projectId: 'proj-r' } as unknown as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBe('proj-r'); + }); + + it('returns null for unknown job types', async () => { + const job = { type: 'unknown' } as unknown as CascadeJob; + expect(await extractProjectIdFromJob(job)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildWorkerEnv +// --------------------------------------------------------------------------- + +describe('buildWorkerEnv', () => { + beforeEach(() => { + mockGetAllProjectCredentials.mockResolvedValue({ GITHUB_TOKEN: 'ghp_test' }); + }); + + it('includes JOB_ID, JOB_TYPE, and JOB_DATA', async () => { + const job = makeJob(); + const env = await buildWorkerEnv(job as never); + expect(env).toContain('JOB_ID=job-1'); + expect(env).toContain('JOB_TYPE=trello'); + expect(env.some((e) => e.startsWith('JOB_DATA='))).toBe(true); + }); + + it('includes project credentials and CASCADE_CREDENTIAL_KEYS', async () => { + const env = await buildWorkerEnv(makeJob() as never); + expect(env).toContain('GITHUB_TOKEN=ghp_test'); + expect(env).toContain('CASCADE_CREDENTIAL_KEYS=GITHUB_TOKEN'); + }); + + it('skips credential env vars if credential resolution fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetAllProjectCredentials.mockRejectedValue(new Error('DB error')); + const env = await buildWorkerEnv(makeJob() as never); + expect(env.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS='))).toBe(false); + warnSpy.mockRestore(); + }); + + it('forwards SENTRY_DSN when set', async () => { + process.env.SENTRY_DSN = 'https://sentry.example.com/1'; + const env = await buildWorkerEnv(makeJob() as never); + expect(env).toContain('SENTRY_DSN=https://sentry.example.com/1'); + process.env.SENTRY_DSN = undefined; + }); +}); + +// --------------------------------------------------------------------------- +// spawnWorker / getActiveWorkerCount / getActiveWorkers +// --------------------------------------------------------------------------- + +describe('spawnWorker', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetAllProjectCredentials.mockResolvedValue({}); + detachAll(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + detachAll(); + }); + + it('creates and starts a container', async () => { + const { container, resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob() as never); + + expect(mockDockerCreateContainer).toHaveBeenCalledWith( + expect.objectContaining({ + Image: 'test-worker:latest', + name: 'cascade-worker-job-1', + }), + ); + expect(container.start).toHaveBeenCalled(); + + resolveWait(); + }); + + it('increments active worker count after spawn', async () => { + const { resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-cnt' }) as never); + + expect(getActiveWorkerCount()).toBeGreaterThan(0); + + resolveWait(); + }); + + it('cleans up worker after container exits', async () => { + const { resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-exit' }) as never); + expect(getActiveWorkerCount()).toBeGreaterThanOrEqual(1); + + resolveWait(0); + // Let microtasks flush + await new Promise((r) => setTimeout(r, 10)); + + const workers = getActiveWorkers(); + expect(workers.find((w) => w.jobId === 'job-exit')).toBeUndefined(); + }); + + it('throws and does not track worker if container creation fails', async () => { + mockDockerCreateContainer.mockRejectedValue(new Error('Docker unavailable')); + const countBefore = getActiveWorkerCount(); + + await expect(spawnWorker(makeJob({ id: 'job-fail' }) as never)).rejects.toThrow( + 'Docker unavailable', + ); + + expect(getActiveWorkerCount()).toBe(countBefore); + }); +}); + +// --------------------------------------------------------------------------- +// killWorker +// --------------------------------------------------------------------------- + +describe('killWorker', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetAllProjectCredentials.mockResolvedValue({}); + mockNotifyTimeout.mockResolvedValue(undefined); + detachAll(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + detachAll(); + }); + + it('is a no-op for an unknown jobId', async () => { + await expect(killWorker('nonexistent')).resolves.toBeUndefined(); + expect(mockDockerGetContainer).not.toHaveBeenCalled(); + }); + + it('stops the container and sends timeout notification', async () => { + const { container, resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-kill' }) as never); + await killWorker('job-kill'); + + expect(container.stop).toHaveBeenCalledWith({ t: 15 }); + expect(mockNotifyTimeout).toHaveBeenCalled(); + + resolveWait(); + }); + + it('still sends notification even if container stop fails', async () => { + const { container, resolveWait } = setupMockContainer(); + container.stop.mockRejectedValue(new Error('already stopped')); + + await spawnWorker(makeJob({ id: 'job-already-stopped' }) as never); + await killWorker('job-already-stopped'); + + expect(mockNotifyTimeout).toHaveBeenCalled(); + + resolveWait(); + }); + + it('removes worker from tracking after kill', async () => { + const { resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-rm' }) as never); + expect(getActiveWorkers().find((w) => w.jobId === 'job-rm')).toBeDefined(); + + await killWorker('job-rm'); + expect(getActiveWorkers().find((w) => w.jobId === 'job-rm')).toBeUndefined(); + + resolveWait(); + }); +}); + +// --------------------------------------------------------------------------- +// cleanupWorker +// --------------------------------------------------------------------------- + +describe('cleanupWorker', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + detachAll(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + detachAll(); + }); + + it('is a no-op for an unknown jobId', () => { + expect(() => cleanupWorker('nonexistent')).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// detachAll +// --------------------------------------------------------------------------- + +describe('detachAll', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetAllProjectCredentials.mockResolvedValue({}); + detachAll(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + detachAll(); + }); + + it('clears all tracked workers', async () => { + setupMockContainer(); + await spawnWorker(makeJob({ id: 'job-d1' }) as never); + expect(getActiveWorkerCount()).toBeGreaterThan(0); + + detachAll(); + expect(getActiveWorkerCount()).toBe(0); + }); +}); diff --git a/tests/unit/router/worker-manager.test.ts b/tests/unit/router/worker-manager.test.ts new file mode 100644 index 00000000..7c585d4c --- /dev/null +++ b/tests/unit/router/worker-manager.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks — all factories use vi.fn() directly (no external variable refs) +// --------------------------------------------------------------------------- + +vi.mock('../../../src/router/bullmq-workers.js', () => ({ + createQueueWorker: vi.fn(), + parseRedisUrl: vi.fn().mockReturnValue({ host: 'localhost', port: 6379 }), +})); + +vi.mock('../../../src/router/container-manager.js', () => ({ + spawnWorker: vi.fn().mockResolvedValue(undefined), + getActiveWorkerCount: vi.fn().mockReturnValue(0), + getActiveWorkers: vi.fn().mockReturnValue([]), + detachAll: vi.fn(), +})); + +vi.mock('../../../src/router/config.js', () => ({ + routerConfig: { + redisUrl: 'redis://localhost:6379', + maxWorkers: 3, + workerImage: 'test-worker:latest', + workerMemoryMb: 512, + workerTimeoutMs: 5000, + dockerNetwork: 'test-network', + }, +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { createQueueWorker, parseRedisUrl } from '../../../src/router/bullmq-workers.js'; +import { + detachAll, + getActiveWorkerCount, + getActiveWorkers, + spawnWorker, +} from '../../../src/router/container-manager.js'; +import { + startWorkerProcessor, + stopWorkerProcessor, + getActiveWorkerCount as wmGetActiveWorkerCount, + getActiveWorkers as wmGetActiveWorkers, +} from '../../../src/router/worker-manager.js'; + +const mockCreateQueueWorker = vi.mocked(createQueueWorker); +const mockParseRedisUrl = vi.mocked(parseRedisUrl); +const mockSpawnWorker = vi.mocked(spawnWorker); +const mockGetActiveWorkerCount = vi.mocked(getActiveWorkerCount); +const mockGetActiveWorkers = vi.mocked(getActiveWorkers); +const mockDetachAll = vi.mocked(detachAll); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockWorker() { + return { close: vi.fn().mockResolvedValue(undefined) }; +} + +// --------------------------------------------------------------------------- +// Re-exports +// --------------------------------------------------------------------------- + +describe('re-exports', () => { + it('getActiveWorkerCount delegates to container-manager', () => { + mockGetActiveWorkerCount.mockReturnValue(5); + expect(wmGetActiveWorkerCount()).toBe(5); + }); + + it('getActiveWorkers delegates to container-manager', () => { + const workers = [{ jobId: 'j1', startedAt: new Date() }]; + mockGetActiveWorkers.mockReturnValue(workers); + expect(wmGetActiveWorkers()).toBe(workers); + }); +}); + +// --------------------------------------------------------------------------- +// startWorkerProcessor +// --------------------------------------------------------------------------- + +describe('startWorkerProcessor', () => { + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockCreateQueueWorker.mockReturnValue(makeMockWorker() as never); + // Ensure clean state + await stopWorkerProcessor(); + mockCreateQueueWorker.mockClear(); + mockParseRedisUrl.mockClear(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await stopWorkerProcessor(); + }); + + it('creates two queue workers (cascade-jobs and cascade-dashboard-jobs)', () => { + startWorkerProcessor(); + + expect(mockCreateQueueWorker).toHaveBeenCalledTimes(2); + const queueNames = mockCreateQueueWorker.mock.calls.map((call) => call[0].queueName); + expect(queueNames).toContain('cascade-jobs'); + expect(queueNames).toContain('cascade-dashboard-jobs'); + }); + + it('passes parsed Redis connection to both workers', () => { + const connection = { host: 'redis-host', port: 6380 }; + mockParseRedisUrl.mockReturnValue(connection); + + startWorkerProcessor(); + + for (const call of mockCreateQueueWorker.mock.calls) { + expect(call[0].connection).toBe(connection); + } + }); + + it('configures maxWorkers as concurrency for both workers', () => { + startWorkerProcessor(); + + for (const call of mockCreateQueueWorker.mock.calls) { + expect(call[0].concurrency).toBe(3); // routerConfig.maxWorkers + } + }); + + it('does not create duplicate workers when called twice', () => { + startWorkerProcessor(); + startWorkerProcessor(); // second call should warn and return early + + expect(mockCreateQueueWorker).toHaveBeenCalledTimes(2); // still only 2 workers total + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already started')); + }); + + it('passes a processFn that checks capacity before spawning', async () => { + startWorkerProcessor(); + + // Get the processFn from the cascade-jobs worker call + const cascadeJobsCall = mockCreateQueueWorker.mock.calls.find( + (call) => call[0].queueName === 'cascade-jobs', + ); + expect(cascadeJobsCall).toBeDefined(); + const processFn = cascadeJobsCall?.[0].processFn; + + // When under capacity, spawnWorker should be called + mockGetActiveWorkerCount.mockReturnValue(0); + const fakeJob = { id: 'j1', data: { type: 'trello', projectId: 'p1' } }; + await processFn(fakeJob); + expect(mockSpawnWorker).toHaveBeenCalledWith(fakeJob); + }); + + it('processFn throws when at capacity', async () => { + startWorkerProcessor(); + + const cascadeJobsCall = mockCreateQueueWorker.mock.calls.find( + (call) => call[0].queueName === 'cascade-jobs', + ); + const processFn = cascadeJobsCall?.[0].processFn; + + // At capacity + mockGetActiveWorkerCount.mockReturnValue(3); // equals maxWorkers + const fakeJob = { id: 'j2', data: { type: 'trello', projectId: 'p1' } }; + await expect(processFn(fakeJob)).rejects.toThrow('No worker slots available'); + expect(mockSpawnWorker).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// stopWorkerProcessor +// --------------------------------------------------------------------------- + +describe('stopWorkerProcessor', () => { + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockCreateQueueWorker.mockReturnValue(makeMockWorker() as never); + await stopWorkerProcessor(); // ensure clean state + mockCreateQueueWorker.mockClear(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await stopWorkerProcessor(); + }); + + it('closes both workers', async () => { + const worker1 = makeMockWorker(); + const worker2 = makeMockWorker(); + mockCreateQueueWorker + .mockReturnValueOnce(worker1 as never) + .mockReturnValueOnce(worker2 as never); + + startWorkerProcessor(); + await stopWorkerProcessor(); + + expect(worker1.close).toHaveBeenCalled(); + expect(worker2.close).toHaveBeenCalled(); + }); + + it('calls detachAll to release container references', async () => { + startWorkerProcessor(); + await stopWorkerProcessor(); + + expect(mockDetachAll).toHaveBeenCalled(); + }); + + it('is idempotent — safe to call multiple times', async () => { + startWorkerProcessor(); + await stopWorkerProcessor(); + mockDetachAll.mockClear(); + await stopWorkerProcessor(); // second call should not throw + + expect(mockDetachAll).toHaveBeenCalledTimes(1); + }); + + it('logs Stopped message', async () => { + startWorkerProcessor(); + await stopWorkerProcessor(); + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Stopped')); + }); +}); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index cda58ace..dfb34005 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -63,6 +63,135 @@ describe('trelloClient', () => { vi.clearAllMocks(); }); + // ===== trelloFetch helper ===== + + describe('trelloFetch (via public methods)', () => { + it('appends key and token to a path without existing query params', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await withTrelloCredentials(creds, () => trelloClient.getMe()); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + // Uses ? separator when no existing query params + expect(url).toMatch(/\/members\/me\?/); + }); + + it('appends key and token with & when path already has query params', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + + await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + const [url] = fetchSpy.mock.calls[0]; + // Path already has ?filter=open, so credentials should be appended with & + expect(url).toMatch(/filter=open.*key=test-key.*token=test-token/); + }); + + it('throws a Trello API error with status code on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 })); + + await expect(withTrelloCredentials(creds, () => trelloClient.getMe())).rejects.toThrow( + 'Trello API error 404', + ); + }); + + it('throws when called outside withTrelloCredentials scope', async () => { + await expect(trelloClient.getMe()).rejects.toThrow('No Trello credentials in scope'); + }); + + it('sends PUT request with JSON body for write operations', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await withTrelloCredentials(creds, () => + trelloClient.updateComment('action-123', 'Updated text'), + ); + + const [, options] = fetchSpy.mock.calls[0]; + expect(options?.method).toBe('PUT'); + expect(options?.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(options?.body).toBe(JSON.stringify({ text: 'Updated text' })); + }); + }); + + // ===== mapLabels utility (tested via card methods) ===== + + describe('mapLabels (via getCard / createCard / getListCards)', () => { + it('maps labels with all fields present', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + labels: [{ id: 'lbl-1', name: 'Bug', color: 'red' }], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([{ id: 'lbl-1', name: 'Bug', color: 'red' }]); + }); + + it('returns empty array when labels is undefined', async () => { + mockCards.getCard.mockResolvedValue({ id: 'card-1' }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([]); + }); + + it('defaults missing label fields to empty strings', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + labels: [{}], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result.labels).toEqual([{ id: '', name: '', color: '' }]); + }); + + it('applies mapLabels consistently across createCard', async () => { + mockCards.createCard.mockResolvedValue({ + id: 'new-card', + name: 'New', + desc: '', + url: '', + shortUrl: '', + idList: 'list-1', + labels: [{ id: 'lbl-2', name: 'Feature', color: 'green' }], + }); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createCard('list-1', { name: 'New' }), + ); + + expect(result.labels).toEqual([{ id: 'lbl-2', name: 'Feature', color: 'green' }]); + }); + + it('applies mapLabels consistently across getListCards', async () => { + mockLists.getListCards.mockResolvedValue([ + { + id: 'card-1', + name: 'Card', + desc: '', + url: '', + shortUrl: '', + idList: 'list-1', + labels: [{ id: 'lbl-3', name: 'High Priority', color: 'orange' }], + }, + ]); + + const results = await withTrelloCredentials(creds, () => trelloClient.getListCards('list-1')); + + expect(results[0].labels).toEqual([{ id: 'lbl-3', name: 'High Priority', color: 'orange' }]); + }); + }); + + // ===== Existing tests (unchanged behavior) ===== + describe('addComment', () => { it('returns the comment action ID from API response', async () => { mockCards.addCardComment.mockResolvedValue({ id: 'action-abc123' }); @@ -111,7 +240,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.updateComment('action-123', 'text')), - ).rejects.toThrow('Failed to update comment: 404'); + ).rejects.toThrow('Trello API error 404'); }); it('throws when called outside withTrelloCredentials scope', async () => { @@ -150,7 +279,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.addActionReaction('action-123', emoji)), - ).rejects.toThrow('Failed to add reaction to action: 400'); + ).rejects.toThrow('Trello API error 400'); }); it('throws when called outside withTrelloCredentials scope', async () => { @@ -338,6 +467,151 @@ describe('trelloClient', () => { }); }); + describe('getBoards', () => { + it('returns boards for authenticated member', async () => { + const boards = [ + { id: 'board-1', name: 'Board One', url: 'https://trello.com/b/board1' }, + { id: 'board-2', name: 'Board Two', url: 'https://trello.com/b/board2' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(boards), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + expect(result).toEqual(boards); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/members/me/boards'); + expect(url).toContain('filter=open'); + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Unauthorized', { status: 401 }), + ); + + await expect(withTrelloCredentials(creds, () => trelloClient.getBoards())).rejects.toThrow( + 'Trello API error 401', + ); + }); + + it('handles missing fields gracefully', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify([{}, { id: 'b1' }]), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + expect(result).toEqual([ + { id: '', name: '', url: '' }, + { id: 'b1', name: '', url: '' }, + ]); + }); + }); + + describe('getBoardLists', () => { + it('returns lists for a board', async () => { + const lists = [ + { id: 'list-1', name: 'Backlog' }, + { id: 'list-2', name: 'In Progress' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(lists), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardLists('board-1'), + ); + + expect(result).toEqual(lists); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/lists'); + expect(url).toContain('filter=open'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardLists('board-1')), + ).rejects.toThrow('Trello API error 404'); + }); + }); + + describe('getBoardLabels', () => { + it('returns labels for a board', async () => { + const labels = [ + { id: 'label-1', name: 'Bug', color: 'red' }, + { id: 'label-2', name: 'Feature', color: 'green' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(labels), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardLabels('board-1'), + ); + + expect(result).toEqual(labels); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/labels'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Error', { status: 500 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardLabels('board-1')), + ).rejects.toThrow('Trello API error 500'); + }); + }); + + describe('getBoardCustomFields', () => { + it('returns custom fields for a board', async () => { + const fields = [ + { id: 'cf-1', name: 'Priority', type: 'list' }, + { id: 'cf-2', name: 'Cost', type: 'number' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(fields), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardCustomFields('board-1'), + ); + + expect(result).toEqual(fields); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/customFields'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Error', { status: 403 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardCustomFields('board-1')), + ).rejects.toThrow('Trello API error 403'); + }); + + it('handles missing fields gracefully', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify([{}, { id: 'cf-1', type: 'text' }]), { status: 200 }), + ); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardCustomFields('board-1'), + ); + + expect(result).toEqual([ + { id: '', name: '', type: '' }, + { id: 'cf-1', name: '', type: 'text' }, + ]); + }); + }); + describe('getCardAttachments', () => { it('returns attachments via fetch', async () => { const attachments = [ @@ -380,7 +654,7 @@ describe('trelloClient', () => { await expect( withTrelloCredentials(creds, () => trelloClient.getCardAttachments('card-1')), - ).rejects.toThrow('Failed to get attachments: 401'); + ).rejects.toThrow('Trello API error 401'); }); }); }); diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts new file mode 100644 index 00000000..9fa58775 --- /dev/null +++ b/tests/unit/triggers/builtins.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock all trigger imports +vi.mock('../../../src/triggers/github/check-suite-failure.js', () => ({ + CheckSuiteFailureTrigger: vi.fn().mockImplementation(() => ({ name: 'check-suite-failure' })), +})); +vi.mock('../../../src/triggers/github/check-suite-success.js', () => ({ + CheckSuiteSuccessTrigger: vi.fn().mockImplementation(() => ({ name: 'check-suite-success' })), +})); +vi.mock('../../../src/triggers/github/pr-comment-mention.js', () => ({ + PRCommentMentionTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-comment-mention' })), +})); +vi.mock('../../../src/triggers/github/pr-merged.js', () => ({ + PRMergedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-merged' })), +})); +vi.mock('../../../src/triggers/github/pr-opened.js', () => ({ + PROpenedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-opened' })), +})); +vi.mock('../../../src/triggers/github/pr-ready-to-merge.js', () => ({ + PRReadyToMergeTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-ready-to-merge' })), +})); +vi.mock('../../../src/triggers/github/pr-review-submitted.js', () => ({ + PRReviewSubmittedTrigger: vi.fn().mockImplementation(() => ({ name: 'pr-review-submitted' })), +})); +vi.mock('../../../src/triggers/github/review-requested.js', () => ({ + ReviewRequestedTrigger: vi.fn().mockImplementation(() => ({ name: 'review-requested' })), +})); +vi.mock('../../../src/triggers/jira/comment-mention.js', () => ({ + JiraCommentMentionTrigger: vi.fn().mockImplementation(() => ({ name: 'jira-comment-mention' })), +})); +vi.mock('../../../src/triggers/jira/issue-transitioned.js', () => ({ + JiraIssueTransitionedTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'jira-issue-transitioned' })), +})); +vi.mock('../../../src/triggers/jira/label-added.js', () => ({ + JiraReadyToProcessLabelTrigger: vi.fn().mockImplementation(() => ({ name: 'jira-label-added' })), +})); +vi.mock('../../../src/triggers/trello/card-moved.js', () => ({ + CardMovedToBriefingTrigger: { name: 'card-moved-to-briefing' }, + CardMovedToPlanningTrigger: { name: 'card-moved-to-planning' }, + CardMovedToTodoTrigger: { name: 'card-moved-to-todo' }, +})); +vi.mock('../../../src/triggers/trello/comment-mention.js', () => ({ + TrelloCommentMentionTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'trello-comment-mention' })), +})); +vi.mock('../../../src/triggers/trello/label-added.js', () => ({ + ReadyToProcessLabelTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'ready-to-process-label' })), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { registerBuiltInTriggers } from '../../../src/triggers/builtins.js'; +import type { TriggerRegistry } from '../../../src/triggers/registry.js'; + +function createMockRegistry(): { register: ReturnType; handlers: object[] } { + const handlers: object[] = []; + return { + register: vi.fn((handler) => handlers.push(handler)), + handlers, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('registerBuiltInTriggers', () => { + it('registers all expected trigger handlers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + // Should have registered all 16 built-in triggers + expect(registry.register).toHaveBeenCalledTimes(16); + }); + + it('registers TrelloCommentMentionTrigger first', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const firstCall = registry.register.mock.calls[0][0]; + expect(firstCall.name).toBe('trello-comment-mention'); + }); + + it('registers all three card-moved triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('card-moved-to-briefing'); + expect(registeredNames).toContain('card-moved-to-planning'); + expect(registeredNames).toContain('card-moved-to-todo'); + }); + + it('registers GitHub triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('check-suite-failure'); + expect(registeredNames).toContain('check-suite-success'); + expect(registeredNames).toContain('pr-comment-mention'); + expect(registeredNames).toContain('pr-merged'); + expect(registeredNames).toContain('pr-opened'); + expect(registeredNames).toContain('pr-ready-to-merge'); + expect(registeredNames).toContain('pr-review-submitted'); + expect(registeredNames).toContain('review-requested'); + }); + + it('registers JIRA triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('jira-comment-mention'); + expect(registeredNames).toContain('jira-issue-transitioned'); + expect(registeredNames).toContain('jira-label-added'); + }); + + it('registers TrelloCommentMentionTrigger before card-moved triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const commentMentionIdx = names.indexOf('trello-comment-mention'); + const cardMovedIdx = names.indexOf('card-moved-to-briefing'); + expect(commentMentionIdx).toBeLessThan(cardMovedIdx); + }); + + it('registers JiraCommentMentionTrigger before JiraIssueTransitionedTrigger', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const jiraCommentIdx = names.indexOf('jira-comment-mention'); + const jiraTransitionIdx = names.indexOf('jira-issue-transitioned'); + expect(jiraCommentIdx).toBeLessThan(jiraTransitionIdx); + }); + + it('registers PRCommentMentionTrigger before other GitHub triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const prCommentIdx = names.indexOf('pr-comment-mention'); + const prReviewIdx = names.indexOf('pr-review-submitted'); + expect(prCommentIdx).toBeLessThan(prReviewIdx); + }); +}); diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index eb4e9229..92d9d1d3 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -240,6 +240,145 @@ describe('PROpenedTrigger', () => { expect(result?.workItemId).toBeUndefined(); }); + it('returns null for PRs by implementer persona', async () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-review' }, + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'feat: add login', + body: 'Implements feature', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/login', sha: 'abc' }, + base: { ref: 'main' }, + user: { login: 'cascade-impl' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'cascade-impl' }, + }, + }; + + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('returns null for PRs by reviewer persona', async () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-review' }, + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'feat: add login', + body: 'Implements feature', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/login', sha: 'abc' }, + base: { ref: 'main' }, + user: { login: 'cascade-review' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'cascade-review' }, + }, + }; + + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('returns null for [bot] variant', async () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-review' }, + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'feat: add login', + body: 'Implements feature', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/login', sha: 'abc' }, + base: { ref: 'main' }, + user: { login: 'cascade-impl[bot]' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'cascade-impl[bot]' }, + }, + }; + + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('fires normally for external PRs with personaIdentities present', async () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + personaIdentities: { implementer: 'cascade-impl', reviewer: 'cascade-review' }, + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'Test PR', + body: 'Just a regular PR', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/test', sha: 'abc' }, + base: { ref: 'main' }, + user: { login: 'external-dev' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'external-dev' }, + }, + }; + + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('respond-to-review'); + }); + + it('fires normally without personaIdentities (graceful degradation)', async () => { + const ctx: TriggerContext = { + project: mockProjectWithPrOpenedEnabled, + source: 'github', + // no personaIdentities — credential resolution failed + payload: { + action: 'opened', + number: 42, + pull_request: { + number: 42, + title: 'Test PR', + body: 'Just a regular PR', + html_url: 'https://github.com/owner/repo/pull/42', + state: 'open', + draft: false, + head: { ref: 'feature/test', sha: 'abc' }, + base: { ref: 'main' }, + user: { login: 'cascade-impl' }, + }, + repository: { full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' }, + sender: { login: 'cascade-impl' }, + }, + }; + + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('respond-to-review'); + }); + it('fires with undefined workItemId for null PR body', async () => { const ctx: TriggerContext = { project: mockProjectWithPrOpenedEnabled, diff --git a/tests/unit/utils/llmEnv.test.ts b/tests/unit/utils/llmEnv.test.ts new file mode 100644 index 00000000..9126a45e --- /dev/null +++ b/tests/unit/utils/llmEnv.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/provider.js', () => ({ + getOrgCredential: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { getOrgCredential } from '../../../src/config/provider.js'; +import { injectLlmApiKeys } from '../../../src/utils/llmEnv.js'; + +const mockGetOrgCredential = vi.mocked(getOrgCredential); + +beforeEach(() => { + vi.clearAllMocks(); + // Clean up the env var before each test + Reflect.deleteProperty(process.env, 'OPENROUTER_API_KEY'); +}); + +afterEach(() => { + Reflect.deleteProperty(process.env, 'OPENROUTER_API_KEY'); +}); + +describe('injectLlmApiKeys', () => { + it('injects OPENROUTER_API_KEY from DB into process.env', async () => { + mockGetOrgCredential.mockResolvedValue('sk-or-test-key'); + + await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('sk-or-test-key'); + }); + + it('returns a restore function that removes injected key', async () => { + mockGetOrgCredential.mockResolvedValue('sk-or-test-key'); + + const restore = await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('sk-or-test-key'); + restore(); + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('restores previously set env var value on restore', async () => { + process.env.OPENROUTER_API_KEY = 'original-key'; + mockGetOrgCredential.mockResolvedValue('new-key-from-db'); + + const restore = await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBe('new-key-from-db'); + restore(); + expect(process.env.OPENROUTER_API_KEY).toBe('original-key'); + }); + + it('does not set env var when DB returns null', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + await injectLlmApiKeys('project-1'); + + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('restores original undefined when DB returns null', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + const restore = await injectLlmApiKeys('project-1'); + restore(); + + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + + it('calls getOrgCredential with the given projectId and key name', async () => { + mockGetOrgCredential.mockResolvedValue(null); + + await injectLlmApiKeys('my-project'); + + expect(mockGetOrgCredential).toHaveBeenCalledWith('my-project', 'OPENROUTER_API_KEY'); + }); +}); diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 0db0b7fa..98e3e067 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -1,86 +1,9 @@ -import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, Loader2, Plus, Trash2, XCircle } from 'lucide-react'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; - -interface KVPair { - key: string; - value: string; -} - -function KeyValueEditor({ - label, - pairs, - onChange, -}: { - label: string; - pairs: KVPair[]; - onChange: (pairs: KVPair[]) => void; -}) { - return ( -
-
- - -
- {pairs.map((pair, i) => ( -
- { - const next = [...pairs]; - next[i] = { ...next[i], key: e.target.value }; - onChange(next); - }} - placeholder="Key" - className="flex-1" - /> - { - const next = [...pairs]; - next[i] = { ...next[i], value: e.target.value }; - onChange(next); - }} - placeholder="Value" - className="flex-1" - /> - -
- ))} - {pairs.length === 0 &&

No entries

} -
- ); -} - -function toKVPairs(obj: Record | undefined): KVPair[] { - if (!obj) return []; - return Object.entries(obj).map(([key, value]) => ({ key, value })); -} - -function fromKVPairs(pairs: KVPair[]): Record { - const result: Record = {}; - for (const pair of pairs) { - if (pair.key.trim()) { - result[pair.key.trim()] = pair.value; - } - } - return result; -} +import { PMWizard } from './pm-wizard.js'; type IntegrationCategory = 'pm' | 'scm'; @@ -156,141 +79,6 @@ function CredentialSelector({ ); } -// ============================================================================ -// Known key constants for constrained editors -// ============================================================================ - -interface KeyOption { - value: string; - label: string; -} - -const TRELLO_LIST_KEYS: KeyOption[] = [ - { value: 'briefing', label: 'briefing' }, - { value: 'stories', label: 'stories' }, - { value: 'planning', label: 'planning' }, - { value: 'todo', label: 'todo' }, - { value: 'inProgress', label: 'inProgress' }, - { value: 'inReview', label: 'inReview' }, - { value: 'done', label: 'done' }, - { value: 'merged', label: 'merged' }, - { value: 'debug', label: 'debug' }, -]; - -const TRELLO_LABEL_KEYS: KeyOption[] = [ - { value: 'readyToProcess', label: 'readyToProcess' }, - { value: 'processing', label: 'processing' }, - { value: 'processed', label: 'processed' }, - { value: 'error', label: 'error' }, -]; - -const JIRA_STATUS_KEYS: KeyOption[] = [ - { value: 'briefing', label: 'briefing' }, - { value: 'planning', label: 'planning' }, - { value: 'todo', label: 'todo' }, - { value: 'inProgress', label: 'inProgress' }, - { value: 'inReview', label: 'inReview' }, - { value: 'done', label: 'done' }, - { value: 'merged', label: 'merged' }, -]; - -const JIRA_LABEL_KEYS: KeyOption[] = [ - { value: 'processing', label: 'processing' }, - { value: 'processed', label: 'processed' }, - { value: 'error', label: 'error' }, - { value: 'readyToProcess', label: 'readyToProcess' }, -]; - -// ============================================================================ -// ConstrainedKeyValueEditor — key column is a dropdown of allowed keys -// ============================================================================ - -function ConstrainedKeyValueEditor({ - label, - pairs, - onChange, - allowedKeys, - valuePlaceholder, -}: { - label: string; - pairs: KVPair[]; - onChange: (pairs: KVPair[]) => void; - allowedKeys: KeyOption[]; - valuePlaceholder?: string; -}) { - const usedKeys = new Set(pairs.map((p) => p.key)); - const availableKeys = allowedKeys.filter((k) => !usedKeys.has(k.value)); - const allUsed = availableKeys.length === 0; - - const handleAdd = () => { - // Pick the first unused allowed key, or empty string if all used - const firstAvailable = availableKeys[0]?.value ?? ''; - onChange([...pairs, { key: firstAvailable, value: '' }]); - }; - - return ( -
-
- - -
- {pairs.map((pair, i) => { - // Keys available for this row: allowed keys not used by OTHER rows - const otherUsedKeys = new Set(pairs.filter((_, j) => j !== i).map((p) => p.key)); - // Build options: all allowed keys not used elsewhere + current key if it's custom - const rowOptions = allowedKeys.filter((k) => !otherUsedKeys.has(k.value)); - const isCustomKey = pair.key !== '' && !allowedKeys.some((k) => k.value === pair.key); - - return ( -
- - { - const next = [...pairs]; - next[i] = { ...next[i], value: e.target.value }; - onChange(next); - }} - placeholder={valuePlaceholder ?? 'Value'} - className="flex-1" - /> - -
- ); - })} - {pairs.length === 0 &&

No entries

} -
- ); -} - // ============================================================================ // Provider-specific credential role definitions // ============================================================================ @@ -302,17 +90,6 @@ interface CredentialRoleDef { hasVerify?: boolean; } -const PM_CREDENTIAL_ROLES: Record = { - trello: [ - { role: 'api_key', label: 'API Key', description: 'Trello API Key for authentication.' }, - { role: 'token', label: 'Token', description: 'Trello token for authorization.' }, - ], - jira: [ - { role: 'email', label: 'Email', description: 'JIRA account email for authentication.' }, - { role: 'api_token', label: 'API Token', description: 'JIRA API token for authorization.' }, - ], -}; - const SCM_CREDENTIAL_ROLES: Record = { github: [ { @@ -404,280 +181,6 @@ function IntegrationCredentialSlots({ ); } -// ============================================================================ -// PM Tab (Trello / JIRA) -// ============================================================================ - -function PMTab({ - projectId, - initialProvider, - initialConfig, - initialCredentials, -}: { - projectId: string; - initialProvider: string; - initialConfig?: Record; - initialCredentials: Map; -}) { - const queryClient = useQueryClient(); - - const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); - const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; - - const [provider, setProvider] = useState(initialProvider || 'trello'); - const [credentialMap, setCredentialMap] = useState>(initialCredentials); - - // Trello fields - const [boardId, setBoardId] = useState(''); - const [lists, setLists] = useState([]); - const [labels, setLabels] = useState([]); - const [costField, setCostField] = useState(''); - - // Jira fields - const [jiraProjectKey, setJiraProjectKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [statuses, setStatuses] = useState([]); - const [issueTypes, setIssueTypes] = useState([]); - const [jiraLabels, setJiraLabels] = useState([ - { key: 'processing', value: 'cascade-processing' }, - { key: 'processed', value: 'cascade-processed' }, - { key: 'error', value: 'cascade-error' }, - { key: 'readyToProcess', value: 'cascade-ready' }, - ]); - const [jiraCostField, setJiraCostField] = useState(''); - - useEffect(() => { - if (initialConfig && initialProvider === 'trello') { - setBoardId((initialConfig.boardId as string) ?? ''); - setLists(toKVPairs(initialConfig.lists as Record)); - setLabels(toKVPairs(initialConfig.labels as Record)); - const cf = initialConfig.customFields as Record | undefined; - setCostField(cf?.cost ?? ''); - } else if (initialConfig && initialProvider === 'jira') { - setJiraProjectKey((initialConfig.projectKey as string) ?? ''); - setBaseUrl((initialConfig.baseUrl as string) ?? ''); - setStatuses(toKVPairs(initialConfig.statuses as Record)); - setIssueTypes(toKVPairs(initialConfig.issueTypes as Record)); - const jl = initialConfig.labels as Record | undefined; - if (jl) setJiraLabels(toKVPairs(jl)); - const cf = initialConfig.customFields as Record | undefined; - setJiraCostField(cf?.cost ?? ''); - } - }, [initialConfig, initialProvider]); - - useEffect(() => { - setCredentialMap(initialCredentials); - }, [initialCredentials]); - - const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles multiple provider types + credential linking - mutationFn: async () => { - let config: Record; - if (provider === 'trello') { - config = { - boardId, - lists: fromKVPairs(lists), - labels: fromKVPairs(labels), - ...(costField ? { customFields: { cost: costField } } : {}), - }; - } else { - config = { - projectKey: jiraProjectKey, - baseUrl, - statuses: fromKVPairs(statuses), - ...(issueTypes.length > 0 ? { issueTypes: fromKVPairs(issueTypes) } : {}), - ...(jiraLabels.length > 0 ? { labels: fromKVPairs(jiraLabels) } : {}), - ...(jiraCostField ? { customFields: { cost: jiraCostField } } : {}), - }; - } - - // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab - const result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'pm', - provider, - config, - }); - - // Set integration credentials - for (const [role, credentialId] of credentialMap) { - await trpcClient.projects.integrationCredentials.set.mutate({ - projectId, - category: 'pm', - role, - credentialId, - }); - } - - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrationCredentials.list.queryOptions({ - projectId, - category: 'pm', - }).queryKey, - }); - }, - }); - - const credentialRoles = PM_CREDENTIAL_ROLES[provider] ?? []; - - return ( -
-
- - -
- - {provider === 'trello' && ( - <> -
- - setBoardId(e.target.value)} - placeholder="Trello board ID" - /> -
- - -
- - setCostField(e.target.value)} - placeholder="Custom field ID for cost tracking" - /> -
- - )} - - {provider === 'jira' && ( - <> -
- - setJiraProjectKey(e.target.value)} - placeholder="e.g., PROJ" - /> -
-
- - setBaseUrl(e.target.value)} - placeholder="https://your-instance.atlassian.net" - /> -
- -

- Map each CASCADE status key to the corresponding JIRA status name. -

- - -

- Map each CASCADE label key to the corresponding JIRA label name. -

-
- - setJiraCostField(e.target.value)} - placeholder="e.g., customfield_10042" - /> -
- - )} - -

- Trigger configuration has moved to the Agent Configs tab. -

- - { - setCredentialMap((prev) => { - const next = new Map(prev); - if (id) { - next.set(role, id); - } else { - next.delete(role); - } - return next; - }); - }} - /> - -
- - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} -
-
- ); -} - // ============================================================================ // SCM Tab (GitHub) // ============================================================================ @@ -857,7 +360,7 @@ export function IntegrationForm({ projectId }: { projectId: string }) { {activeTab === 'pm' && ( - } diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx new file mode 100644 index 00000000..1d768615 --- /dev/null +++ b/web/src/components/projects/pm-wizard.tsx @@ -0,0 +1,1847 @@ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AlertCircle, + AlertTriangle, + Check, + CheckCircle, + ChevronDown, + ChevronRight, + ExternalLink, + Globe, + KeyRound, + Loader2, + Plus, + RefreshCw, + Trash2, + XCircle, +} from 'lucide-react'; +import { type Reducer, useEffect, useReducer, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +interface CredentialOption { + id: number; + name: string; + envVarKey: string; + value: string; +} + +interface TrelloBoardOption { + id: string; + name: string; + url: string; +} + +interface TrelloBoardDetails { + lists: Array<{ id: string; name: string }>; + labels: Array<{ id: string; name: string; color: string }>; + customFields: Array<{ id: string; name: string; type: string }>; +} + +interface JiraProjectOption { + key: string; + name: string; +} + +interface JiraProjectDetails { + statuses: Array<{ name: string; id: string }>; + issueTypes: Array<{ name: string; subtask: boolean }>; + fields: Array<{ id: string; name: string; custom: boolean }>; +} + +// ============================================================================ +// Wizard State +// ============================================================================ + +type Provider = 'trello' | 'jira'; + +interface WizardState { + provider: Provider; + // Step 2: Credentials + trelloApiKeyCredentialId: number | null; + trelloTokenCredentialId: number | null; + jiraEmailCredentialId: number | null; + jiraApiTokenCredentialId: number | null; + jiraBaseUrl: string; + verificationResult: { provider: Provider; display: string } | null; + verifyError: string | null; + // Step 3: Board/Project + trelloBoardId: string; + trelloBoards: TrelloBoardOption[]; + jiraProjectKey: string; + jiraProjects: JiraProjectOption[]; + // Step 4: Field mapping + trelloBoardDetails: TrelloBoardDetails | null; + jiraProjectDetails: JiraProjectDetails | null; + // Trello mappings + trelloListMappings: Record; + trelloLabelMappings: Record; + trelloCostFieldId: string; + // JIRA mappings + jiraStatusMappings: Record; + jiraIssueTypes: Record; + jiraLabels: Record; + jiraCostFieldId: string; + // Editing mode + isEditing: boolean; +} + +type WizardAction = + | { type: 'SET_PROVIDER'; provider: Provider } + | { type: 'SET_TRELLO_API_KEY_CRED'; id: number | null } + | { type: 'SET_TRELLO_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_EMAIL_CRED'; id: number | null } + | { type: 'SET_JIRA_API_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_BASE_URL'; url: string } + | { + type: 'SET_VERIFICATION'; + result: { provider: Provider; display: string } | null; + error?: string | null; + } + | { type: 'SET_TRELLO_BOARDS'; boards: TrelloBoardOption[] } + | { type: 'SET_TRELLO_BOARD_ID'; id: string } + | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } + | { type: 'SET_JIRA_PROJECT_KEY'; key: string } + | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } + | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } + | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_LABEL_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_COST_FIELD'; id: string } + | { type: 'SET_JIRA_STATUS_MAPPING'; key: string; value: string } + | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } + | { type: 'SET_JIRA_LABEL'; key: string; value: string } + | { type: 'SET_JIRA_COST_FIELD'; id: string } + | { type: 'INIT_EDIT'; state: Partial }; + +const INITIAL_JIRA_LABELS: Record = { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', +}; + +function createInitialState(): WizardState { + return { + provider: 'trello', + trelloApiKeyCredentialId: null, + trelloTokenCredentialId: null, + jiraEmailCredentialId: null, + jiraApiTokenCredentialId: null, + jiraBaseUrl: '', + verificationResult: null, + verifyError: null, + trelloBoardId: '', + trelloBoards: [], + jiraProjectKey: '', + jiraProjects: [], + trelloBoardDetails: null, + jiraProjectDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraLabels: { ...INITIAL_JIRA_LABELS }, + jiraCostFieldId: '', + isEditing: false, + }; +} + +const wizardReducer: Reducer = (state, action) => { + switch (action.type) { + case 'SET_PROVIDER': + return { + ...createInitialState(), + provider: action.provider, + }; + case 'SET_TRELLO_API_KEY_CRED': + return { + ...state, + trelloApiKeyCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_TRELLO_TOKEN_CRED': + return { + ...state, + trelloTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_EMAIL_CRED': + return { + ...state, + jiraEmailCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_API_TOKEN_CRED': + return { + ...state, + jiraApiTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_BASE_URL': + return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; + case 'SET_VERIFICATION': + return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; + case 'SET_TRELLO_BOARDS': + return { ...state, trelloBoards: action.boards }; + case 'SET_TRELLO_BOARD_ID': + return { + ...state, + trelloBoardId: action.id, + trelloBoardDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + }; + case 'SET_JIRA_PROJECTS': + return { ...state, jiraProjects: action.projects }; + case 'SET_JIRA_PROJECT_KEY': + return { + ...state, + jiraProjectKey: action.key, + jiraProjectDetails: null, + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraCostFieldId: '', + }; + case 'SET_TRELLO_BOARD_DETAILS': + return { ...state, trelloBoardDetails: action.details }; + case 'SET_JIRA_PROJECT_DETAILS': + return { ...state, jiraProjectDetails: action.details }; + case 'SET_TRELLO_LIST_MAPPING': + return { + ...state, + trelloListMappings: { ...state.trelloListMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_LABEL_MAPPING': + return { + ...state, + trelloLabelMappings: { ...state.trelloLabelMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_COST_FIELD': + return { ...state, trelloCostFieldId: action.id }; + case 'SET_JIRA_STATUS_MAPPING': + return { + ...state, + jiraStatusMappings: { ...state.jiraStatusMappings, [action.key]: action.value }, + }; + case 'SET_JIRA_ISSUE_TYPE': + return { + ...state, + jiraIssueTypes: { ...state.jiraIssueTypes, [action.key]: action.value }, + }; + case 'SET_JIRA_LABEL': + return { + ...state, + jiraLabels: { ...state.jiraLabels, [action.key]: action.value }, + }; + case 'SET_JIRA_COST_FIELD': + return { ...state, jiraCostFieldId: action.id }; + case 'INIT_EDIT': + return { ...state, ...action.state, isEditing: true }; + default: + return state; + } +}; + +// ============================================================================ +// Wizard Step Shell +// ============================================================================ + +const STEP_TITLES = [ + 'Provider', + 'Credentials & Verification', + 'Board / Project Selection', + 'Field Mapping', + 'Webhooks', + 'Save', +] as const; + +function WizardStep({ + stepNumber, + title, + status, + isOpen, + onToggle, + children, +}: { + stepNumber: number; + title: string; + status: 'pending' | 'complete' | 'error' | 'active'; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +// ============================================================================ +// Inline Credential Creator +// ============================================================================ + +function InlineCredentialCreator({ + onCreated, +}: { + onCreated: (id: number) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [envVarKey, setEnvVarKey] = useState(''); + const [value, setValue] = useState(''); + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: async () => { + return trpcClient.credentials.create.mutate({ + name, + envVarKey, + value, + isDefault: false, + }); + }, + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: trpc.credentials.list.queryOptions().queryKey, + }); + onCreated((result as { id: number }).id); + setIsOpen(false); + setName(''); + setEnvVarKey(''); + setValue(''); + }, + }); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder="Name (e.g. My Trello Key)" + className="flex-1" + /> + setEnvVarKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_KEY" + className="flex-1" + /> +
+ setValue(e.target.value)} + placeholder="Secret value" + type="password" + /> +
+ + + {createMutation.isError && ( + + {createMutation.error.message} + + )} +
+
+ ); +} + +// ============================================================================ +// Searchable Select +// ============================================================================ + +function SearchableSelect({ + options, + value, + onChange, + placeholder, + isLoading, + error, + onRetry, +}: { + options: T[]; + value: string; + onChange: (value: string) => void; + placeholder: string; + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; +}) { + const [search, setSearch] = useState(''); + + const filtered = search + ? options.filter( + (o) => + o.value === value || + o.label.toLowerCase().includes(search.toLowerCase()) || + o.value.toLowerCase().includes(search.toLowerCase()) || + o.detail?.toLowerCase().includes(search.toLowerCase()), + ) + : options; + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+ {onRetry && ( + + )} +
+ ); + } + + return ( +
+ {options.length > 5 && ( + setSearch(e.target.value)} + placeholder="Filter..." + className="h-8 text-sm" + /> + )} + +
+ ); +} + +// ============================================================================ +// Field Mapping Row +// ============================================================================ + +function FieldMappingRow({ + slotLabel, + options, + value, + onChange, + manualFallback, +}: { + slotLabel: string; + options: Array<{ label: string; value: string }>; + value: string; + onChange: (value: string) => void; + manualFallback?: boolean; +}) { + const [isManual, setIsManual] = useState(false); + + // If the value doesn't match any option, show manual mode + const hasMatch = !value || options.some((o) => o.value === value); + const showManual = isManual || (value && !hasMatch && manualFallback); + + return ( +
+ {slotLabel} + {showManual ? ( +
+ onChange(e.target.value)} + placeholder="Enter ID manually" + className="flex-1" + /> + {manualFallback && ( + + )} +
+ ) : ( +
+ + {manualFallback && ( + + )} +
+ )} +
+ ); +} + +// ============================================================================ +// CASCADE slot key definitions +// ============================================================================ + +const TRELLO_LIST_SLOTS = [ + 'briefing', + 'stories', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', + 'debug', +]; + +const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; + +const JIRA_STATUS_SLOTS = [ + 'briefing', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', +]; + +const JIRA_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess']; + +// ============================================================================ +// Main PMWizard Component +// ============================================================================ + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard component with provider-specific branching across 6 steps +export function PMWizard({ + projectId, + initialProvider, + initialConfig, + initialCredentials, +}: { + projectId: string; + initialProvider: string; + initialConfig?: Record; + initialCredentials: Map; +}) { + const queryClient = useQueryClient(); + const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); + const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); + + const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState); + const [openSteps, setOpenSteps] = useState>(new Set([1])); + + // Initialize from existing integration + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: restoring state from two provider config shapes + useEffect(() => { + if (!initialConfig || !initialProvider) return; + + const editState: Partial = { + provider: initialProvider as Provider, + }; + + // Restore credential selections + if (initialProvider === 'trello') { + editState.trelloApiKeyCredentialId = initialCredentials.get('api_key') ?? null; + editState.trelloTokenCredentialId = initialCredentials.get('token') ?? null; + editState.trelloBoardId = (initialConfig.boardId as string) ?? ''; + + const lists = initialConfig.lists as Record | undefined; + if (lists) editState.trelloListMappings = lists; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.trelloLabelMappings = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.trelloCostFieldId = cf?.cost ?? ''; + } else if (initialProvider === 'jira') { + editState.jiraEmailCredentialId = initialCredentials.get('email') ?? null; + editState.jiraApiTokenCredentialId = initialCredentials.get('api_token') ?? null; + editState.jiraBaseUrl = (initialConfig.baseUrl as string) ?? ''; + editState.jiraProjectKey = (initialConfig.projectKey as string) ?? ''; + + const statuses = initialConfig.statuses as Record | undefined; + if (statuses) editState.jiraStatusMappings = statuses; + + const issueTypes = initialConfig.issueTypes as Record | undefined; + if (issueTypes) editState.jiraIssueTypes = issueTypes; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.jiraLabels = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.jiraCostFieldId = cf?.cost ?? ''; + } + + dispatch({ type: 'INIT_EDIT', state: editState }); + // In edit mode, open all steps + setOpenSteps(new Set([1, 2, 3, 4, 5, 6])); + }, [initialConfig, initialProvider, initialCredentials]); + + // Toggle step open/closed + const toggleStep = (step: number) => { + setOpenSteps((prev) => { + const next = new Set(prev); + if (next.has(step)) { + next.delete(step); + } else { + next.add(step); + } + return next; + }); + }; + + const advanceToStep = (step: number) => { + setOpenSteps((prev) => { + const next = new Set(prev); + next.add(step); + return next; + }); + }; + + // ---- Step status calculations ---- + + const step1Complete = !!state.provider; + + const credsReady = + state.provider === 'trello' + ? !!(state.trelloApiKeyCredentialId && state.trelloTokenCredentialId) + : !!(state.jiraEmailCredentialId && state.jiraApiTokenCredentialId && state.jiraBaseUrl); + const step2Complete = credsReady && !!state.verificationResult; + + const step3Complete = + state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; + + const step4Complete = + state.provider === 'trello' + ? Object.keys(state.trelloListMappings).length > 0 + : Object.keys(state.jiraStatusMappings).length > 0; + + // Step 5 (webhooks) is optional, always "complete" + const step5Complete = true; + + function getStatus( + stepNum: number, + complete: boolean, + ): 'pending' | 'complete' | 'error' | 'active' { + if (complete) return 'complete'; + if (openSteps.has(stepNum)) return 'active'; + return 'pending'; + } + + // ---- Mutations ---- + + const verifyMutation = useMutation({ + mutationFn: async () => { + const provider = state.provider; + if (provider === 'trello') { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyTrello.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + return { provider: 'trello' as const, result }; + } + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyJira.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + return { provider: 'jira' as const, result }; + }, + onSuccess: ({ provider, result }) => { + // Ignore if provider changed while we were verifying + if (provider !== state.provider) return; + if (provider === 'trello') { + const r = result as { username: string; fullName: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, + }); + } else { + const r = result as { displayName: string; emailAddress: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'jira', display: `${r.displayName} (${r.emailAddress})` }, + }); + } + advanceToStep(3); + }, + onError: (err) => { + dispatch({ + type: 'SET_VERIFICATION', + result: null, + error: err instanceof Error ? err.message : String(err), + }); + }, + }); + + const boardsMutation = useMutation({ + mutationFn: () => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching boards'); + } + return trpcClient.integrationsDiscovery.trelloBoards.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + }, + onSuccess: (boards) => dispatch({ type: 'SET_TRELLO_BOARDS', boards }), + }); + + const boardDetailsMutation = useMutation({ + mutationFn: (boardId: string) => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching board details'); + } + return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + boardId, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); + advanceToStep(4); + }, + }); + + const jiraProjectsMutation = useMutation({ + mutationFn: () => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching projects'); + } + return trpcClient.integrationsDiscovery.jiraProjects.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + }, + onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), + }); + + const jiraDetailsMutation = useMutation({ + mutationFn: (projectKey: string) => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching project details'); + } + return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + projectKey, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); + advanceToStep(4); + }, + }); + + // Fetch boards/projects when step 3 opens and credentials are verified + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult) return; + if ( + state.provider === 'trello' && + state.trelloBoards.length === 0 && + !boardsMutation.isPending + ) { + boardsMutation.mutate(); + } else if ( + state.provider === 'jira' && + state.jiraProjects.length === 0 && + !jiraProjectsMutation.isPending + ) { + jiraProjectsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch boards/projects list and details when credentials are present + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on edit mode state changes + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: two-provider branching with guard conditions + useEffect(() => { + if (!state.isEditing) return; + + if (state.provider === 'trello') { + if ( + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + state.trelloBoards.length === 0 && + !boardsMutation.isPending + ) { + boardsMutation.mutate(); + } + if ( + state.trelloBoardId && + !state.trelloBoardDetails && + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + !boardDetailsMutation.isPending + ) { + boardDetailsMutation.mutate(state.trelloBoardId); + } + } else if (state.provider === 'jira') { + if ( + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + state.jiraProjects.length === 0 && + !jiraProjectsMutation.isPending + ) { + jiraProjectsMutation.mutate(); + } + if ( + state.jiraProjectKey && + !state.jiraProjectDetails && + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + !jiraDetailsMutation.isPending + ) { + jiraDetailsMutation.mutate(state.jiraProjectKey); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.trelloBoardId, state.jiraProjectKey]); + + // Fetch board/project details when selection changes + const handleBoardSelect = (boardId: string) => { + dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); + if (boardId) { + boardDetailsMutation.mutate(boardId); + } + }; + + const handleProjectSelect = (key: string) => { + dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); + if (key) { + jiraDetailsMutation.mutate(key); + } + }; + + // ---- Webhook management ---- + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const [adminTokensOpen, setAdminTokensOpen] = useState(false); + const [oneTimeGithubToken, setOneTimeGithubToken] = useState(''); + const [oneTimeTrelloApiKey, setOneTimeTrelloApiKey] = useState(''); + const [oneTimeTrelloToken, setOneTimeTrelloToken] = useState(''); + const [oneTimeJiraEmail, setOneTimeJiraEmail] = useState(''); + const [oneTimeJiraApiToken, setOneTimeJiraApiToken] = useState(''); + + const buildOneTimeTokens = () => { + const tokens: Record = {}; + if (oneTimeGithubToken) tokens.github = oneTimeGithubToken; + if (oneTimeTrelloApiKey) tokens.trelloApiKey = oneTimeTrelloApiKey; + if (oneTimeTrelloToken) tokens.trelloToken = oneTimeTrelloToken; + if (oneTimeJiraEmail) tokens.jiraEmail = oneTimeJiraEmail; + if (oneTimeJiraApiToken) tokens.jiraApiToken = oneTimeJiraApiToken; + return Object.keys(tokens).length > 0 ? tokens : undefined; + }; + + const clearOneTimeTokens = () => { + setOneTimeGithubToken(''); + setOneTimeTrelloApiKey(''); + setOneTimeTrelloToken(''); + setOneTimeJiraEmail(''); + setOneTimeJiraApiToken(''); + }; + + const createWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId, + callbackBaseUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + oneTimeTokens: buildOneTimeTokens(), + }), + onSuccess: () => { + clearOneTimeTokens(); + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteWebhookMutation = useMutation({ + mutationFn: (deleteCallbackBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId, + callbackBaseUrl: deleteCallbackBaseUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + oneTimeTokens: buildOneTimeTokens(), + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + // ---- Save ---- + + const saveMutation = useMutation({ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential linking + mutationFn: async () => { + let config: Record; + if (state.provider === 'trello') { + config = { + boardId: state.trelloBoardId, + lists: state.trelloListMappings, + labels: state.trelloLabelMappings, + ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), + }; + } else { + config = { + projectKey: state.jiraProjectKey, + baseUrl: state.jiraBaseUrl, + statuses: state.jiraStatusMappings, + ...(Object.keys(state.jiraIssueTypes).length > 0 + ? { issueTypes: state.jiraIssueTypes } + : {}), + ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), + ...(state.jiraCostFieldId ? { customFields: { cost: state.jiraCostFieldId } } : {}), + }; + } + + const result = await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'pm', + provider: state.provider, + config, + }); + + // Set credentials + const credPairs: Array<{ role: string; credentialId: number }> = + state.provider === 'trello' + ? [ + ...(state.trelloApiKeyCredentialId + ? [{ role: 'api_key', credentialId: state.trelloApiKeyCredentialId }] + : []), + ...(state.trelloTokenCredentialId + ? [{ role: 'token', credentialId: state.trelloTokenCredentialId }] + : []), + ] + : [ + ...(state.jiraEmailCredentialId + ? [{ role: 'email', credentialId: state.jiraEmailCredentialId }] + : []), + ...(state.jiraApiTokenCredentialId + ? [{ role: 'api_token', credentialId: state.jiraApiTokenCredentialId }] + : []), + ]; + + for (const { role, credentialId } of credPairs) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'pm', + role, + credentialId, + }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'pm', + }).queryKey, + }); + }, + }); + + // ---- Active webhooks for this provider ---- + const activeWebhooks = + state.provider === 'trello' + ? (webhooksQuery.data?.trello ?? []).map((w) => ({ + id: String(w.id), + url: w.callbackURL, + active: w.active, + })) + : (webhooksQuery.data?.jira ?? []).map((w) => ({ + id: String(w.id), + url: w.url, + active: w.enabled, + })); + + // ---- Render ---- + + return ( +
+ {/* Step 1: Provider */} + toggleStep(1)} + > +
+ +
+ {(['trello', 'jira'] as const).map((p) => ( + + ))} +
+
+
+ + {/* Step 2: Credentials & Verification */} + toggleStep(2)} + > + {state.provider === 'trello' ? ( +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_API_KEY_CRED', id })} + /> +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_TOKEN_CRED', id })} + /> +
+
+ ) : ( +
+
+ + dispatch({ type: 'SET_JIRA_BASE_URL', url: e.target.value })} + placeholder="https://your-instance.atlassian.net" + /> +
+
+ + + dispatch({ type: 'SET_JIRA_EMAIL_CRED', id })} + /> +
+
+ + + dispatch({ type: 'SET_JIRA_API_TOKEN_CRED', id })} + /> +
+
+ )} + +
+ + {state.verificationResult && ( +
+ + Connected as {state.verificationResult.display} +
+ )} + {state.verifyError && ( +
+ + {state.verifyError} +
+ )} +
+
+ + {/* Step 3: Board / Project Selection */} + toggleStep(3)} + > + {state.provider === 'trello' ? ( +
+ + ({ + label: b.name, + value: b.id, + detail: b.url.split('/').pop(), + }))} + value={state.trelloBoardId} + onChange={handleBoardSelect} + placeholder="Select a Trello board..." + isLoading={boardsMutation.isPending} + error={boardsMutation.isError ? boardsMutation.error.message : null} + onRetry={() => boardsMutation.mutate()} + /> + {state.trelloBoardId && boardDetailsMutation.isPending && ( +
+ Loading board details... +
+ )} +
+ ) : ( +
+ + ({ + label: p.name, + value: p.key, + detail: p.key, + }))} + value={state.jiraProjectKey} + onChange={handleProjectSelect} + placeholder="Select a JIRA project..." + isLoading={jiraProjectsMutation.isPending} + error={jiraProjectsMutation.isError ? jiraProjectsMutation.error.message : null} + onRetry={() => jiraProjectsMutation.mutate()} + /> + {state.jiraProjectKey && jiraDetailsMutation.isPending && ( +
+ Loading project details... +
+ )} +
+ )} +
+ + {/* Step 4: Field Mapping */} + toggleStep(4)} + > + {state.provider === 'trello' ? ( +
+ {/* List mappings */} +
+ +

+ Map each CASCADE stage to a Trello list on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LIST_SLOTS.map((slot) => ( + ({ + label: l.name, + value: l.id, + })) ?? [] + } + value={state.trelloListMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LIST_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate list options. +

+ )} +
+ + {/* Label mappings */} +
+ +

+ Map each CASCADE label to a Trello label on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LABEL_SLOTS.map((slot) => ( + l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + })) ?? [] + } + value={state.trelloLabelMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LABEL_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate label options. +

+ )} +
+ + {/* Cost custom field */} +
+ + {state.trelloBoardDetails ? ( + f.type === 'number') + .map((f) => ({ + label: f.name, + value: f.id, + }))} + value={state.trelloCostFieldId} + onChange={(v) => dispatch({ type: 'SET_TRELLO_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_TRELLO_COST_FIELD', + id: e.target.value, + }) + } + placeholder="Custom field ID for cost tracking" + /> + )} +
+
+ ) : ( +
+ {/* Status mappings */} +
+ +

+ Map each CASCADE status to a JIRA status in the project. +

+ {state.jiraProjectDetails ? ( + JIRA_STATUS_SLOTS.map((slot) => ( + ({ + label: s.name, + value: s.name, + })) ?? [] + } + value={state.jiraStatusMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_STATUS_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a project first to populate status options. +

+ )} +
+ + {/* Issue types */} +
+ +

+ Map CASCADE issue types. Typically "task" for the main type and + "subtask" for sub-tasks. +

+ {state.jiraProjectDetails ? ( + <> + !t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.task ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'task', + value: v, + }) + } + manualFallback + /> + t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.subtask ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'subtask', + value: v, + }) + } + manualFallback + /> + + ) : ( +

Select a project first.

+ )} +
+ + {/* Labels */} +
+ +

+ CASCADE label names used in JIRA. These are created automatically by CASCADE. +

+ {JIRA_LABEL_SLOTS.map((slot) => ( +
+ {slot} + + dispatch({ + type: 'SET_JIRA_LABEL', + key: slot, + value: e.target.value, + }) + } + placeholder={`JIRA label for ${slot}`} + className="flex-1" + /> +
+ ))} +
+ + {/* Cost custom field */} +
+ + {state.jiraProjectDetails ? ( + ({ + label: `${f.name} (${f.id})`, + value: f.id, + }))} + value={state.jiraCostFieldId} + onChange={(v) => dispatch({ type: 'SET_JIRA_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_JIRA_COST_FIELD', + id: e.target.value, + }) + } + placeholder="e.g., customfield_10042" + /> + )} +
+
+ )} +
+ + {/* Step 5: Webhooks */} + toggleStep(5)} + > +
+ {/* Per-provider errors */} + {webhooksQuery.data?.errors && + Object.entries(webhooksQuery.data.errors) + .filter(([, err]) => err != null) + .map(([provider, err]) => ( +
+ +
+ + {provider} + + : {String(err)} +
+ +
+ ))} + + {webhooksQuery.isLoading ? ( +
+ Loading webhooks... +
+ ) : activeWebhooks.length > 0 ? ( +
+ + {activeWebhooks.map((w) => ( +
+
+ + {w.url} +
+ +
+ ))} +
+ ) : ( +
+ + No {state.provider === 'trello' ? 'Trello' : 'JIRA'} webhooks configured for this + project. +
+ )} + +
+
+ +
+

+ Callback URL:{' '} + + {callbackBaseUrl}/{state.provider === 'trello' ? 'trello' : 'jira'}/webhook + +

+ {createWebhookMutation.isError && ( +

{createWebhookMutation.error.message}

+ )} + {createWebhookMutation.isSuccess && ( +

+ {webhooksQuery.data?.errors && + Object.values(webhooksQuery.data.errors).some((e) => e != null) + ? 'Webhook created, but some providers failed to load — see warnings above.' + : 'Webhook created successfully.'} +

+ )} +
+ + {/* One-time admin credentials */} +
+ + {adminTokensOpen && ( +
+

+ Provide tokens with elevated permissions for webhook management. These are used + once and never saved. +

+ {/* GitHub — always shown */} +
+ + setOneTimeGithubToken(e.target.value)} + placeholder="ghp_... — used once, not saved" + type="password" + className="h-8 text-sm" + /> +
+ {/* PM-provider-specific fields */} + {state.provider === 'trello' ? ( + <> +
+ + setOneTimeTrelloApiKey(e.target.value)} + placeholder="One-time API key" + type="password" + className="h-8 text-sm" + /> +
+
+ + setOneTimeTrelloToken(e.target.value)} + placeholder="One-time token" + type="password" + className="h-8 text-sm" + /> +
+ + ) : ( + <> +
+ + setOneTimeJiraEmail(e.target.value)} + placeholder="user@example.com" + className="h-8 text-sm" + /> +
+
+ + setOneTimeJiraApiToken(e.target.value)} + placeholder="One-time API token" + type="password" + className="h-8 text-sm" + /> +
+ + )} +
+ )} +
+
+
+ + {/* Step 6: Save */} + toggleStep(6)} + > +
+ {/* Summary */} +
+
+ Provider + {state.provider === 'trello' ? 'Trello' : 'JIRA'} +
+ {state.verificationResult && ( +
+ Identity + {state.verificationResult.display} +
+ )} +
+ + {state.provider === 'trello' ? 'Board' : 'Project'} + + + {state.provider === 'trello' + ? state.trelloBoards.find((b) => b.id === state.trelloBoardId)?.name || + state.trelloBoardId + : state.jiraProjects.find((p) => p.key === state.jiraProjectKey)?.name || + state.jiraProjectKey} + +
+
+ + {state.provider === 'trello' ? 'Lists mapped' : 'Statuses mapped'} + + + {state.provider === 'trello' + ? Object.keys(state.trelloListMappings).filter((k) => state.trelloListMappings[k]) + .length + : Object.keys(state.jiraStatusMappings).filter((k) => state.jiraStatusMappings[k]) + .length} + +
+
+ +

+ Trigger configuration is managed separately in the Agent Configs tab. +

+ +
+ + {saveMutation.isSuccess && ( + Integration saved successfully. + )} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
+
+
+
+ ); +}