From 41e668dbd01c0e69130c1548179823b4e69b2463 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 16:46:34 +0000 Subject: [PATCH 1/4] refactor(router): extract webhook handlers into dedicated modules --- src/router/github.ts | 212 +++++++++ src/router/index.ts | 565 +---------------------- src/router/jira.ts | 167 +++++++ src/router/trello.ts | 264 +++++++++++ src/router/webhookParsing.ts | 39 ++ src/server.ts | 35 +- tests/unit/router/github.test.ts | 229 +++++++++ tests/unit/router/jira.test.ts | 218 +++++++++ tests/unit/router/trello.test.ts | 316 +++++++++++++ tests/unit/router/webhookParsing.test.ts | 85 ++++ 10 files changed, 1549 insertions(+), 581 deletions(-) create mode 100644 src/router/github.ts create mode 100644 src/router/jira.ts create mode 100644 src/router/trello.ts create mode 100644 src/router/webhookParsing.ts create mode 100644 tests/unit/router/github.test.ts create mode 100644 tests/unit/router/jira.test.ts create mode 100644 tests/unit/router/trello.test.ts create mode 100644 tests/unit/router/webhookParsing.test.ts diff --git a/src/router/github.ts b/src/router/github.ts new file mode 100644 index 00000000..d5b3ba32 --- /dev/null +++ b/src/router/github.ts @@ -0,0 +1,212 @@ +/** + * GitHub webhook handler for the router (multi-container) deployment mode. + * + * Handles webhook parsing, self-comment filtering, ack posting, pre-actions, + * and job queuing for GitHub webhook events. + */ + +import { INITIAL_MESSAGES } from '../config/agentMessages.js'; +import { findProjectByRepo } from '../config/provider.js'; +import { + type PersonaIdentities, + isCascadeBot, + resolvePersonaIdentities, +} from '../github/personas.js'; +import type { TriggerRegistry } from '../triggers/registry.js'; +import type { TriggerContext } from '../types/index.js'; +import { postGitHubAck, resolveGitHubTokenForAck } from './acknowledgments.js'; +import { loadProjectConfig } from './config.js'; +import { extractPRNumber } from './notifications.js'; +import { addEyesReactionToPR } from './pre-actions.js'; +import { type CascadeJob, type GitHubJob, addJob } from './queue.js'; +import { sendAcknowledgeReaction } from './reactions.js'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Try to match a trigger and post an ack comment for a GitHub webhook. + * Returns the ack comment ID if posted, undefined otherwise. + */ +export async function tryPostGitHubAck( + eventType: string, + repoFullName: string, + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.repo === repoFullName); + if (!fullProject) return undefined; + + let personaIdentities: PersonaIdentities | undefined; + try { + personaIdentities = await resolvePersonaIdentities(fullProject.id); + } catch { + // Persona resolution may fail — proceed without ack + } + + const ctx: TriggerContext = { + project: fullProject, + source: 'github', + payload, + personaIdentities, + }; + const match = triggerRegistry.matchTrigger(ctx); + if (!match) return undefined; + + const message = INITIAL_MESSAGES[match.agentType]; + if (!message) return undefined; + + const resolved = await resolveGitHubTokenForAck(repoFullName); + if (!resolved) return undefined; + + const tempJob = { eventType, repoFullName, payload } as GitHubJob; + const prNumber = extractPRNumber(tempJob); + if (!prNumber) return undefined; + + const commentId = await postGitHubAck(repoFullName, prNumber, message, resolved.token); + return commentId ?? undefined; +} + +export async function isSelfAuthoredGitHubComment( + payload: unknown, + repoFullName: string, +): Promise { + const p = payload as Record; + const commentUser = (p.comment as Record | undefined)?.user as + | Record + | undefined; + const login = commentUser?.login as string | undefined; + if (!login) return false; + try { + const project = await findProjectByRepo(repoFullName); + if (!project) return false; + const personas = await resolvePersonaIdentities(project.id); + return isCascadeBot(login, personas); + } catch { + return false; // Persona resolution failed — proceed normally + } +} + +export function fireGitHubAckReaction(repoFullName: string, payload: unknown): void { + void (async () => { + try { + const project = await findProjectByRepo(repoFullName); + if (!project) { + console.warn('[Router] No project found for repo, skipping GitHub reaction', { + repoFullName, + }); + return; + } + const personaIdentities = await resolvePersonaIdentities(project.id); + await sendAcknowledgeReaction('github', repoFullName, payload, personaIdentities, project); + } catch (err) { + console.warn('[Router] GitHub reaction error:', String(err)); + } + })(); +} + +/** + * Fire non-blocking pre-actions for a GitHub job before it is queued. + * Currently adds a 👀 reaction for first-time check_suite success events. + */ +export function firePreActions(job: GitHubJob, p: Record): void { + if (job.eventType !== 'check_suite') return; + const suite = p.check_suite as Record | undefined; + const action = p.action as string | undefined; + const conclusion = suite?.conclusion as string | undefined; + const prs = suite?.pull_requests as Array | undefined; + if (action === 'completed' && conclusion === 'success' && prs && prs.length > 0) { + addEyesReactionToPR(job).catch((err) => + console.warn('[Router] Pre-action error (eyes reaction):', String(err)), + ); + } +} + +export async function processGitHubWebhookEvent( + eventType: string, + repoFullName: string, + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise { + const isCommentEvent = + eventType === 'issue_comment' || eventType === 'pull_request_review_comment'; + + if (isCommentEvent && (await isSelfAuthoredGitHubComment(payload, repoFullName))) { + console.log('[Router] Ignoring self-authored GitHub comment'); + return; + } + + console.log('[Router] Queueing GitHub job:', { eventType, repoFullName }); + + // Fire-and-forget acknowledgment reaction — only for comment events that @mention the bot + if (isCommentEvent) { + fireGitHubAckReaction(repoFullName, payload); + } + + // Try to post an ack comment via trigger matching (non-blocking best-effort) + let ackCommentId: number | undefined; + try { + ackCommentId = await tryPostGitHubAck(eventType, repoFullName, payload, triggerRegistry); + } catch (err) { + console.warn('[Router] GitHub ack comment failed (non-fatal):', String(err)); + } + + const job: CascadeJob = { + type: 'github', + source: 'github', + payload, + eventType, + repoFullName, + receivedAt: new Date().toISOString(), + ackCommentId, + }; + + // Fire pre-actions (non-blocking) before queueing + const p = payload as Record; + firePreActions(job as GitHubJob, p); + + try { + const jobId = await addJob(job); + console.log('[Router] GitHub job queued:', { jobId, eventType, ackCommentId }); + } catch (err) { + console.error('[Router] Failed to queue GitHub job:', err); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const PROCESSABLE_EVENTS = [ + 'pull_request', + 'pull_request_review', + 'pull_request_review_comment', + 'issue_comment', + 'check_suite', +]; + +/** + * Handle a POST /github/webhook request. + * Parses the payload, filters irrelevant events, and queues a job. + */ +export async function handleGitHubWebhook( + eventType: string, + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise<{ shouldProcess: boolean; repoFullName: string }> { + const p = payload as Record; + const repo = p.repository as Record | undefined; + const repoFullName = (repo?.full_name as string) || 'unknown'; + + const shouldProcess = PROCESSABLE_EVENTS.includes(eventType); + + if (shouldProcess) { + await processGitHubWebhookEvent(eventType, repoFullName, payload, triggerRegistry); + } else { + console.log('[Router] Ignoring GitHub event:', eventType); + } + + return { shouldProcess, repoFullName }; +} diff --git a/src/router/index.ts b/src/router/index.ts index e9f8d0fc..d492abf1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,30 +1,14 @@ import { serve } from '@hono/node-server'; -import type { Context } from 'hono'; import { Hono } from 'hono'; -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; -import { findProjectByRepo } from '../config/provider.js'; -import { - type PersonaIdentities, - isCascadeBot, - resolvePersonaIdentities, -} from '../github/personas.js'; import { registerBuiltInTriggers } from '../triggers/builtins.js'; import { createTriggerRegistry } from '../triggers/registry.js'; -import type { TriggerContext } from '../types/index.js'; import { logWebhookCall } from '../utils/webhookLogger.js'; -import { - postGitHubAck, - postJiraAck, - postTrelloAck, - resolveGitHubTokenForAck, - resolveJiraBotAccountId, - resolveTrelloBotMemberId, -} from './acknowledgments.js'; -import { type RouterProjectConfig, loadProjectConfig } from './config.js'; -import { extractPRNumber } from './notifications.js'; -import { addEyesReactionToPR } from './pre-actions.js'; -import { type CascadeJob, type GitHubJob, addJob, getQueueStats } from './queue.js'; -import { sendAcknowledgeReaction } from './reactions.js'; +import { handleGitHubWebhook } from './github.js'; +import { handleJiraWebhook } from './jira.js'; +import { getQueueStats } from './queue.js'; +import { handleTrelloWebhook } from './trello.js'; +import { parseTrelloWebhook } from './trello.js'; +import { extractRawHeaders, parseGitHubWebhookPayload } from './webhookParsing.js'; import { getActiveWorkerCount, getActiveWorkers, @@ -36,447 +20,6 @@ import { const triggerRegistry = createTriggerRegistry(); registerBuiltInTriggers(triggerRegistry); -/** - * Check if filename matches agent log pattern: {agent-type}-{timestamp}.zip - * Examples: implementation-2026-01-02T16-30-24-339Z.zip, briefing-timeout-2026-01-02T12-34-56-789Z.zip - */ -function isAgentLogFilename(filename: string): boolean { - return /^[a-z]+(?:-timeout)?-[\d-TZ]+\.zip$/i.test(filename); -} - -function isCardInTriggerList( - actionType: string, - data: Record | undefined, - project: RouterProjectConfig, -): boolean { - if (!project.trello) return false; - const triggerLists = [ - project.trello.lists.briefing, - project.trello.lists.planning, - project.trello.lists.todo, - ]; - - // Card moved into a trigger list - if (actionType === 'updateCard' && data?.listAfter) { - const listAfter = data.listAfter as Record; - const listId = listAfter.id as string; - if (triggerLists.includes(listId)) { - console.log(`[Router] Card moved to trigger list: ${listId}`); - return true; - } - } - - // Card created directly in a trigger list - if (actionType === 'createCard' && data?.list) { - const list = data.list as Record; - const listId = list.id as string; - if (triggerLists.includes(listId)) { - console.log(`[Router] Card created in trigger list: ${listId}`); - return true; - } - } - - return false; -} - -function isReadyToProcessLabelAdded( - actionType: string, - data: Record | undefined, - project: RouterProjectConfig, -): boolean { - if (actionType !== 'addLabelToCard' || !data?.label) return false; - if (!project.trello) return false; - - const label = data.label as Record; - const labelId = label.id as string; - - if (labelId === project.trello.labels.readyToProcess) { - console.log('[Router] Ready-to-process label added'); - return true; - } - return false; -} - -function isAgentLogAttachmentUploaded( - actionType: string, - data: Record | undefined, - project: RouterProjectConfig, -): boolean { - if (actionType !== 'addAttachmentToCard' || !data?.attachment) return false; - if (!project.trello?.lists.debug) return false; - - const attachment = data.attachment as Record; - const name = attachment.name as string | undefined; - - if (name && isAgentLogFilename(name) && !name.startsWith('debug-')) { - console.log(`[Router] Agent log attachment uploaded: ${name}`); - return true; - } - return false; -} - -interface TrelloWebhookResult { - shouldProcess: boolean; - project?: RouterProjectConfig; - projectId?: string; - actionType?: string; - cardId?: string; -} - -async function parseTrelloWebhook(payload: unknown): Promise { - if (!payload || typeof payload !== 'object') { - return { shouldProcess: false }; - } - - const p = payload as Record; - const action = p.action as Record | undefined; - const model = p.model as Record | undefined; - - if (!action || !model) { - return { shouldProcess: false }; - } - - const boardId = model.id as string; - const actionType = action.type as string; - const data = action.data as Record | undefined; - - const config = await loadProjectConfig(); - const project = config.projects.find((proj) => proj.trello?.boardId === boardId); - if (!project) { - return { shouldProcess: false }; - } - - // Extract card ID - const card = data?.card as Record | undefined; - const cardId = card?.id as string | undefined; - - const shouldProcess = - isCardInTriggerList(actionType, data, project) || - isReadyToProcessLabelAdded(actionType, data, project) || - isAgentLogAttachmentUploaded(actionType, data, project) || - actionType === 'commentCard'; - - return { shouldProcess, project, projectId: project.id, actionType, cardId }; -} - -/** - * Try to match a trigger and post an ack comment for a Trello webhook. - * Returns the ack comment ID if posted, undefined otherwise. - */ -async function tryPostTrelloAck( - projectId: string, - cardId: string, - payload: unknown, -): Promise { - const config = await loadProjectConfig(); - const fullProject = config.fullProjects.find((fp) => fp.id === projectId); - if (!fullProject) return undefined; - - const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; - const match = triggerRegistry.matchTrigger(ctx); - if (!match) return undefined; - - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; - - const commentId = await postTrelloAck(projectId, cardId, message); - return commentId ?? undefined; -} - -async function isSelfAuthoredTrelloComment(payload: unknown, projectId: string): Promise { - const action = (payload as Record).action as Record | undefined; - const commentAuthorId = action?.idMemberCreator as string | undefined; - if (!commentAuthorId) return false; - try { - const botId = await resolveTrelloBotMemberId(projectId); - return !!botId && commentAuthorId === botId; - } catch { - return false; // Identity resolution failed — proceed normally - } -} - -async function processTrelloWebhookEvent( - project: RouterProjectConfig, - cardId: string, - actionType: string, - payload: unknown, -): Promise { - if (actionType === 'commentCard' && (await isSelfAuthoredTrelloComment(payload, project.id))) { - console.log('[Router] Ignoring self-authored Trello comment'); - return; - } - - console.log('[Router] Queueing Trello job:', { actionType, cardId, projectId: project.id }); - - // Fire-and-forget acknowledgment reaction — only for comment actions - if (actionType === 'commentCard') { - void sendAcknowledgeReaction('trello', project.id, payload).catch((err) => - console.error('[Router] Trello reaction error:', err), - ); - } - - // Try to post an ack comment via trigger matching (non-blocking best-effort) - let ackCommentId: string | undefined; - try { - ackCommentId = await tryPostTrelloAck(project.id, cardId, payload); - } catch (err) { - console.warn('[Router] Trello ack comment failed (non-fatal):', String(err)); - } - - const job: CascadeJob = { - type: 'trello', - source: 'trello', - payload, - projectId: project.id, - cardId, - actionType: actionType || 'unknown', - receivedAt: new Date().toISOString(), - ackCommentId, - }; - - try { - const jobId = await addJob(job); - console.log('[Router] Trello job queued:', { jobId, actionType, ackCommentId }); - } catch (err) { - console.error('[Router] Failed to queue Trello job:', err); - // Still return to caller — Trello gets 200 to avoid retries - } -} - -/** - * Try to match a trigger and post an ack comment for a GitHub webhook. - * Returns the ack comment ID if posted, undefined otherwise. - */ -async function tryPostGitHubAck( - eventType: string, - repoFullName: string, - payload: unknown, -): Promise { - const config = await loadProjectConfig(); - const fullProject = config.fullProjects.find((fp) => fp.repo === repoFullName); - if (!fullProject) return undefined; - - let personaIdentities: PersonaIdentities | undefined; - try { - personaIdentities = await resolvePersonaIdentities(fullProject.id); - } catch { - // Persona resolution may fail — proceed without ack - } - - const ctx: TriggerContext = { - project: fullProject, - source: 'github', - payload, - personaIdentities, - }; - const match = triggerRegistry.matchTrigger(ctx); - if (!match) return undefined; - - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; - - const resolved = await resolveGitHubTokenForAck(repoFullName); - if (!resolved) return undefined; - - const tempJob = { eventType, repoFullName, payload } as GitHubJob; - const prNumber = extractPRNumber(tempJob); - if (!prNumber) return undefined; - - const commentId = await postGitHubAck(repoFullName, prNumber, message, resolved.token); - return commentId ?? undefined; -} - -async function isSelfAuthoredGitHubComment( - payload: unknown, - repoFullName: string, -): Promise { - const p = payload as Record; - const commentUser = (p.comment as Record | undefined)?.user as - | Record - | undefined; - const login = commentUser?.login as string | undefined; - if (!login) return false; - try { - const project = await findProjectByRepo(repoFullName); - if (!project) return false; - const personas = await resolvePersonaIdentities(project.id); - return isCascadeBot(login, personas); - } catch { - return false; // Persona resolution failed — proceed normally - } -} - -function fireGitHubAckReaction(repoFullName: string, payload: unknown): void { - void (async () => { - try { - const project = await findProjectByRepo(repoFullName); - if (!project) { - console.warn('[Router] No project found for repo, skipping GitHub reaction', { - repoFullName, - }); - return; - } - const personaIdentities = await resolvePersonaIdentities(project.id); - await sendAcknowledgeReaction('github', repoFullName, payload, personaIdentities, project); - } catch (err) { - console.warn('[Router] GitHub reaction error:', String(err)); - } - })(); -} - -async function processGitHubWebhookEvent( - eventType: string, - repoFullName: string, - payload: unknown, -): Promise { - const isCommentEvent = - eventType === 'issue_comment' || eventType === 'pull_request_review_comment'; - - if (isCommentEvent && (await isSelfAuthoredGitHubComment(payload, repoFullName))) { - console.log('[Router] Ignoring self-authored GitHub comment'); - return; - } - - console.log('[Router] Queueing GitHub job:', { eventType, repoFullName }); - - // Fire-and-forget acknowledgment reaction — only for comment events that @mention the bot - if (isCommentEvent) { - fireGitHubAckReaction(repoFullName, payload); - } - - // Try to post an ack comment via trigger matching (non-blocking best-effort) - let ackCommentId: number | undefined; - try { - ackCommentId = await tryPostGitHubAck(eventType, repoFullName, payload); - } catch (err) { - console.warn('[Router] GitHub ack comment failed (non-fatal):', String(err)); - } - - const job: CascadeJob = { - type: 'github', - source: 'github', - payload, - eventType, - repoFullName, - receivedAt: new Date().toISOString(), - ackCommentId, - }; - - // Fire pre-actions (non-blocking) before queueing - const p = payload as Record; - firePreActions(job as GitHubJob, p); - - try { - const jobId = await addJob(job); - console.log('[Router] GitHub job queued:', { jobId, eventType, ackCommentId }); - } catch (err) { - console.error('[Router] Failed to queue GitHub job:', err); - } -} - -/** - * Try to match a trigger and post an ack comment for a JIRA webhook. - * Returns the ack comment ID if posted, undefined otherwise. - */ -async function tryPostJiraAck( - projectId: string, - issueKey: string, - payload: unknown, - fullProjects: import('../types/index.js').ProjectConfig[], -): Promise { - const fullProject = fullProjects.find((fp) => fp.id === projectId); - if (!fullProject || !issueKey) return undefined; - - const ctx: TriggerContext = { project: fullProject, source: 'jira', payload }; - const match = triggerRegistry.matchTrigger(ctx); - if (!match) return undefined; - - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; - - const commentId = await postJiraAck(projectId, issueKey, message); - return commentId ?? undefined; -} - -async function isSelfAuthoredJiraComment( - webhookEvent: string, - payload: unknown, - projectId: string, -): Promise { - if (!webhookEvent.startsWith('comment_')) return false; - const p = payload as Record; - const comment = p.comment as Record | undefined; - const author = comment?.author as Record | undefined; - const commentAuthorId = author?.accountId as string | undefined; - if (!commentAuthorId) return false; - try { - const botId = await resolveJiraBotAccountId(projectId); - return !!botId && commentAuthorId === botId; - } catch { - return false; // Identity resolution failed — proceed normally - } -} - -/** - * Fire non-blocking pre-actions for a GitHub job before it is queued. - * Currently adds a 👀 reaction for first-time check_suite success events. - */ -function firePreActions(job: GitHubJob, p: Record): void { - if (job.eventType !== 'check_suite') return; - const suite = p.check_suite as Record | undefined; - const action = p.action as string | undefined; - const conclusion = suite?.conclusion as string | undefined; - const prs = suite?.pull_requests as Array | undefined; - if (action === 'completed' && conclusion === 'success' && prs && prs.length > 0) { - addEyesReactionToPR(job).catch((err) => - console.warn('[Router] Pre-action error (eyes reaction):', String(err)), - ); - } -} - -async function queueJiraJob( - project: RouterProjectConfig, - issueKey: string, - webhookEvent: string, - payload: unknown, - fullProjects: import('../types/index.js').ProjectConfig[], -): Promise { - console.log('[Router] Queueing JIRA job:', { webhookEvent, issueKey, projectId: project.id }); - - // Fire-and-forget acknowledgment reaction — only for comment events - if (webhookEvent.startsWith('comment_')) { - void sendAcknowledgeReaction('jira', project.id, payload).catch((err) => - console.error('[Router] JIRA reaction error:', err), - ); - } - - // Try to post an ack comment via trigger matching (non-blocking best-effort) - let ackCommentId: string | undefined; - try { - ackCommentId = await tryPostJiraAck(project.id, issueKey, payload, fullProjects); - } catch (err) { - console.warn('[Router] JIRA ack comment failed (non-fatal):', String(err)); - } - - const job: CascadeJob = { - type: 'jira', - source: 'jira', - payload, - projectId: project.id, - issueKey, - webhookEvent, - receivedAt: new Date().toISOString(), - ackCommentId, - }; - - try { - const jobId = await addJob(job); - console.log('[Router] JIRA job queued:', { jobId, webhookEvent, ackCommentId }); - } catch (err) { - console.error('[Router] Failed to queue JIRA job:', err); - } -} - const app = new Hono(); // Health check with queue stats @@ -498,9 +41,7 @@ app.on(['HEAD', 'GET'], '/trello/webhook', (c) => { // Trello webhook handler app.post('/trello/webhook', async (c) => { - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); let payload: unknown; try { payload = await c.req.json(); @@ -530,36 +71,11 @@ app.post('/trello/webhook', async (c) => { processed: shouldProcess && !!project && !!cardId, }); - if (shouldProcess && project && cardId) { - await processTrelloWebhookEvent(project, cardId, actionType || 'unknown', payload); - } else { - console.log(`[Router] Ignoring Trello: ${actionType || 'unknown'}`); - } + await handleTrelloWebhook(payload, triggerRegistry); return c.text('OK', 200); }); -type PayloadParseResult = { ok: true; payload: unknown } | { ok: false; error: string }; - -async function parseGitHubWebhookPayload( - c: Context, - contentType: string, -): Promise { - try { - if (contentType.includes('application/x-www-form-urlencoded')) { - const formData = await c.req.parseBody(); - const payloadStr = formData.payload; - if (typeof payloadStr === 'string') { - return { ok: true, payload: JSON.parse(payloadStr) }; - } - throw new Error('Missing payload field in form data'); - } - return { ok: true, payload: await c.req.json() }; - } catch (err) { - return { ok: false, error: String(err) }; - } -} - // GitHub webhook verification app.get('/github/webhook', (c) => { return c.text('OK', 200); @@ -569,9 +85,7 @@ app.get('/github/webhook', (c) => { app.post('/github/webhook', async (c) => { const eventType = c.req.header('X-GitHub-Event') || 'unknown'; const contentType = c.req.header('Content-Type') || ''; - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); const parseResult = await parseGitHubWebhookPayload(c, contentType); if (!parseResult.ok) { @@ -594,20 +108,7 @@ app.post('/github/webhook', async (c) => { } const payload = parseResult.payload; - // Extract repo info - const p = payload as Record; - const repo = p.repository as Record | undefined; - const repoFullName = (repo?.full_name as string) || 'unknown'; - - // Determine if we should process this event - const processableEvents = [ - 'pull_request', - 'pull_request_review', - 'pull_request_review_comment', - 'issue_comment', - 'check_suite', - ]; - const shouldProcess = processableEvents.includes(eventType); + const { shouldProcess } = await handleGitHubWebhook(eventType, payload, triggerRegistry); logWebhookCall({ source: 'github', @@ -620,12 +121,6 @@ app.post('/github/webhook', async (c) => { processed: shouldProcess, }); - if (shouldProcess) { - await processGitHubWebhookEvent(eventType, repoFullName, payload); - } else { - console.log('[Router] Ignoring GitHub event:', eventType); - } - return c.text('OK', 200); }); @@ -636,9 +131,7 @@ app.get('/jira/webhook', (c) => { // JIRA webhook handler app.post('/jira/webhook', async (c) => { - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); let payload: unknown; try { payload = await c.req.json(); @@ -654,28 +147,10 @@ app.post('/jira/webhook', async (c) => { return c.text('Bad Request', 400); } - const p = payload as Record; - const webhookEvent = (p.webhookEvent as string) || ''; - const issue = p.issue as Record | undefined; - const issueKey = (issue?.key as string) || ''; - const fields = issue?.fields as Record | undefined; - const projectField = fields?.project as Record | undefined; - const jiraProjectKey = (projectField?.key as string) || ''; - - // Match JIRA project key to a configured project - const config = await loadProjectConfig(); - const project = jiraProjectKey - ? config.projects.find((proj) => proj.jira?.projectKey === jiraProjectKey) - : undefined; - - // Process issue transitions and comment events - const processableEvents = [ - 'jira:issue_updated', - 'jira:issue_created', - 'comment_created', - 'comment_updated', - ]; - const shouldProcess = project && processableEvents.some((e) => webhookEvent.startsWith(e)); + const { shouldProcess, project, webhookEvent } = await handleJiraWebhook( + payload, + triggerRegistry, + ); logWebhookCall({ source: 'jira', @@ -689,16 +164,6 @@ app.post('/jira/webhook', async (c) => { processed: !!shouldProcess, }); - if (shouldProcess && project) { - if (await isSelfAuthoredJiraComment(webhookEvent, payload, project.id)) { - console.log('[Router] Ignoring self-authored JIRA comment'); - } else { - await queueJiraJob(project, issueKey, webhookEvent, payload, config.fullProjects); - } - } else { - console.log(`[Router] Ignoring JIRA: ${webhookEvent}`); - } - return c.text('OK', 200); }); diff --git a/src/router/jira.ts b/src/router/jira.ts new file mode 100644 index 00000000..576110f1 --- /dev/null +++ b/src/router/jira.ts @@ -0,0 +1,167 @@ +/** + * JIRA webhook handler for the router (multi-container) deployment mode. + * + * Handles webhook parsing, self-comment filtering, ack posting, and job queuing + * for JIRA webhook events. + */ + +import { INITIAL_MESSAGES } from '../config/agentMessages.js'; +import type { TriggerRegistry } from '../triggers/registry.js'; +import type { ProjectConfig, TriggerContext } from '../types/index.js'; +import { postJiraAck, resolveJiraBotAccountId } from './acknowledgments.js'; +import { type RouterProjectConfig, loadProjectConfig } from './config.js'; +import { type CascadeJob, addJob } from './queue.js'; +import { sendAcknowledgeReaction } from './reactions.js'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Try to match a trigger and post an ack comment for a JIRA webhook. + * Returns the ack comment ID if posted, undefined otherwise. + */ +export async function tryPostJiraAck( + projectId: string, + issueKey: string, + payload: unknown, + fullProjects: ProjectConfig[], + triggerRegistry: TriggerRegistry, +): Promise { + const fullProject = fullProjects.find((fp) => fp.id === projectId); + if (!fullProject || !issueKey) return undefined; + + const ctx: TriggerContext = { project: fullProject, source: 'jira', payload }; + const match = triggerRegistry.matchTrigger(ctx); + if (!match) return undefined; + + const message = INITIAL_MESSAGES[match.agentType]; + if (!message) return undefined; + + const commentId = await postJiraAck(projectId, issueKey, message); + return commentId ?? undefined; +} + +export async function isSelfAuthoredJiraComment( + webhookEvent: string, + payload: unknown, + projectId: string, +): Promise { + if (!webhookEvent.startsWith('comment_')) return false; + const p = payload as Record; + const comment = p.comment as Record | undefined; + const author = comment?.author as Record | undefined; + const commentAuthorId = author?.accountId as string | undefined; + if (!commentAuthorId) return false; + try { + const botId = await resolveJiraBotAccountId(projectId); + return !!botId && commentAuthorId === botId; + } catch { + return false; // Identity resolution failed — proceed normally + } +} + +export async function queueJiraJob( + project: RouterProjectConfig, + issueKey: string, + webhookEvent: string, + payload: unknown, + fullProjects: ProjectConfig[], + triggerRegistry: TriggerRegistry, +): Promise { + console.log('[Router] Queueing JIRA job:', { webhookEvent, issueKey, projectId: project.id }); + + // Fire-and-forget acknowledgment reaction — only for comment events + if (webhookEvent.startsWith('comment_')) { + void sendAcknowledgeReaction('jira', project.id, payload).catch((err) => + console.error('[Router] JIRA reaction error:', err), + ); + } + + // Try to post an ack comment via trigger matching (non-blocking best-effort) + let ackCommentId: string | undefined; + try { + ackCommentId = await tryPostJiraAck( + project.id, + issueKey, + payload, + fullProjects, + triggerRegistry, + ); + } catch (err) { + console.warn('[Router] JIRA ack comment failed (non-fatal):', String(err)); + } + + const job: CascadeJob = { + type: 'jira', + source: 'jira', + payload, + projectId: project.id, + issueKey, + webhookEvent, + receivedAt: new Date().toISOString(), + ackCommentId, + }; + + try { + const jobId = await addJob(job); + console.log('[Router] JIRA job queued:', { jobId, webhookEvent, ackCommentId }); + } catch (err) { + console.error('[Router] Failed to queue JIRA job:', err); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const PROCESSABLE_EVENTS = [ + 'jira:issue_updated', + 'jira:issue_created', + 'comment_created', + 'comment_updated', +]; + +/** + * Handle a POST /jira/webhook request. + * Parses the payload, filters irrelevant events, and queues a job. + */ +export async function handleJiraWebhook( + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise<{ shouldProcess: boolean; project?: RouterProjectConfig; webhookEvent: string }> { + const p = payload as Record; + const webhookEvent = (p.webhookEvent as string) || ''; + const issue = p.issue as Record | undefined; + const issueKey = (issue?.key as string) || ''; + const fields = issue?.fields as Record | undefined; + const projectField = fields?.project as Record | undefined; + const jiraProjectKey = (projectField?.key as string) || ''; + + // Match JIRA project key to a configured project + const config = await loadProjectConfig(); + const project = jiraProjectKey + ? config.projects.find((proj) => proj.jira?.projectKey === jiraProjectKey) + : undefined; + + const shouldProcess = !!project && PROCESSABLE_EVENTS.some((e) => webhookEvent.startsWith(e)); + + if (shouldProcess && project) { + if (await isSelfAuthoredJiraComment(webhookEvent, payload, project.id)) { + console.log('[Router] Ignoring self-authored JIRA comment'); + } else { + await queueJiraJob( + project, + issueKey, + webhookEvent, + payload, + config.fullProjects, + triggerRegistry, + ); + } + } else { + console.log(`[Router] Ignoring JIRA: ${webhookEvent}`); + } + + return { shouldProcess, project, webhookEvent }; +} diff --git a/src/router/trello.ts b/src/router/trello.ts new file mode 100644 index 00000000..d512b393 --- /dev/null +++ b/src/router/trello.ts @@ -0,0 +1,264 @@ +/** + * Trello webhook handler for the router (multi-container) deployment mode. + * + * Handles webhook parsing, self-comment filtering, ack posting, and job queuing + * for Trello webhook events. + */ + +import { INITIAL_MESSAGES } from '../config/agentMessages.js'; +import type { TriggerRegistry } from '../triggers/registry.js'; +import type { TriggerContext } from '../types/index.js'; +import { postTrelloAck, resolveTrelloBotMemberId } from './acknowledgments.js'; +import { type RouterProjectConfig, loadProjectConfig } from './config.js'; +import { type CascadeJob, addJob } from './queue.js'; +import { sendAcknowledgeReaction } from './reactions.js'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Check if filename matches agent log pattern: {agent-type}-{timestamp}.zip + * Examples: implementation-2026-01-02T16-30-24-339Z.zip, briefing-timeout-2026-01-02T12-34-56-789Z.zip + */ +export function isAgentLogFilename(filename: string): boolean { + return /^[a-z]+(?:-timeout)?-[\d-TZ]+\.zip$/i.test(filename); +} + +export function isCardInTriggerList( + actionType: string, + data: Record | undefined, + project: RouterProjectConfig, +): boolean { + if (!project.trello) return false; + const triggerLists = [ + project.trello.lists.briefing, + project.trello.lists.planning, + project.trello.lists.todo, + ]; + + // Card moved into a trigger list + if (actionType === 'updateCard' && data?.listAfter) { + const listAfter = data.listAfter as Record; + const listId = listAfter.id as string; + if (triggerLists.includes(listId)) { + console.log(`[Router] Card moved to trigger list: ${listId}`); + return true; + } + } + + // Card created directly in a trigger list + if (actionType === 'createCard' && data?.list) { + const list = data.list as Record; + const listId = list.id as string; + if (triggerLists.includes(listId)) { + console.log(`[Router] Card created in trigger list: ${listId}`); + return true; + } + } + + return false; +} + +export function isReadyToProcessLabelAdded( + actionType: string, + data: Record | undefined, + project: RouterProjectConfig, +): boolean { + if (actionType !== 'addLabelToCard' || !data?.label) return false; + if (!project.trello) return false; + + const label = data.label as Record; + const labelId = label.id as string; + + if (labelId === project.trello.labels.readyToProcess) { + console.log('[Router] Ready-to-process label added'); + return true; + } + return false; +} + +export function isAgentLogAttachmentUploaded( + actionType: string, + data: Record | undefined, + project: RouterProjectConfig, +): boolean { + if (actionType !== 'addAttachmentToCard' || !data?.attachment) return false; + if (!project.trello?.lists.debug) return false; + + const attachment = data.attachment as Record; + const name = attachment.name as string | undefined; + + if (name && isAgentLogFilename(name) && !name.startsWith('debug-')) { + console.log(`[Router] Agent log attachment uploaded: ${name}`); + return true; + } + return false; +} + +export interface TrelloWebhookResult { + shouldProcess: boolean; + project?: RouterProjectConfig; + projectId?: string; + actionType?: string; + cardId?: string; +} + +export async function parseTrelloWebhook(payload: unknown): Promise { + if (!payload || typeof payload !== 'object') { + return { shouldProcess: false }; + } + + const p = payload as Record; + const action = p.action as Record | undefined; + const model = p.model as Record | undefined; + + if (!action || !model) { + return { shouldProcess: false }; + } + + const boardId = model.id as string; + const actionType = action.type as string; + const data = action.data as Record | undefined; + + const config = await loadProjectConfig(); + const project = config.projects.find((proj) => proj.trello?.boardId === boardId); + if (!project) { + return { shouldProcess: false }; + } + + // Extract card ID + const card = data?.card as Record | undefined; + const cardId = card?.id as string | undefined; + + const shouldProcess = + isCardInTriggerList(actionType, data, project) || + isReadyToProcessLabelAdded(actionType, data, project) || + isAgentLogAttachmentUploaded(actionType, data, project) || + actionType === 'commentCard'; + + return { shouldProcess, project, projectId: project.id, actionType, cardId }; +} + +/** + * Try to match a trigger and post an ack comment for a Trello webhook. + * Returns the ack comment ID if posted, undefined otherwise. + */ +export async function tryPostTrelloAck( + projectId: string, + cardId: string, + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === projectId); + if (!fullProject) return undefined; + + const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; + const match = triggerRegistry.matchTrigger(ctx); + if (!match) return undefined; + + const message = INITIAL_MESSAGES[match.agentType]; + if (!message) return undefined; + + const commentId = await postTrelloAck(projectId, cardId, message); + return commentId ?? undefined; +} + +export async function isSelfAuthoredTrelloComment( + payload: unknown, + projectId: string, +): Promise { + const action = (payload as Record).action as Record | undefined; + const commentAuthorId = action?.idMemberCreator as string | undefined; + if (!commentAuthorId) return false; + try { + const botId = await resolveTrelloBotMemberId(projectId); + return !!botId && commentAuthorId === botId; + } catch { + return false; // Identity resolution failed — proceed normally + } +} + +export async function processTrelloWebhookEvent( + project: RouterProjectConfig, + cardId: string, + actionType: string, + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise { + if (actionType === 'commentCard' && (await isSelfAuthoredTrelloComment(payload, project.id))) { + console.log('[Router] Ignoring self-authored Trello comment'); + return; + } + + console.log('[Router] Queueing Trello job:', { actionType, cardId, projectId: project.id }); + + // Fire-and-forget acknowledgment reaction — only for comment actions + if (actionType === 'commentCard') { + void sendAcknowledgeReaction('trello', project.id, payload).catch((err) => + console.error('[Router] Trello reaction error:', err), + ); + } + + // Try to post an ack comment via trigger matching (non-blocking best-effort) + let ackCommentId: string | undefined; + try { + ackCommentId = await tryPostTrelloAck(project.id, cardId, payload, triggerRegistry); + } catch (err) { + console.warn('[Router] Trello ack comment failed (non-fatal):', String(err)); + } + + const job: CascadeJob = { + type: 'trello', + source: 'trello', + payload, + projectId: project.id, + cardId, + actionType: actionType || 'unknown', + receivedAt: new Date().toISOString(), + ackCommentId, + }; + + try { + const jobId = await addJob(job); + console.log('[Router] Trello job queued:', { jobId, actionType, ackCommentId }); + } catch (err) { + console.error('[Router] Failed to queue Trello job:', err); + // Still return to caller — Trello gets 200 to avoid retries + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Handle a POST /trello/webhook request. + * Parses the payload, filters irrelevant events, and queues a job. + */ +export async function handleTrelloWebhook( + payload: unknown, + triggerRegistry: TriggerRegistry, +): Promise<{ + shouldProcess: boolean; + project?: RouterProjectConfig; + actionType?: string; + cardId?: string; +}> { + const { shouldProcess, project, actionType, cardId } = await parseTrelloWebhook(payload); + + if (shouldProcess && project && cardId) { + await processTrelloWebhookEvent( + project, + cardId, + actionType || 'unknown', + payload, + triggerRegistry, + ); + } else { + console.log(`[Router] Ignoring Trello: ${actionType || 'unknown'}`); + } + + return { shouldProcess, project, actionType, cardId }; +} diff --git a/src/router/webhookParsing.ts b/src/router/webhookParsing.ts new file mode 100644 index 00000000..21b7b779 --- /dev/null +++ b/src/router/webhookParsing.ts @@ -0,0 +1,39 @@ +/** + * Shared webhook parsing utilities used by both the router (multi-container) + * and server (single-process) deployment modes. + */ + +import type { Context } from 'hono'; + +export type PayloadParseResult = { ok: true; payload: unknown } | { ok: false; error: string }; + +/** + * Parse a GitHub webhook payload, handling both JSON and + * application/x-www-form-urlencoded content types. + */ +export async function parseGitHubWebhookPayload( + c: Context, + contentType: string, +): Promise { + try { + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await c.req.parseBody(); + const payloadStr = formData.payload; + if (typeof payloadStr === 'string') { + return { ok: true, payload: JSON.parse(payloadStr) }; + } + throw new Error('Missing payload field in form data'); + } + return { ok: true, payload: await c.req.json() }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +/** + * Extract all request headers as a plain string-keyed object. + * Used for webhook call logging. + */ +export function extractRawHeaders(c: Context): Record { + return Object.fromEntries(Object.entries(c.req.header()).map(([k, v]) => [k, String(v)])); +} diff --git a/src/server.ts b/src/server.ts index ebbac9cc..62f946ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { serveStatic } from '@hono/node-server/serve-static'; import { trpcServer } from '@hono/trpc-server'; -import type { Context } from 'hono'; import { Hono } from 'hono'; import { getCookie } from 'hono/cookie'; import { cors } from 'hono/cors'; @@ -15,6 +14,7 @@ import { appRouter } from './api/router.js'; import { findProjectByRepo } from './config/provider.js'; import { resolvePersonaIdentities } from './github/personas.js'; import { sendAcknowledgeReaction } from './router/reactions.js'; +import { extractRawHeaders, parseGitHubWebhookPayload } from './router/webhookParsing.js'; import type { CascadeConfig } from './types/index.js'; import { canAcceptWebhook, isCurrentlyProcessing, logger } from './utils/index.js'; import { logWebhookCall } from './utils/webhookLogger.js'; @@ -26,27 +26,6 @@ export interface ServerDependencies { onJiraWebhook: (payload: unknown) => Promise; } -type PayloadParseResult = { ok: true; payload: unknown } | { ok: false; error: string }; - -async function parseGitHubWebhookPayload( - c: Context, - contentType: string, -): Promise { - try { - if (contentType.includes('application/x-www-form-urlencoded')) { - const formData = await c.req.parseBody(); - const payloadStr = formData.payload; - if (typeof payloadStr === 'string') { - return { ok: true, payload: JSON.parse(payloadStr) }; - } - throw new Error('Missing payload field in form data'); - } - return { ok: true, payload: await c.req.json() }; - } catch (err) { - return { ok: false, error: String(err) }; - } -} - export function createServer(deps: ServerDependencies): Hono { const app = new Hono(); @@ -104,9 +83,7 @@ export function createServer(deps: ServerDependencies): Hono { return c.text('Service Unavailable', 503); } - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); try { const payload = await c.req.json(); @@ -178,9 +155,7 @@ export function createServer(deps: ServerDependencies): Hono { const eventType = c.req.header('X-GitHub-Event') || 'unknown'; const contentType = c.req.header('Content-Type') || ''; - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); const parseResult = await parseGitHubWebhookPayload(c, contentType); if (!parseResult.ok) { @@ -278,9 +253,7 @@ export function createServer(deps: ServerDependencies): Hono { return c.text('Service Unavailable', 503); } - const rawHeaders = Object.fromEntries( - Object.entries(c.req.header()).map(([k, v]) => [k, String(v)]), - ); + const rawHeaders = extractRawHeaders(c); try { const payload = await c.req.json(); diff --git a/tests/unit/router/github.test.ts b/tests/unit/router/github.test.ts new file mode 100644 index 00000000..4e90ffee --- /dev/null +++ b/tests/unit/router/github.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports +vi.mock('../../../src/router/queue.js', () => ({ + addJob: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postGitHubAck: vi.fn(), + resolveGitHubTokenForAck: vi.fn(), +})); +vi.mock('../../../src/router/notifications.js', () => ({ + extractPRNumber: vi.fn(), +})); +vi.mock('../../../src/router/pre-actions.js', () => ({ + addEyesReactionToPR: vi.fn(), +})); +vi.mock('../../../src/config/agentMessages.js', () => ({ + INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +})); +vi.mock('../../../src/config/provider.js', () => ({ + findProjectByRepo: vi.fn(), +})); +vi.mock('../../../src/github/personas.js', () => ({ + resolvePersonaIdentities: vi.fn(), + isCascadeBot: vi.fn(), +})); + +import { findProjectByRepo } from '../../../src/config/provider.js'; +import { isCascadeBot, resolvePersonaIdentities } from '../../../src/github/personas.js'; +import { resolveGitHubTokenForAck } from '../../../src/router/acknowledgments.js'; +import { + firePreActions, + handleGitHubWebhook, + isSelfAuthoredGitHubComment, + processGitHubWebhookEvent, +} from '../../../src/router/github.js'; +import { extractPRNumber } from '../../../src/router/notifications.js'; +import { addEyesReactionToPR } from '../../../src/router/pre-actions.js'; +import { addJob } from '../../../src/router/queue.js'; +import type { GitHubJob } from '../../../src/router/queue.js'; +import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import type { TriggerRegistry } from '../../../src/triggers/registry.js'; + +const mockTriggerRegistry = { + matchTrigger: vi.fn(), +} as unknown as TriggerRegistry; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('isSelfAuthoredGitHubComment', () => { + it('returns true when comment author is a cascade bot', async () => { + vi.mocked(findProjectByRepo).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({ + implementer: { login: 'cascade-bot', id: 1 }, + reviewer: { login: 'cascade-reviewer', id: 2 }, + } as never); + vi.mocked(isCascadeBot).mockReturnValue(true); + + const result = await isSelfAuthoredGitHubComment( + { comment: { user: { login: 'cascade-bot' } } }, + 'owner/repo', + ); + expect(result).toBe(true); + }); + + it('returns false when comment author is not a bot', async () => { + vi.mocked(findProjectByRepo).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + vi.mocked(isCascadeBot).mockReturnValue(false); + + const result = await isSelfAuthoredGitHubComment( + { comment: { user: { login: 'regular-user' } } }, + 'owner/repo', + ); + expect(result).toBe(false); + }); + + it('returns false when no login present', async () => { + const result = await isSelfAuthoredGitHubComment({ comment: {} }, 'owner/repo'); + expect(result).toBe(false); + }); + + it('returns false when persona resolution fails', async () => { + vi.mocked(findProjectByRepo).mockRejectedValue(new Error('DB error')); + const result = await isSelfAuthoredGitHubComment( + { comment: { user: { login: 'cascade-bot' } } }, + 'owner/repo', + ); + expect(result).toBe(false); + }); +}); + +describe('firePreActions', () => { + it('calls addEyesReactionToPR for successful check_suite', () => { + vi.mocked(addEyesReactionToPR).mockResolvedValue(undefined); + const job = { eventType: 'check_suite' } as GitHubJob; + const payload = { + action: 'completed', + check_suite: { conclusion: 'success', pull_requests: [{}] }, + }; + firePreActions(job, payload); + expect(addEyesReactionToPR).toHaveBeenCalledWith(job); + }); + + it('does nothing for non-check_suite events', () => { + const job = { eventType: 'push' } as GitHubJob; + firePreActions(job, {}); + expect(addEyesReactionToPR).not.toHaveBeenCalled(); + }); + + it('does nothing when check_suite has no PRs', () => { + const job = { eventType: 'check_suite' } as GitHubJob; + const payload = { + action: 'completed', + check_suite: { conclusion: 'success', pull_requests: [] }, + }; + firePreActions(job, payload); + expect(addEyesReactionToPR).not.toHaveBeenCalled(); + }); + + it('does nothing when conclusion is not success', () => { + const job = { eventType: 'check_suite' } as GitHubJob; + const payload = { + action: 'completed', + check_suite: { conclusion: 'failure', pull_requests: [{}] }, + }; + firePreActions(job, payload); + expect(addEyesReactionToPR).not.toHaveBeenCalled(); + }); +}); + +describe('handleGitHubWebhook', () => { + it('ignores non-processable events', async () => { + const result = await handleGitHubWebhook('push', {}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(false); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('processes pull_request events', async () => { + vi.mocked(findProjectByRepo).mockResolvedValue(null); + vi.mocked(addJob).mockResolvedValue('job-1'); + + const result = await handleGitHubWebhook( + 'pull_request', + { repository: { full_name: 'owner/repo' }, action: 'opened' }, + mockTriggerRegistry, + ); + + expect(result.shouldProcess).toBe(true); + expect(result.repoFullName).toBe('owner/repo'); + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'github', + eventType: 'pull_request', + repoFullName: 'owner/repo', + }), + ); + }); + + it('ignores self-authored issue_comment events', async () => { + vi.mocked(findProjectByRepo).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + vi.mocked(isCascadeBot).mockReturnValue(true); + + const result = await handleGitHubWebhook( + 'issue_comment', + { + repository: { full_name: 'owner/repo' }, + comment: { user: { login: 'cascade-bot' } }, + }, + mockTriggerRegistry, + ); + + expect(result.shouldProcess).toBe(true); // Event IS processable type... + expect(addJob).not.toHaveBeenCalled(); // ...but skipped because self-authored + }); + + it('processes check_suite events', async () => { + vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(addEyesReactionToPR).mockResolvedValue(undefined); + + await handleGitHubWebhook( + 'check_suite', + { + repository: { full_name: 'owner/repo' }, + action: 'completed', + check_suite: { conclusion: 'success', pull_requests: [{}] }, + }, + mockTriggerRegistry, + ); + + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ type: 'github', eventType: 'check_suite' }), + ); + }); +}); + +describe('processGitHubWebhookEvent', () => { + it('sends ack reaction for comment events', async () => { + vi.mocked(findProjectByRepo).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(sendAcknowledgeReaction).mockResolvedValue(undefined); + + await processGitHubWebhookEvent('issue_comment', 'owner/repo', {}, mockTriggerRegistry); + + await vi.waitFor(() => { + expect(sendAcknowledgeReaction).toHaveBeenCalled(); + }); + }); + + it('does not send ack reaction for non-comment events', async () => { + vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue(null); + vi.mocked(extractPRNumber).mockReturnValue(null); + + await processGitHubWebhookEvent('pull_request', 'owner/repo', {}, mockTriggerRegistry); + + expect(sendAcknowledgeReaction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/router/jira.test.ts b/tests/unit/router/jira.test.ts new file mode 100644 index 00000000..39a76352 --- /dev/null +++ b/tests/unit/router/jira.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); +vi.mock('../../../src/router/queue.js', () => ({ + addJob: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/config/agentMessages.js', () => ({ + INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +})); + +import { resolveJiraBotAccountId } from '../../../src/router/acknowledgments.js'; +import { loadProjectConfig } from '../../../src/router/config.js'; +import type { RouterProjectConfig } from '../../../src/router/config.js'; +import { + handleJiraWebhook, + isSelfAuthoredJiraComment, + queueJiraJob, +} from '../../../src/router/jira.js'; +import { addJob } from '../../../src/router/queue.js'; +import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import type { TriggerRegistry } from '../../../src/triggers/registry.js'; + +const mockProject: RouterProjectConfig = { + id: 'p1', + repo: 'owner/repo', + pmType: 'jira', + jira: { + projectKey: 'MYPROJ', + baseUrl: 'https://mycompany.atlassian.net', + }, +}; + +const mockTriggerRegistry = { + matchTrigger: vi.fn(), +} as unknown as TriggerRegistry; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('isSelfAuthoredJiraComment', () => { + it('returns true when comment author matches bot account ID', async () => { + vi.mocked(resolveJiraBotAccountId).mockResolvedValue('bot-account-id'); + const result = await isSelfAuthoredJiraComment( + 'comment_created', + { comment: { author: { accountId: 'bot-account-id' } } }, + 'p1', + ); + expect(result).toBe(true); + }); + + it('returns false when comment author does not match', async () => { + vi.mocked(resolveJiraBotAccountId).mockResolvedValue('bot-account-id'); + const result = await isSelfAuthoredJiraComment( + 'comment_created', + { comment: { author: { accountId: 'user-account-id' } } }, + 'p1', + ); + expect(result).toBe(false); + }); + + it('returns false for non-comment webhook events', async () => { + const result = await isSelfAuthoredJiraComment( + 'jira:issue_updated', + { comment: { author: { accountId: 'bot-account-id' } } }, + 'p1', + ); + expect(result).toBe(false); + expect(resolveJiraBotAccountId).not.toHaveBeenCalled(); + }); + + it('returns false when identity resolution fails', async () => { + vi.mocked(resolveJiraBotAccountId).mockRejectedValue(new Error('DB error')); + const result = await isSelfAuthoredJiraComment( + 'comment_created', + { comment: { author: { accountId: 'bot-account-id' } } }, + 'p1', + ); + expect(result).toBe(false); + }); +}); + +describe('queueJiraJob', () => { + it('queues a jira job', async () => { + vi.mocked(addJob).mockResolvedValue('job-1'); + await queueJiraJob( + mockProject, + 'MYPROJ-123', + 'jira:issue_updated', + { issue: { key: 'MYPROJ-123' } }, + [], + mockTriggerRegistry, + ); + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'jira', + projectId: 'p1', + issueKey: 'MYPROJ-123', + webhookEvent: 'jira:issue_updated', + }), + ); + }); + + it('sends ack reaction for comment events', async () => { + vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(sendAcknowledgeReaction).mockResolvedValue(undefined); + + await queueJiraJob( + mockProject, + 'MYPROJ-123', + 'comment_created', + { comment: {} }, + [], + mockTriggerRegistry, + ); + + await vi.waitFor(() => { + expect(sendAcknowledgeReaction).toHaveBeenCalledWith('jira', 'p1', expect.any(Object)); + }); + }); + + it('does not send reaction for non-comment events', async () => { + vi.mocked(addJob).mockResolvedValue('job-1'); + await queueJiraJob( + mockProject, + 'MYPROJ-123', + 'jira:issue_updated', + {}, + [], + mockTriggerRegistry, + ); + expect(sendAcknowledgeReaction).not.toHaveBeenCalled(); + }); +}); + +describe('handleJiraWebhook', () => { + it('ignores events with unknown project', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [], + }); + const result = await handleJiraWebhook( + { + webhookEvent: 'jira:issue_updated', + issue: { key: 'UNKNOWN-1', fields: { project: { key: 'UNKNOWN' } } }, + }, + mockTriggerRegistry, + ); + expect(result.shouldProcess).toBe(false); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('ignores unknown event types', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [], + }); + const result = await handleJiraWebhook( + { + webhookEvent: 'unknown_event', + issue: { key: 'MYPROJ-1', fields: { project: { key: 'MYPROJ' } } }, + }, + mockTriggerRegistry, + ); + expect(result.shouldProcess).toBe(false); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('processes jira:issue_updated events for known projects', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [], + }); + vi.mocked(addJob).mockResolvedValue('job-1'); + + const result = await handleJiraWebhook( + { + webhookEvent: 'jira:issue_updated', + issue: { key: 'MYPROJ-1', fields: { project: { key: 'MYPROJ' } } }, + }, + mockTriggerRegistry, + ); + + expect(result.shouldProcess).toBe(true); + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jira', projectId: 'p1', issueKey: 'MYPROJ-1' }), + ); + }); + + it('ignores self-authored JIRA comments', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [], + }); + vi.mocked(resolveJiraBotAccountId).mockResolvedValue('bot-id'); + + await handleJiraWebhook( + { + webhookEvent: 'comment_created', + issue: { key: 'MYPROJ-1', fields: { project: { key: 'MYPROJ' } } }, + comment: { author: { accountId: 'bot-id' } }, + }, + mockTriggerRegistry, + ); + + expect(addJob).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts new file mode 100644 index 00000000..8758de1b --- /dev/null +++ b/tests/unit/router/trello.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); +vi.mock('../../../src/router/queue.js', () => ({ + addJob: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), +})); +vi.mock('../../../src/config/agentMessages.js', () => ({ + INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +})); + +import { postTrelloAck, resolveTrelloBotMemberId } from '../../../src/router/acknowledgments.js'; +import { loadProjectConfig } from '../../../src/router/config.js'; +import type { RouterProjectConfig } from '../../../src/router/config.js'; +import { addJob } from '../../../src/router/queue.js'; +import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import { + handleTrelloWebhook, + isAgentLogAttachmentUploaded, + isAgentLogFilename, + isCardInTriggerList, + isReadyToProcessLabelAdded, + isSelfAuthoredTrelloComment, + parseTrelloWebhook, + processTrelloWebhookEvent, +} from '../../../src/router/trello.js'; +import type { TriggerRegistry } from '../../../src/triggers/registry.js'; + +const mockProject: RouterProjectConfig = { + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', + trello: { + boardId: 'board1', + lists: { + briefing: 'list-briefing', + planning: 'list-planning', + todo: 'list-todo', + debug: 'list-debug', + }, + labels: { readyToProcess: 'label-ready' }, + }, +}; + +const mockTriggerRegistry = { + matchTrigger: vi.fn(), +} as unknown as TriggerRegistry; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('isAgentLogFilename', () => { + it('matches valid agent log filenames', () => { + expect(isAgentLogFilename('implementation-2026-01-02T16-30-24-339Z.zip')).toBe(true); + expect(isAgentLogFilename('briefing-timeout-2026-01-02T12-34-56-789Z.zip')).toBe(true); + }); + + it('does not match other filenames', () => { + expect(isAgentLogFilename('screenshot.png')).toBe(false); + expect(isAgentLogFilename('debug-2026-01-02T16-30-24-339Z.zip')).toBe(true); // matches pattern even with debug prefix + }); +}); + +describe('isCardInTriggerList', () => { + it('returns true when card moved to trigger list', () => { + const result = isCardInTriggerList( + 'updateCard', + { listAfter: { id: 'list-todo' } }, + mockProject, + ); + expect(result).toBe(true); + }); + + it('returns false when card moved to non-trigger list', () => { + const result = isCardInTriggerList( + 'updateCard', + { listAfter: { id: 'list-other' } }, + mockProject, + ); + expect(result).toBe(false); + }); + + it('returns true when card created in trigger list', () => { + const result = isCardInTriggerList( + 'createCard', + { list: { id: 'list-briefing' } }, + mockProject, + ); + expect(result).toBe(true); + }); + + it('returns false when project has no trello config', () => { + const result = isCardInTriggerList( + 'updateCard', + { listAfter: { id: 'list-todo' } }, + { + ...mockProject, + trello: undefined, + }, + ); + expect(result).toBe(false); + }); +}); + +describe('isReadyToProcessLabelAdded', () => { + it('returns true when ready-to-process label added', () => { + const result = isReadyToProcessLabelAdded( + 'addLabelToCard', + { label: { id: 'label-ready' } }, + mockProject, + ); + expect(result).toBe(true); + }); + + it('returns false for wrong action type', () => { + const result = isReadyToProcessLabelAdded( + 'commentCard', + { label: { id: 'label-ready' } }, + mockProject, + ); + expect(result).toBe(false); + }); + + it('returns false for different label', () => { + const result = isReadyToProcessLabelAdded( + 'addLabelToCard', + { label: { id: 'label-other' } }, + mockProject, + ); + expect(result).toBe(false); + }); +}); + +describe('isAgentLogAttachmentUploaded', () => { + it('returns true for matching attachment name', () => { + const result = isAgentLogAttachmentUploaded( + 'addAttachmentToCard', + { attachment: { name: 'implementation-2026-01-02T16-30-24-339Z.zip' } }, + mockProject, + ); + expect(result).toBe(true); + }); + + it('returns false for debug- prefixed attachments', () => { + const result = isAgentLogAttachmentUploaded( + 'addAttachmentToCard', + { attachment: { name: 'debug-2026-01-02T16-30-24-339Z.zip' } }, + mockProject, + ); + expect(result).toBe(false); + }); + + it('returns false when project has no debug list', () => { + const result = isAgentLogAttachmentUploaded( + 'addAttachmentToCard', + { attachment: { name: 'implementation-2026-01-02T16-30-24-339Z.zip' } }, + { + ...mockProject, + trello: { ...mockProject.trello, lists: { ...mockProject.trello?.lists, debug: '' } }, + }, + ); + expect(result).toBe(false); + }); +}); + +describe('parseTrelloWebhook', () => { + it('returns shouldProcess false for invalid payload', async () => { + const result = await parseTrelloWebhook(null); + expect(result.shouldProcess).toBe(false); + }); + + it('returns shouldProcess false when no matching project', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [] }); + const result = await parseTrelloWebhook({ + action: { type: 'commentCard', data: {} }, + model: { id: 'unknown-board' }, + }); + expect(result.shouldProcess).toBe(false); + }); + + it('returns shouldProcess true for commentCard event', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [], + }); + const result = await parseTrelloWebhook({ + action: { type: 'commentCard', data: { card: { id: 'card1' } } }, + model: { id: 'board1' }, + }); + expect(result.shouldProcess).toBe(true); + expect(result.cardId).toBe('card1'); + expect(result.actionType).toBe('commentCard'); + }); +}); + +describe('isSelfAuthoredTrelloComment', () => { + it('returns true when comment author matches bot ID', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + const result = await isSelfAuthoredTrelloComment( + { action: { idMemberCreator: 'bot-id' } }, + 'p1', + ); + expect(result).toBe(true); + }); + + it('returns false when comment author does not match', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + const result = await isSelfAuthoredTrelloComment( + { action: { idMemberCreator: 'user-id' } }, + 'p1', + ); + expect(result).toBe(false); + }); + + it('returns false when identity resolution fails', async () => { + vi.mocked(resolveTrelloBotMemberId).mockRejectedValue(new Error('DB error')); + const result = await isSelfAuthoredTrelloComment( + { action: { idMemberCreator: 'bot-id' } }, + 'p1', + ); + expect(result).toBe(false); + }); +}); + +describe('processTrelloWebhookEvent', () => { + it('ignores self-authored comments', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + await processTrelloWebhookEvent( + mockProject, + 'card1', + 'commentCard', + { action: { idMemberCreator: 'bot-id' } }, + mockTriggerRegistry, + ); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('queues a job for non-self-authored comment', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(addJob).mockResolvedValue('job-1'); + await processTrelloWebhookEvent( + mockProject, + 'card1', + 'commentCard', + { action: { idMemberCreator: 'user-id' } }, + mockTriggerRegistry, + ); + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'trello', + projectId: 'p1', + cardId: 'card1', + actionType: 'commentCard', + }), + ); + }); + + it('sends ack reaction for comment actions', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(sendAcknowledgeReaction).mockResolvedValue(undefined); + await processTrelloWebhookEvent( + mockProject, + 'card1', + 'commentCard', + { action: { idMemberCreator: 'user-id' } }, + mockTriggerRegistry, + ); + // Reaction is fire-and-forget so we just check it was called + await vi.waitFor(() => { + expect(sendAcknowledgeReaction).toHaveBeenCalledWith('trello', 'p1', expect.any(Object)); + }); + }); +}); + +describe('handleTrelloWebhook', () => { + it('returns shouldProcess false and ignores invalid payload', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [] }); + const result = await handleTrelloWebhook({}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(false); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('processes a valid trello webhook', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [], + }); + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(addJob).mockResolvedValue('job-1'); + + const result = await handleTrelloWebhook( + { + action: { + type: 'commentCard', + data: { card: { id: 'card1' } }, + idMemberCreator: 'user-id', + }, + model: { id: 'board1' }, + }, + mockTriggerRegistry, + ); + + expect(result.shouldProcess).toBe(true); + }); +}); diff --git a/tests/unit/router/webhookParsing.test.ts b/tests/unit/router/webhookParsing.test.ts new file mode 100644 index 00000000..264a4101 --- /dev/null +++ b/tests/unit/router/webhookParsing.test.ts @@ -0,0 +1,85 @@ +import type { Context } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; +import { + extractRawHeaders, + parseGitHubWebhookPayload, +} from '../../../src/router/webhookParsing.js'; + +function makeContext( + overrides: Partial<{ + json: () => Promise; + parseBody: () => Promise>; + header: () => Record; + }> = {}, +): Context { + return { + req: { + json: overrides.json ?? vi.fn().mockResolvedValue({ event: 'push' }), + parseBody: overrides.parseBody ?? vi.fn().mockResolvedValue({}), + header: + overrides.header ?? + vi.fn().mockReturnValue({ 'content-type': 'application/json', 'x-github-event': 'push' }), + }, + } as unknown as Context; +} + +describe('parseGitHubWebhookPayload', () => { + it('parses JSON body', async () => { + const ctx = makeContext({ json: vi.fn().mockResolvedValue({ action: 'opened' }) }); + const result = await parseGitHubWebhookPayload(ctx, 'application/json'); + expect(result).toEqual({ ok: true, payload: { action: 'opened' } }); + }); + + it('parses form-urlencoded body with payload field', async () => { + const payloadObj = { action: 'opened' }; + const ctx = makeContext({ + parseBody: vi.fn().mockResolvedValue({ payload: JSON.stringify(payloadObj) }), + }); + const result = await parseGitHubWebhookPayload(ctx, 'application/x-www-form-urlencoded'); + expect(result).toEqual({ ok: true, payload: payloadObj }); + }); + + it('returns error when form-urlencoded missing payload field', async () => { + const ctx = makeContext({ + parseBody: vi.fn().mockResolvedValue({}), + }); + const result = await parseGitHubWebhookPayload(ctx, 'application/x-www-form-urlencoded'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('Missing payload field'); + } + }); + + it('returns error when JSON parsing fails', async () => { + const ctx = makeContext({ + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }); + const result = await parseGitHubWebhookPayload(ctx, 'application/json'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('Invalid JSON'); + } + }); +}); + +describe('extractRawHeaders', () => { + it('converts headers to plain string record', () => { + const ctx = makeContext({ + header: vi.fn().mockReturnValue({ + 'content-type': 'application/json', + 'x-github-event': 'push', + }), + }); + const headers = extractRawHeaders(ctx); + expect(headers).toEqual({ + 'content-type': 'application/json', + 'x-github-event': 'push', + }); + }); + + it('returns empty object for no headers', () => { + const ctx = makeContext({ header: vi.fn().mockReturnValue({}) }); + const headers = extractRawHeaders(ctx); + expect(headers).toEqual({}); + }); +}); From 58ab7b85ee3f8ae13287221324ac08f6f3e53b4e Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 22 Feb 2026 16:59:37 +0000 Subject: [PATCH 2/4] fix(router): eliminate double parseTrelloWebhook call and review nits Use handleTrelloWebhook() as the single entry point (matching GitHub/JIRA pattern), merge duplicate ./trello.js imports, and split misleading test. Co-Authored-By: Claude Opus 4.6 --- src/router/index.ts | 8 ++++---- tests/unit/router/trello.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/router/index.ts b/src/router/index.ts index d492abf1..3b814fea 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -7,7 +7,6 @@ import { handleGitHubWebhook } from './github.js'; import { handleJiraWebhook } from './jira.js'; import { getQueueStats } from './queue.js'; import { handleTrelloWebhook } from './trello.js'; -import { parseTrelloWebhook } from './trello.js'; import { extractRawHeaders, parseGitHubWebhookPayload } from './webhookParsing.js'; import { getActiveWorkerCount, @@ -57,7 +56,10 @@ app.post('/trello/webhook', async (c) => { return c.text('Bad Request', 400); } - const { shouldProcess, project, actionType, cardId } = await parseTrelloWebhook(payload); + const { shouldProcess, project, actionType, cardId } = await handleTrelloWebhook( + payload, + triggerRegistry, + ); logWebhookCall({ source: 'trello', @@ -71,8 +73,6 @@ app.post('/trello/webhook', async (c) => { processed: shouldProcess && !!project && !!cardId, }); - await handleTrelloWebhook(payload, triggerRegistry); - return c.text('OK', 200); }); diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index 8758de1b..5abd6378 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -65,9 +65,12 @@ describe('isAgentLogFilename', () => { expect(isAgentLogFilename('briefing-timeout-2026-01-02T12-34-56-789Z.zip')).toBe(true); }); - it('does not match other filenames', () => { + it('does not match non-zip filenames', () => { expect(isAgentLogFilename('screenshot.png')).toBe(false); - expect(isAgentLogFilename('debug-2026-01-02T16-30-24-339Z.zip')).toBe(true); // matches pattern even with debug prefix + }); + + it('matches debug-prefixed filenames (caller filters separately)', () => { + expect(isAgentLogFilename('debug-2026-01-02T16-30-24-339Z.zip')).toBe(true); }); }); From 0e59a7ffdf46cf5ee7cf2eb52bcb2700daf052aa Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 22 Feb 2026 17:11:49 +0000 Subject: [PATCH 3/4] feat(router): generate contextual ack messages via lightweight LLM call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded INITIAL_MESSAGES lookups in webhook ack handlers with a single-shot LLM call that produces a short, context-aware message reflecting the actual request (e.g., card name, PR title, comment text). Uses the existing progressModel config (defaults to gemini-2.5-flash-lite via OpenRouter) with a 5-second timeout. On any failure — no model configured, missing API key, LLM error, timeout, or empty output — gracefully falls back to the static INITIAL_MESSAGES. Co-Authored-By: Claude Opus 4.6 --- src/router/ackMessageGenerator.ts | 234 +++++++++++ src/router/github.ts | 6 +- src/router/jira.ts | 6 +- src/router/trello.ts | 6 +- tests/unit/router/ackMessageGenerator.test.ts | 387 ++++++++++++++++++ tests/unit/router/github.test.ts | 5 +- tests/unit/router/jira.test.ts | 5 +- tests/unit/router/trello.test.ts | 5 +- 8 files changed, 639 insertions(+), 15 deletions(-) create mode 100644 src/router/ackMessageGenerator.ts create mode 100644 tests/unit/router/ackMessageGenerator.test.ts diff --git a/src/router/ackMessageGenerator.ts b/src/router/ackMessageGenerator.ts new file mode 100644 index 00000000..c47559af --- /dev/null +++ b/src/router/ackMessageGenerator.ts @@ -0,0 +1,234 @@ +/** + * LLM-generated acknowledgment messages for webhook events. + * + * Makes a single-shot LLM call to a lightweight model (same as progress tracking) + * to produce a short, contextual ack message that reflects the actual request. + * Gracefully falls back to static INITIAL_MESSAGES on any failure. + */ + +import { AgentBuilder, LLMist, type ModelSpec } from 'llmist'; + +import { INITIAL_MESSAGES } from '../config/agentMessages.js'; +import { CUSTOM_MODELS } from '../config/customModels.js'; +import { getOrgCredential, loadConfig } from '../config/provider.js'; + +// --------------------------------------------------------------------------- +// System prompt for ack message generation +// --------------------------------------------------------------------------- + +const ACK_SYSTEM_PROMPT = `You write brief acknowledgment messages for CASCADE, an AI coding automation platform. +Given the agent type and request context, write a SHORT 1-sentence message confirming understanding of the request. Keep it under 25 words. Use markdown bold for the header. Start with an appropriate emoji. Do not mention implementation details — just confirm what you'll be working on.`; + +// --------------------------------------------------------------------------- +// Context extractors — pull relevant snippets from webhook payloads +// --------------------------------------------------------------------------- + +const MAX_CONTEXT_LENGTH = 500; + +function truncate(text: string, maxLength: number = MAX_CONTEXT_LENGTH): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}…`; +} + +/** + * Extract context from a Trello webhook payload. + * Pulls card name and optional comment text. + */ +export function extractTrelloContext(payload: unknown): string { + if (!payload || typeof payload !== 'object') return ''; + + const p = payload as Record; + const action = p.action as Record | undefined; + if (!action) return ''; + + const data = action.data as Record | undefined; + if (!data) return ''; + + const parts: string[] = []; + + const card = data.card as Record | undefined; + if (card?.name) { + parts.push(`Card: ${card.name as string}`); + } + + // Comment text (for commentCard actions) + const text = data.text as string | undefined; + if (text) { + parts.push(`Comment: ${text}`); + } + + return truncate(parts.join('\n')); +} + +/** + * Extract context from a GitHub webhook payload. + * Pulls PR title and optional comment/review body. + */ +export function extractGitHubContext(payload: unknown, eventType: string): string { + if (!payload || typeof payload !== 'object') return ''; + + const p = payload as Record; + const parts: string[] = []; + + const pr = p.pull_request as Record | undefined; + if (pr?.title) { + parts.push(`PR: ${pr.title as string}`); + } + + // Comment body (issue_comment or pull_request_review_comment) + if (eventType === 'issue_comment' || eventType === 'pull_request_review_comment') { + const comment = p.comment as Record | undefined; + if (comment?.body) { + parts.push(`Comment: ${comment.body as string}`); + } + } + + // Review body (pull_request_review) + if (eventType === 'pull_request_review') { + const review = p.review as Record | undefined; + if (review?.body) { + parts.push(`Review: ${review.body as string}`); + } + } + + return truncate(parts.join('\n')); +} + +/** + * Extract context from a JIRA webhook payload. + * Pulls issue summary and optional comment body. + */ +export function extractJiraContext(payload: unknown): string { + if (!payload || typeof payload !== 'object') return ''; + + const p = payload as Record; + const parts: string[] = []; + + const issue = p.issue as Record | undefined; + if (issue) { + const fields = issue.fields as Record | undefined; + if (fields?.summary) { + parts.push(`Issue: ${fields.summary as string}`); + } + } + + const comment = p.comment as Record | undefined; + if (comment?.body) { + parts.push(`Comment: ${comment.body as string}`); + } + + return truncate(parts.join('\n')); +} + +// --------------------------------------------------------------------------- +// Core generator +// --------------------------------------------------------------------------- + +const ACK_TIMEOUT_MS = 5_000; + +const GENERIC_FALLBACK = '**⚙️ Working on it** — Processing your request...'; + +function getStaticFallback(agentType: string): string { + return INITIAL_MESSAGES[agentType] ?? GENERIC_FALLBACK; +} + +/** + * Generate a contextual acknowledgment message using a lightweight LLM call. + * + * Falls back to static INITIAL_MESSAGES on any failure: + * - No progressModel configured + * - No OPENROUTER_API_KEY credential + * - Empty context snippet + * - LLM call failure (network, auth, etc.) + * - LLM call exceeds 5s timeout + * - LLM returns empty output + */ +export async function generateAckMessage( + agentType: string, + contextSnippet: string, + projectId: string, +): Promise { + const fallback = getStaticFallback(agentType); + + // No context to work with — use static message + if (!contextSnippet.trim()) { + return fallback; + } + + let restoreEnv: (() => void) | undefined; + + try { + // Load config to get progressModel + const config = await loadConfig(); + const progressModel = config.defaults.progressModel; + if (!progressModel) { + return fallback; + } + + // Resolve API key + const apiKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); + if (!apiKey) { + return fallback; + } + + // Temporarily inject API key into process.env (same pattern as llmEnv.ts) + const previousKey = process.env.OPENROUTER_API_KEY; + process.env.OPENROUTER_API_KEY = apiKey; + restoreEnv = () => { + if (previousKey === undefined) { + process.env.OPENROUTER_API_KEY = undefined; + } else { + process.env.OPENROUTER_API_KEY = previousKey; + } + }; + + // Single-shot LLM call with timeout + const llmPromise = callAckModel(progressModel, agentType, contextSnippet); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('Ack message generation timed out')), ACK_TIMEOUT_MS); + }); + + const result = await Promise.race([llmPromise, timeoutPromise]); + + if (!result || !result.trim()) { + return fallback; + } + + return result.trim(); + } catch (err) { + console.warn('[Router] Ack message generation failed (using static fallback):', String(err)); + return fallback; + } finally { + restoreEnv?.(); + } +} + +/** + * Make the actual single-shot LLM call to generate an ack message. + */ +async function callAckModel( + model: string, + agentType: string, + contextSnippet: string, +): Promise { + const client = new LLMist({ customModels: CUSTOM_MODELS as ModelSpec[] }); + + const builder = new AgentBuilder(client) + .withModel(model) + .withTemperature(0) + .withSystem(ACK_SYSTEM_PROMPT) + .withMaxIterations(1) + .withGadgets(); + + const userPrompt = `Agent type: ${agentType}\n\nRequest context:\n${contextSnippet}`; + const agent = builder.ask(userPrompt); + + const outputLines: string[] = []; + for await (const event of agent.run()) { + if (event.type === 'text' && event.content) { + outputLines.push(event.content); + } + } + + return outputLines.join('\n').trim(); +} diff --git a/src/router/github.ts b/src/router/github.ts index d5b3ba32..71d4170e 100644 --- a/src/router/github.ts +++ b/src/router/github.ts @@ -5,7 +5,6 @@ * and job queuing for GitHub webhook events. */ -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; import { findProjectByRepo } from '../config/provider.js'; import { type PersonaIdentities, @@ -14,6 +13,7 @@ import { } from '../github/personas.js'; import type { TriggerRegistry } from '../triggers/registry.js'; import type { TriggerContext } from '../types/index.js'; +import { extractGitHubContext, generateAckMessage } from './ackMessageGenerator.js'; import { postGitHubAck, resolveGitHubTokenForAck } from './acknowledgments.js'; import { loadProjectConfig } from './config.js'; import { extractPRNumber } from './notifications.js'; @@ -55,8 +55,8 @@ export async function tryPostGitHubAck( const match = triggerRegistry.matchTrigger(ctx); if (!match) return undefined; - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; + const context = extractGitHubContext(payload, eventType); + const message = await generateAckMessage(match.agentType, context, fullProject.id); const resolved = await resolveGitHubTokenForAck(repoFullName); if (!resolved) return undefined; diff --git a/src/router/jira.ts b/src/router/jira.ts index 576110f1..db596499 100644 --- a/src/router/jira.ts +++ b/src/router/jira.ts @@ -5,9 +5,9 @@ * for JIRA webhook events. */ -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; import type { TriggerRegistry } from '../triggers/registry.js'; import type { ProjectConfig, TriggerContext } from '../types/index.js'; +import { extractJiraContext, generateAckMessage } from './ackMessageGenerator.js'; import { postJiraAck, resolveJiraBotAccountId } from './acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from './config.js'; import { type CascadeJob, addJob } from './queue.js'; @@ -35,8 +35,8 @@ export async function tryPostJiraAck( const match = triggerRegistry.matchTrigger(ctx); if (!match) return undefined; - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; + const context = extractJiraContext(payload); + const message = await generateAckMessage(match.agentType, context, projectId); const commentId = await postJiraAck(projectId, issueKey, message); return commentId ?? undefined; diff --git a/src/router/trello.ts b/src/router/trello.ts index d512b393..7e8f0aa9 100644 --- a/src/router/trello.ts +++ b/src/router/trello.ts @@ -5,9 +5,9 @@ * for Trello webhook events. */ -import { INITIAL_MESSAGES } from '../config/agentMessages.js'; import type { TriggerRegistry } from '../triggers/registry.js'; import type { TriggerContext } from '../types/index.js'; +import { extractTrelloContext, generateAckMessage } from './ackMessageGenerator.js'; import { postTrelloAck, resolveTrelloBotMemberId } from './acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from './config.js'; import { type CascadeJob, addJob } from './queue.js'; @@ -158,8 +158,8 @@ export async function tryPostTrelloAck( const match = triggerRegistry.matchTrigger(ctx); if (!match) return undefined; - const message = INITIAL_MESSAGES[match.agentType]; - if (!message) return undefined; + const context = extractTrelloContext(payload); + const message = await generateAckMessage(match.agentType, context, projectId); const commentId = await postTrelloAck(projectId, cardId, message); return commentId ?? undefined; diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts new file mode 100644 index 00000000..4cec7f74 --- /dev/null +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports before importing the module under test +vi.mock('llmist', () => { + const mockRun = vi.fn(); + const mockBuilder = { + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + withGadgets: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: mockRun }), + }; + return { + LLMist: vi.fn().mockImplementation(() => ({})), + AgentBuilder: vi.fn().mockImplementation(() => mockBuilder), + __mockBuilder: mockBuilder, + __mockRun: mockRun, + }; +}); + +vi.mock('../../../src/config/provider.js', () => ({ + loadConfig: vi.fn(), + getOrgCredential: vi.fn(), +})); + +vi.mock('../../../src/config/customModels.js', () => ({ + CUSTOM_MODELS: [], +})); + +vi.mock('../../../src/config/agentMessages.js', () => ({ + INITIAL_MESSAGES: { + implementation: + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + briefing: + '**📋 Analyzing brief** — Reading the card and gathering context to create a clear brief...', + review: '**🔍 Reviewing code** — Examining the PR changes for quality and correctness...', + }, +})); + +import { getOrgCredential, loadConfig } from '../../../src/config/provider.js'; +import { + extractGitHubContext, + extractJiraContext, + extractTrelloContext, + generateAckMessage, +} from '../../../src/router/ackMessageGenerator.js'; + +// Access llmist mocks — biome-ignore lint/suspicious/noExplicitAny: accessing test-only mock internals +const llmistModule = (await import('llmist')) as Record; +const mockRun = llmistModule.__mockRun; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Context extractors +// --------------------------------------------------------------------------- + +describe('extractTrelloContext', () => { + it('extracts card name from payload', () => { + const payload = { + action: { + type: 'updateCard', + data: { + card: { id: 'card1', name: 'Add dark mode support' }, + }, + }, + }; + const result = extractTrelloContext(payload); + expect(result).toBe('Card: Add dark mode support'); + }); + + it('extracts card name and comment text for commentCard actions', () => { + const payload = { + action: { + type: 'commentCard', + data: { + card: { id: 'card1', name: 'Fix auth bug' }, + text: 'Please also check the session handling', + }, + }, + }; + const result = extractTrelloContext(payload); + expect(result).toContain('Card: Fix auth bug'); + expect(result).toContain('Comment: Please also check the session handling'); + }); + + it('returns empty string for null payload', () => { + expect(extractTrelloContext(null)).toBe(''); + }); + + it('returns empty string for payload without action', () => { + expect(extractTrelloContext({})).toBe(''); + }); + + it('returns empty string for payload without data', () => { + expect(extractTrelloContext({ action: { type: 'updateCard' } })).toBe(''); + }); + + it('truncates long context to max length', () => { + const longName = 'A'.repeat(600); + const payload = { + action: { + type: 'updateCard', + data: { card: { id: 'card1', name: longName } }, + }, + }; + const result = extractTrelloContext(payload); + // "Card: " is 6 chars, so total should be truncated to 500 + "…" + expect(result.length).toBeLessThanOrEqual(501); + expect(result.endsWith('…')).toBe(true); + }); +}); + +describe('extractGitHubContext', () => { + it('extracts PR title from pull_request event', () => { + const payload = { + pull_request: { title: 'feat: add dark mode', number: 42 }, + }; + const result = extractGitHubContext(payload, 'pull_request'); + expect(result).toBe('PR: feat: add dark mode'); + }); + + it('extracts PR title and comment body from issue_comment event', () => { + const payload = { + pull_request: { title: 'feat: add dark mode' }, + comment: { body: '@cascade please fix the linting errors' }, + }; + const result = extractGitHubContext(payload, 'issue_comment'); + expect(result).toContain('PR: feat: add dark mode'); + expect(result).toContain('Comment: @cascade please fix the linting errors'); + }); + + it('extracts PR title and review body from pull_request_review event', () => { + const payload = { + pull_request: { title: 'fix: auth bug' }, + review: { body: 'Please handle the edge case for expired tokens' }, + }; + const result = extractGitHubContext(payload, 'pull_request_review'); + expect(result).toContain('PR: fix: auth bug'); + expect(result).toContain('Review: Please handle the edge case for expired tokens'); + }); + + it('extracts comment from pull_request_review_comment event', () => { + const payload = { + pull_request: { title: 'refactor: cleanup' }, + comment: { body: 'This function should be extracted' }, + }; + const result = extractGitHubContext(payload, 'pull_request_review_comment'); + expect(result).toContain('Comment: This function should be extracted'); + }); + + it('returns empty string for null payload', () => { + expect(extractGitHubContext(null, 'pull_request')).toBe(''); + }); + + it('returns empty string for payload without PR', () => { + expect(extractGitHubContext({}, 'check_suite')).toBe(''); + }); + + it('truncates long context', () => { + const longTitle = 'B'.repeat(600); + const payload = { pull_request: { title: longTitle } }; + const result = extractGitHubContext(payload, 'pull_request'); + expect(result.length).toBeLessThanOrEqual(501); + expect(result.endsWith('…')).toBe(true); + }); +}); + +describe('extractJiraContext', () => { + it('extracts issue summary from payload', () => { + const payload = { + issue: { + key: 'PROJ-123', + fields: { summary: 'Implement user authentication' }, + }, + }; + const result = extractJiraContext(payload); + expect(result).toBe('Issue: Implement user authentication'); + }); + + it('extracts issue summary and comment body', () => { + const payload = { + issue: { + key: 'PROJ-123', + fields: { summary: 'Fix login bug' }, + }, + comment: { body: 'This also affects the password reset flow' }, + }; + const result = extractJiraContext(payload); + expect(result).toContain('Issue: Fix login bug'); + expect(result).toContain('Comment: This also affects the password reset flow'); + }); + + it('returns empty string for null payload', () => { + expect(extractJiraContext(null)).toBe(''); + }); + + it('returns empty string for payload without issue', () => { + expect(extractJiraContext({})).toBe(''); + }); + + it('extracts comment even without issue', () => { + const payload = { + comment: { body: 'Some standalone comment' }, + }; + const result = extractJiraContext(payload); + expect(result).toBe('Comment: Some standalone comment'); + }); + + it('truncates long context', () => { + const longSummary = 'C'.repeat(600); + const payload = { + issue: { key: 'PROJ-1', fields: { summary: longSummary } }, + }; + const result = extractJiraContext(payload); + expect(result.length).toBeLessThanOrEqual(501); + expect(result.endsWith('…')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// generateAckMessage +// --------------------------------------------------------------------------- + +describe('generateAckMessage', () => { + function setupHappyPath(llmResponse: string) { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue('sk-test-key'); + + // Mock the async iterator returned by agent.run() + async function* fakeRun() { + yield { type: 'text' as const, content: llmResponse }; + } + mockRun.mockReturnValue(fakeRun()); + } + + it('returns LLM-generated message on happy path', async () => { + setupHappyPath('**🚀 Implementing dark mode** — Adding dark mode support to the application.'); + + const result = await generateAckMessage('implementation', 'Card: Add dark mode support', 'p1'); + + expect(result).toBe( + '**🚀 Implementing dark mode** — Adding dark mode support to the application.', + ); + expect(loadConfig).toHaveBeenCalled(); + expect(getOrgCredential).toHaveBeenCalledWith('p1', 'OPENROUTER_API_KEY'); + }); + + it('falls back to static message when context snippet is empty', async () => { + const result = await generateAckMessage('implementation', '', 'p1'); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + expect(loadConfig).not.toHaveBeenCalled(); + }); + + it('falls back to static message when context is only whitespace', async () => { + const result = await generateAckMessage('implementation', ' ', 'p1'); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + expect(loadConfig).not.toHaveBeenCalled(); + }); + + it('falls back to static message when progressModel is not configured', async () => { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: '' }, + } as never); + + const result = await generateAckMessage('implementation', 'Card: Test', 'p1'); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + }); + + it('falls back to static message when no API key', async () => { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue(null); + + const result = await generateAckMessage('briefing', 'Card: Test', 'p1'); + + expect(result).toBe( + '**📋 Analyzing brief** — Reading the card and gathering context to create a clear brief...', + ); + }); + + it('falls back to static message when LLM call throws', async () => { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue('sk-test-key'); + mockRun.mockImplementation(() => { + throw new Error('Network error'); + }); + + const result = await generateAckMessage('implementation', 'Card: Test', 'p1'); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + }); + + it('falls back to static message when LLM returns empty output', async () => { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue('sk-test-key'); + + async function* emptyRun() { + // Yields nothing + } + mockRun.mockReturnValue(emptyRun()); + + const result = await generateAckMessage('implementation', 'Card: Test', 'p1'); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + }); + + it('falls back to generic message for unknown agent types', async () => { + const result = await generateAckMessage('unknown-agent', '', 'p1'); + + expect(result).toBe('**⚙️ Working on it** — Processing your request...'); + }); + + it('restores process.env after successful call', async () => { + const originalKey = process.env.OPENROUTER_API_KEY; + setupHappyPath('**🚀 Test message**'); + + await generateAckMessage('implementation', 'Card: Test', 'p1'); + + expect(process.env.OPENROUTER_API_KEY).toBe(originalKey); + }); + + it('restores process.env after failed call', async () => { + const originalKey = process.env.OPENROUTER_API_KEY; + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue('sk-test-key'); + mockRun.mockImplementation(() => { + throw new Error('LLM error'); + }); + + await generateAckMessage('implementation', 'Card: Test', 'p1'); + + expect(process.env.OPENROUTER_API_KEY).toBe(originalKey); + }); + + it('falls back to static message on timeout', async () => { + vi.mocked(loadConfig).mockResolvedValue({ + defaults: { progressModel: 'openrouter:google/gemini-2.5-flash-lite' }, + } as never); + vi.mocked(getOrgCredential).mockResolvedValue('sk-test-key'); + + // Simulate a call that never resolves (will be beaten by the 5s timeout) + let resolveHang: () => void; + const hangForever = new Promise((r) => { + resolveHang = r; + }); + async function* slowRun() { + await hangForever; + yield { type: 'text' as const, content: 'too late' }; + } + mockRun.mockReturnValue(slowRun()); + + const result = await generateAckMessage('implementation', 'Card: Test', 'p1'); + + // Clean up the hanging promise so it doesn't leak + resolveHang?.(); + + expect(result).toBe( + '**🚀 Implementing changes** — Writing code, running tests, and preparing a PR...', + ); + }, 10_000); +}); diff --git a/tests/unit/router/github.test.ts b/tests/unit/router/github.test.ts index 4e90ffee..1298cc02 100644 --- a/tests/unit/router/github.test.ts +++ b/tests/unit/router/github.test.ts @@ -20,8 +20,9 @@ vi.mock('../../../src/router/notifications.js', () => ({ vi.mock('../../../src/router/pre-actions.js', () => ({ addEyesReactionToPR: vi.fn(), })); -vi.mock('../../../src/config/agentMessages.js', () => ({ - INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ + extractGitHubContext: vi.fn().mockReturnValue('PR: Test PR'), + generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); vi.mock('../../../src/config/provider.js', () => ({ findProjectByRepo: vi.fn(), diff --git a/tests/unit/router/jira.test.ts b/tests/unit/router/jira.test.ts index 39a76352..6e0c6248 100644 --- a/tests/unit/router/jira.test.ts +++ b/tests/unit/router/jira.test.ts @@ -14,8 +14,9 @@ vi.mock('../../../src/router/acknowledgments.js', () => ({ postJiraAck: vi.fn(), resolveJiraBotAccountId: vi.fn(), })); -vi.mock('../../../src/config/agentMessages.js', () => ({ - INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ + extractJiraContext: vi.fn().mockReturnValue('Issue: Test issue'), + generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); import { resolveJiraBotAccountId } from '../../../src/router/acknowledgments.js'; diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index 5abd6378..bde90102 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -14,8 +14,9 @@ vi.mock('../../../src/router/acknowledgments.js', () => ({ postTrelloAck: vi.fn(), resolveTrelloBotMemberId: vi.fn(), })); -vi.mock('../../../src/config/agentMessages.js', () => ({ - INITIAL_MESSAGES: { implementation: 'Starting implementation...' }, +vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ + extractTrelloContext: vi.fn().mockReturnValue('Card: Test card'), + generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); import { postTrelloAck, resolveTrelloBotMemberId } from '../../../src/router/acknowledgments.js'; From 7b32d7e696c2268d865cba622b79e634fdeb104f Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 22 Feb 2026 17:21:36 +0000 Subject: [PATCH 4/4] docs: add local CLI invocation instructions to CLAUDE.md Document that `node bin/cascade.js` is needed in development since the `cascade` binary isn't globally installed. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 467aa3fb..c91b116e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,10 +292,20 @@ psql $DATABASE_URL -c "INSERT INTO users (org_id, email, password_hash, name, ro CASCADE includes a `cascade` CLI for managing the platform from the terminal. It consumes the same tRPC endpoints as the web dashboard — no business logic duplication, full type safety. +### Running the CLI + +In production the `cascade` binary is available globally. In development, run it via: + +```bash +npm run build # Compile TypeScript (required before first use) +node bin/cascade.js # Run any CLI command +``` + +All examples below use the bare `cascade` name — substitute `node bin/cascade.js` when running locally. + ### Setup ```bash -npm run build # Compile TypeScript cascade login --server http://localhost:3000 --email you@example.com --password secret cascade whoami # Verify session ```