From d7a0eae4ef5fe0d849bfba24060d377c18fae0cc Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 22 Feb 2026 18:43:27 +0000 Subject: [PATCH 01/14] feat(github): replace hardcoded agent messages with LLM-generated ack messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub agents had hardcoded initial messages in three places: headerMessage in agent definitions, preExecute in Claude Code profiles, and postAcknowledgmentComment in the webhook handler. Meanwhile, the router already had a working LLM ack mechanism that generated contextual messages. In router+worker mode this created duplicate comments — one LLM-generated ack from the router, then a second hardcoded one from the agent. This change makes the LLM-generated ack the single initial message for all GitHub agents by plumbing ackMessage through the entire pipeline (queue, worker, webhook handler, agent execution) and teaching agents to reuse pre-existing ack comments instead of posting new ones. Key changes: - Add ackMessage field to AgentInput, GitHubJob, and webhook queue - tryPostGitHubAck now returns { commentId, message } instead of just number - postAcknowledgmentComment handles all GitHub agent types (not just respond-to-review/respond-to-pr-comment) using generateAckMessage() - executeGitHubAgent resolves effective header: ackMessage > INITIAL_MESSAGES > definition.headerMessage, and reuses ack comment when ackCommentId exists - Remove hardcoded headerMessage from review, respond-to-review, respond-to-pr-comment, respond-to-ci agent definitions - Claude Code profile preExecute hooks skip when ackCommentId exists - Remove acknowledgmentCommentId from PRResponseAgentInput (unified to ackCommentId on AgentInput) Co-Authored-By: Claude Opus 4.6 --- src/agents/respond-to-ci.ts | 1 - src/agents/respond-to-pr-comment.ts | 1 - src/agents/respond-to-review.ts | 1 - src/agents/review.ts | 1 - src/agents/shared/githubAgent.ts | 47 +++++-- src/agents/shared/prResponseAgent.ts | 19 +-- src/backends/agent-profiles.ts | 18 ++- src/router/github.ts | 14 +- src/router/queue.ts | 1 + src/triggers/github/webhook-handler.ts | 73 +++++++--- src/triggers/shared/webhook-queue.ts | 7 +- src/types/index.ts | 2 + src/utils/webhookQueue.ts | 3 + src/worker-entry.ts | 2 + .../agents/shared/prResponseAgent.test.ts | 19 +-- tests/unit/backends/agent-profiles.test.ts | 124 +++++++++++++++++ tests/unit/router/github.test.ts | 129 +++++++++++++++++- tests/unit/triggers/webhook-queue.test.ts | 37 ++++- tests/unit/utils/webhookQueue.test.ts | 28 ++++ 19 files changed, 439 insertions(+), 88 deletions(-) diff --git a/src/agents/respond-to-ci.ts b/src/agents/respond-to-ci.ts index 34919ac0..00425dd4 100644 --- a/src/agents/respond-to-ci.ts +++ b/src/agents/respond-to-ci.ts @@ -292,7 +292,6 @@ Use these values when calling GitHub gadgets (GetPRDetails, PostPRComment, Updat const ciAgentDefinition: GitHubAgentDefinition = { agentType: 'respond-to-ci', - headerMessage: '🤖 Working on fixing CI failures...', initialCommentDescription: 'Acknowledge CI failures', timeoutMessage: '⚠️ CI fix agent timed out while attempting to fix failures.', loggerPrefix: 'ci', diff --git a/src/agents/respond-to-pr-comment.ts b/src/agents/respond-to-pr-comment.ts index 26abc141..4b5b0a41 100644 --- a/src/agents/respond-to-pr-comment.ts +++ b/src/agents/respond-to-pr-comment.ts @@ -16,7 +16,6 @@ const respondToPRCommentDefinition: GitHubAgentDefinition< PRResponseContextData > = { agentType: 'respond-to-pr-comment', - headerMessage: '🤖 Working on your request...', initialCommentDescription: 'Acknowledge PR comment request', timeoutMessage: '⚠️ PR comment agent timed out while working on the request.', loggerPrefix: 'pr-comment', diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts index 6c5c0299..1e3f333f 100644 --- a/src/agents/respond-to-review.ts +++ b/src/agents/respond-to-review.ts @@ -15,7 +15,6 @@ const respondToReviewDefinition: GitHubAgentDefinition< PRResponseContextData > = { agentType: 'respond-to-review', - headerMessage: '🤖 Working on addressing the review feedback...', initialCommentDescription: 'Acknowledge review feedback', timeoutMessage: '⚠️ Review agent timed out while addressing feedback.', loggerPrefix: 'review', diff --git a/src/agents/review.ts b/src/agents/review.ts index 25eaba9b..f8761592 100644 --- a/src/agents/review.ts +++ b/src/agents/review.ts @@ -141,7 +141,6 @@ ${skippedFiles.map((f) => `- ${f}`).join('\n')}`; const reviewAgentDefinition: GitHubAgentDefinition = { agentType: 'review', - headerMessage: '🔍 Reviewing PR...', initialCommentDescription: 'Post initial review status comment', timeoutMessage: '⚠️ Review agent timed out while reviewing the PR.', loggerPrefix: 'review', diff --git a/src/agents/shared/githubAgent.ts b/src/agents/shared/githubAgent.ts index 799784c8..94f71bc6 100644 --- a/src/agents/shared/githubAgent.ts +++ b/src/agents/shared/githubAgent.ts @@ -1,6 +1,7 @@ import type { ModelSpec } from 'llmist'; import { createProgressMonitor } from '../../backends/progress.js'; +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; import { CUSTOM_MODELS } from '../../config/customModels.js'; import { recordInitialComment } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; @@ -51,7 +52,8 @@ export interface GitHubAgentDefinition< TContext extends GitHubAgentContext, > { agentType: string; - headerMessage: string; + /** Static header message — last-resort fallback when no ackMessage or INITIAL_MESSAGES entry. */ + headerMessage?: string; initialCommentDescription: string; timeoutMessage: string; loggerPrefix: string; @@ -124,6 +126,16 @@ export async function executeGitHubAgent< if (earlyResult) return earlyResult; } + // Resolve effective header: ackMessage (LLM-generated) > INITIAL_MESSAGES > definition fallback + const effectiveHeader = + (input.ackMessage as string | undefined) ?? + INITIAL_MESSAGES[definition.agentType] ?? + definition.headerMessage ?? + INITIAL_MESSAGES.implementation; + + // Pre-existing ack comment from router or webhook handler + const preExistingAckId = input.ackCommentId as number | undefined; + const runLifecycle = () => executeAgentLifecycle({ loggerIdentifier: `${definition.loggerPrefix}-${prNumber}`, @@ -172,24 +184,37 @@ export async function executeGitHubAgent< }), injectSyntheticCalls: async ({ builder, ctx, trackingContext, repoDir }) => { - const initialComment = await definition.postInitialComment( - input, - id, - definition.headerMessage, - ); - recordInitialComment(initialComment.id); + let initialCommentId: number; + let initialCommentHtmlUrl: string; + let gadgetName: string; + + if (preExistingAckId) { + // Ack comment already posted by router/webhook-handler — reuse it + recordInitialComment(preExistingAckId); + initialCommentId = preExistingAckId; + initialCommentHtmlUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}#issuecomment-${preExistingAckId}`; + gadgetName = 'PostPRComment'; + } else { + // No pre-existing ack — post initial comment now + const initialComment = await definition.postInitialComment(input, id, effectiveHeader); + recordInitialComment(initialComment.id); + initialCommentId = initialComment.id; + initialCommentHtmlUrl = initialComment.htmlUrl; + gadgetName = initialComment.gadgetName; + } + const withComment = injectSyntheticCall( builder, trackingContext, - initialComment.gadgetName, + gadgetName, { comment: definition.initialCommentDescription, owner, repo, prNumber, - body: definition.headerMessage, + body: effectiveHeader, }, - `Comment posted (id: ${initialComment.id}): ${initialComment.htmlUrl}`, + `Comment posted (id: ${initialCommentId}): ${initialCommentHtmlUrl}`, 'gc_initial_comment', ); @@ -211,7 +236,7 @@ export async function executeGitHubAgent< progressModel: input.config.defaults.progressModel, intervalMinutes: input.config.defaults.progressIntervalMinutes, customModels: CUSTOM_MODELS as ModelSpec[], - github: { owner, repo, headerMessage: definition.headerMessage }, + github: { owner, repo, headerMessage: effectiveHeader }, }), interactive, diff --git a/src/agents/shared/prResponseAgent.ts b/src/agents/shared/prResponseAgent.ts index ca4d7418..4f9a739b 100644 --- a/src/agents/shared/prResponseAgent.ts +++ b/src/agents/shared/prResponseAgent.ts @@ -2,13 +2,8 @@ import { githubClient } from '../../github/client.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import type { TrackingContext } from '../utils/tracking.js'; import type { BuilderType } from './builderFactory.js'; -import type { - GitHubAgentContext, - GitHubAgentInput, - InitialCommentResult, - RepoIdentifier, -} from './githubAgent.js'; -import { createInitialPRComment } from './githubAgent.js'; +import type { GitHubAgentContext, GitHubAgentInput, RepoIdentifier } from './githubAgent.js'; +import { type InitialCommentResult, createInitialPRComment } from './githubAgent.js'; import { resolveModelConfig } from './modelResolution.js'; import { formatPRComments, @@ -33,7 +28,6 @@ export interface PRResponseAgentInput extends GitHubAgentInput { triggerCommentBody: string; triggerCommentPath: string; triggerCommentUrl: string; - acknowledgmentCommentId?: number; } export interface PRResponseContextData extends GitHubAgentContext { @@ -138,15 +132,6 @@ export async function postInitialPRResponseComment( id: RepoIdentifier, headerMessage: string, ): Promise { - if (input.acknowledgmentCommentId) { - const comment = await githubClient.updatePRComment( - id.owner, - id.repo, - input.acknowledgmentCommentId, - headerMessage, - ); - return { id: comment.id, htmlUrl: comment.htmlUrl, gadgetName: 'UpdatePRComment' }; - } return createInitialPRComment(input.prNumber, id, headerMessage); } diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts index d099ddb9..9bb76e89 100644 --- a/src/backends/agent-profiles.ts +++ b/src/backends/agent-profiles.ts @@ -23,6 +23,7 @@ import { buildWorkItemPrompt, } from '../agents/shared/taskPrompts.js'; import type { ContextFile } from '../agents/utils/setup.js'; +import { INITIAL_MESSAGES } from '../config/agentMessages.js'; import { ListDirectory } from '../gadgets/ListDirectory.js'; import { formatCheckStatus } from '../gadgets/github/core/getPRChecks.js'; import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; @@ -480,12 +481,16 @@ const reviewProfile: AgentProfile = { getLlmistGadgets: (_agentType) => buildReviewGadgets(), async preExecute({ input, logWriter }: PreExecuteParams): Promise { + // Skip if ack comment already posted by router or webhook handler + if (input.ackCommentId) return; + const repoFullName = input.repoFullName as string; const prNumber = input.prNumber as number; const { owner, repo } = parseRepoFullName(repoFullName); + const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES.review; logWriter('INFO', 'Posting initial review comment', { owner, repo, prNumber }); - await githubClient.createPRComment(owner, repo, prNumber, '🔍 Reviewing PR...'); + await githubClient.createPRComment(owner, repo, prNumber, message); }, }; @@ -519,17 +524,16 @@ const respondToCIProfile: AgentProfile = { getLlmistGadgets: (_agentType) => buildPRAgentGadgets(), async preExecute({ input, logWriter }: PreExecuteParams): Promise { + // Skip if ack comment already posted by router or webhook handler + if (input.ackCommentId) return; + const repoFullName = input.repoFullName as string; const prNumber = input.prNumber as number; const { owner, repo } = parseRepoFullName(repoFullName); + const message = (input.ackMessage as string | undefined) ?? INITIAL_MESSAGES['respond-to-ci']; logWriter('INFO', 'Posting initial CI fix comment', { owner, repo, prNumber }); - await githubClient.createPRComment( - owner, - repo, - prNumber, - '🤖 Working on fixing CI failures...', - ); + await githubClient.createPRComment(owner, repo, prNumber, message); }, }; diff --git a/src/router/github.ts b/src/router/github.ts index 71d4170e..5adb4e62 100644 --- a/src/router/github.ts +++ b/src/router/github.ts @@ -27,14 +27,14 @@ import { sendAcknowledgeReaction } from './reactions.js'; /** * Try to match a trigger and post an ack comment for a GitHub webhook. - * Returns the ack comment ID if posted, undefined otherwise. + * Returns the ack comment ID and message text if posted, undefined otherwise. */ export async function tryPostGitHubAck( eventType: string, repoFullName: string, payload: unknown, triggerRegistry: TriggerRegistry, -): Promise { +): Promise<{ commentId: number; message: string } | undefined> { const config = await loadProjectConfig(); const fullProject = config.fullProjects.find((fp) => fp.repo === repoFullName); if (!fullProject) return undefined; @@ -66,7 +66,7 @@ export async function tryPostGitHubAck( if (!prNumber) return undefined; const commentId = await postGitHubAck(repoFullName, prNumber, message, resolved.token); - return commentId ?? undefined; + return commentId != null ? { commentId, message } : undefined; } export async function isSelfAuthoredGitHubComment( @@ -147,8 +147,13 @@ export async function processGitHubWebhookEvent( // Try to post an ack comment via trigger matching (non-blocking best-effort) let ackCommentId: number | undefined; + let ackMessage: string | undefined; try { - ackCommentId = await tryPostGitHubAck(eventType, repoFullName, payload, triggerRegistry); + const ackResult = await tryPostGitHubAck(eventType, repoFullName, payload, triggerRegistry); + if (ackResult) { + ackCommentId = ackResult.commentId; + ackMessage = ackResult.message; + } } catch (err) { console.warn('[Router] GitHub ack comment failed (non-fatal):', String(err)); } @@ -161,6 +166,7 @@ export async function processGitHubWebhookEvent( repoFullName, receivedAt: new Date().toISOString(), ackCommentId, + ackMessage, }; // Fire pre-actions (non-blocking) before queueing diff --git a/src/router/queue.ts b/src/router/queue.ts index 31966b57..80714d32 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -33,6 +33,7 @@ export interface GitHubJob { repoFullName: string; receivedAt: string; ackCommentId?: number; + ackMessage?: string; } export interface JiraJob { diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 0e563755..1b0b1e08 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,8 +1,10 @@ +import { INITIAL_MESSAGES } from '../../config/agentMessages.js'; import { loadProjectConfigByRepo } from '../../config/provider.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; +import { extractGitHubContext, generateAckMessage } from '../../router/ackMessageGenerator.js'; import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; import { enqueueWebhook, @@ -48,33 +50,43 @@ async function updateInitialCommentWithError( }); } -async function postAcknowledgmentComment(result: TriggerResult): Promise { - if ( - (result.agentType !== 'respond-to-review' && result.agentType !== 'respond-to-pr-comment') || - !result.prNumber - ) { +async function postAcknowledgmentComment( + result: TriggerResult, + payload: unknown, + eventType: string, +): Promise { + if (!result.agentType || !result.prNumber) { return; } const input = result.agentInput as { repoFullName?: string; - acknowledgmentCommentId?: number; - commentAuthor?: string; + project?: ProjectConfig; }; if (!input.repoFullName) { return; } const { owner, repo } = parseRepoFullName(input.repoFullName); const prNumber = result.prNumber; - const message = - result.agentType === 'respond-to-pr-comment' - ? `💭 Thinking about your comment, @${input.commentAuthor ?? 'you'}...` - : '👀 Checking this out...'; + + // Generate LLM ack message, falling back to static INITIAL_MESSAGES + let message: string; + try { + const context = extractGitHubContext(payload, eventType); + const projectId = input.project?.id; + message = projectId + ? await generateAckMessage(result.agentType, context, projectId) + : (INITIAL_MESSAGES[result.agentType] ?? INITIAL_MESSAGES.implementation); + } catch { + message = INITIAL_MESSAGES[result.agentType] ?? INITIAL_MESSAGES.implementation; + } + const comment = await safeOperation( () => githubClient.createPRComment(owner, repo, prNumber, message), { action: 'post acknowledgment comment', prNumber }, ); if (comment) { - input.acknowledgmentCommentId = comment.id; + result.agentInput.ackCommentId = comment.id; + result.agentInput.ackMessage = message; } } @@ -128,7 +140,10 @@ async function runGitHubAgentJob( config: CascadeConfig, githubToken: string, registry: TriggerRegistry, + payload: unknown, + eventType: string, routerAckCommentId?: number, + routerAckMessage?: string, ): Promise { if (!result.agentType) return; // Use the persona token for the agent that will do the work (for ack comments) @@ -139,10 +154,15 @@ async function runGitHubAgentJob( prCommentToken = githubToken; } - // Skip worker-side ack if the router already posted one - if (!routerAckCommentId) { + // Skip worker-side ack if the router already posted one; otherwise generate one for all agents + if (routerAckCommentId) { + // Router already posted — just propagate the message text + if (routerAckMessage) { + result.agentInput.ackMessage = routerAckMessage; + } + } else { await withGitHubToken(prCommentToken, async () => { - await postAcknowledgmentComment(result); + await postAcknowledgmentComment(result, payload, eventType); }); } setProcessing(true); @@ -163,12 +183,13 @@ async function runGitHubAgentJob( function processNextQueuedGitHubWebhook(registry: TriggerRegistry): void { processNextQueuedWebhook( - (payload, eventType, ackCommentId) => + (payload, eventType, ackCommentId, ackMsg) => processGitHubWebhook( payload, eventType ?? 'pull_request_review_comment', registry, ackCommentId as number | undefined, + ackMsg, ), 'GitHub', (entry) => entry.eventType ?? 'pull_request_review_comment', @@ -180,6 +201,7 @@ export async function processGitHubWebhook( eventType: string, registry: TriggerRegistry, ackCommentId?: number, + ackMessage?: string, ): Promise { logger.info('Processing GitHub webhook', { eventType }); @@ -193,7 +215,7 @@ export async function processGitHubWebhook( } if (isCurrentlyProcessing()) { - const queued = enqueueWebhook(payload, eventType, ackCommentId); + const queued = enqueueWebhook(payload, eventType, ackCommentId, ackMessage); if (queued) { logger.info('Currently processing, GitHub webhook queued', { queueLength: getQueueLength(), @@ -234,10 +256,13 @@ export async function processGitHubWebhook( return; } - // Pass ack comment ID into agent input for ProgressMonitor pre-seeding + // Pass ack comment ID + message into agent input for ProgressMonitor pre-seeding if (ackCommentId) { result.agentInput.ackCommentId = ackCommentId; } + if (ackMessage) { + result.agentInput.ackMessage = ackMessage; + } logger.info('GitHub trigger matched', { agentType: result.agentType || '(no agent)', @@ -245,7 +270,17 @@ export async function processGitHubWebhook( }); if (result.agentType) { - await runGitHubAgentJob(result, project, config, githubToken, registry, ackCommentId); + await runGitHubAgentJob( + result, + project, + config, + githubToken, + registry, + payload, + eventType, + ackCommentId, + ackMessage, + ); } else { logger.info('Trigger completed without agent', { prNumber: result.prNumber }); } diff --git a/src/triggers/shared/webhook-queue.ts b/src/triggers/shared/webhook-queue.ts index 48892b9f..7cc8d74c 100644 --- a/src/triggers/shared/webhook-queue.ts +++ b/src/triggers/shared/webhook-queue.ts @@ -13,6 +13,7 @@ export function processNextQueuedWebhook( payload: unknown, eventType?: string, ackCommentId?: string | number, + ackMessage?: string, ) => Promise, label: string, getEventType?: (entry: { payload: unknown; eventType?: string }) => string | undefined, @@ -24,8 +25,10 @@ export function processNextQueuedWebhook( if (eventType) logContext.eventType = eventType; logger.info(`Processing queued ${label} webhook`, logContext); setImmediate(() => { - processWebhook(next.payload, eventType, next.ackCommentId).catch((err) => { - logger.error(`Failed to process queued ${label} webhook`, { error: String(err) }); + processWebhook(next.payload, eventType, next.ackCommentId, next.ackMessage).catch((err) => { + logger.error(`Failed to process queued ${label} webhook`, { + error: String(err), + }); }); }); } diff --git a/src/types/index.ts b/src/types/index.ts index ee72b897..59ef22e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,6 +41,8 @@ export interface AgentInput { // Router-posted ack comment ID — used by ProgressMonitor to update in-place ackCommentId?: string | number; + // Router/webhook-handler-posted ack message text — reused as initial comment header + ackMessage?: string; [key: string]: unknown; } diff --git a/src/utils/webhookQueue.ts b/src/utils/webhookQueue.ts index 3edda042..723f6733 100644 --- a/src/utils/webhookQueue.ts +++ b/src/utils/webhookQueue.ts @@ -6,6 +6,7 @@ interface QueuedWebhook { payload: unknown; eventType?: string; // Optional for backward compatibility (Trello doesn't need it) ackCommentId?: string | number; + ackMessage?: string; receivedAt: Date; } @@ -15,6 +16,7 @@ export function enqueueWebhook( payload: unknown, eventType?: string, ackCommentId?: string | number, + ackMessage?: string, ): boolean { if (queue.length >= MAX_QUEUE_SIZE) { logger.warn('Webhook queue full, rejecting', { @@ -28,6 +30,7 @@ export function enqueueWebhook( payload, eventType, ackCommentId, + ackMessage, receivedAt: new Date(), }); diff --git a/src/worker-entry.ts b/src/worker-entry.ts index b28efd85..ce709d07 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -45,6 +45,7 @@ interface GitHubJobData { repoFullName: string; receivedAt: string; ackCommentId?: number; + ackMessage?: string; } interface JiraJobData { @@ -201,6 +202,7 @@ async function main(): Promise { jobData.eventType, triggerRegistry, jobData.ackCommentId, + jobData.ackMessage, ); } else if (jobData.type === 'jira') { logger.info('[Worker] Processing JIRA job', { diff --git a/tests/unit/agents/shared/prResponseAgent.test.ts b/tests/unit/agents/shared/prResponseAgent.test.ts index 9065b763..9e0cc7de 100644 --- a/tests/unit/agents/shared/prResponseAgent.test.ts +++ b/tests/unit/agents/shared/prResponseAgent.test.ts @@ -122,24 +122,7 @@ describe('prResponseAgent shared module', () => { triggerCommentUrl: 'url', } as PRResponseAgentInput; - it('updates existing comment when acknowledgmentCommentId is set', async () => { - const input = { ...baseInput, acknowledgmentCommentId: 555 }; - mockGithub.updatePRComment.mockResolvedValue({ - id: 555, - htmlUrl: 'https://example.com/555', - } as ReturnType extends Promise ? R : never); - - const result = await postInitialPRResponseComment(input, id, 'header'); - - expect(mockGithub.updatePRComment).toHaveBeenCalledWith('org', 'repo', 555, 'header'); - expect(result).toEqual({ - id: 555, - htmlUrl: 'https://example.com/555', - gadgetName: 'UpdatePRComment', - }); - }); - - it('creates a new comment when no acknowledgmentCommentId', async () => { + it('creates a new comment via createInitialPRComment', async () => { mockCreateInitialPRComment.mockResolvedValue({ id: 999, htmlUrl: 'https://example.com/999', diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index 5bd4d464..36acc6f8 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -109,6 +109,9 @@ vi.mock('../../../src/github/client.js', () => ({ vi.mock('../../../src/agents/utils/setup.js', () => ({})); import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; +import { githubClient } from '../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); beforeEach(() => { vi.clearAllMocks(); @@ -193,6 +196,127 @@ describe('getAgentProfile', () => { it('has a preExecute hook', () => { expect(profile.preExecute).toBeDefined(); }); + + it('preExecute skips posting when ackCommentId exists', async () => { + const logWriter = vi.fn(); + await profile.preExecute?.({ + input: { + prNumber: 42, + prBranch: 'fix/ci', + repoFullName: 'acme/widgets', + ackCommentId: 12345, + }, + logWriter, + }); + + expect(mockGithub.createPRComment).not.toHaveBeenCalled(); + }); + + it('preExecute posts with ackMessage when no ackCommentId', async () => { + const logWriter = vi.fn(); + mockGithub.createPRComment.mockResolvedValue(undefined as never); + + await profile.preExecute?.({ + input: { + prNumber: 42, + prBranch: 'fix/ci', + repoFullName: 'acme/widgets', + ackMessage: 'On it — checking the CI failures...', + }, + logWriter, + }); + + expect(mockGithub.createPRComment).toHaveBeenCalledWith( + 'acme', + 'widgets', + 42, + 'On it — checking the CI failures...', + ); + }); + + it('preExecute falls back to INITIAL_MESSAGES when no ackCommentId or ackMessage', async () => { + const logWriter = vi.fn(); + mockGithub.createPRComment.mockResolvedValue(undefined as never); + + await profile.preExecute?.({ + input: { + prNumber: 42, + prBranch: 'fix/ci', + repoFullName: 'acme/widgets', + }, + logWriter, + }); + + expect(mockGithub.createPRComment).toHaveBeenCalledWith( + 'acme', + 'widgets', + 42, + expect.stringContaining('Fixing CI failures'), + ); + }); + }); + + describe('review profile preExecute', () => { + let profile: AgentProfile; + + beforeEach(() => { + profile = getAgentProfile('review'); + }); + + it('skips posting when ackCommentId exists', async () => { + const logWriter = vi.fn(); + await profile.preExecute?.({ + input: { + prNumber: 10, + repoFullName: 'org/repo', + ackCommentId: 999, + }, + logWriter, + }); + + expect(mockGithub.createPRComment).not.toHaveBeenCalled(); + }); + + it('posts with ackMessage when no ackCommentId', async () => { + const logWriter = vi.fn(); + mockGithub.createPRComment.mockResolvedValue(undefined as never); + + await profile.preExecute?.({ + input: { + prNumber: 10, + repoFullName: 'org/repo', + ackMessage: 'Looking into the PR changes now...', + }, + logWriter, + }); + + expect(mockGithub.createPRComment).toHaveBeenCalledWith( + 'org', + 'repo', + 10, + 'Looking into the PR changes now...', + ); + }); + + it('falls back to INITIAL_MESSAGES when no ackCommentId or ackMessage', async () => { + const logWriter = vi.fn(); + mockGithub.createPRComment.mockResolvedValue(undefined as never); + + await profile.preExecute?.({ + input: { + prNumber: 10, + repoFullName: 'org/repo', + }, + logWriter, + }); + + expect(mockGithub.createPRComment).toHaveBeenCalledWith( + 'org', + 'repo', + 10, + expect.stringContaining('Reviewing code'), + ); + }); }); describe('respond-to-pr-comment profile', () => { diff --git a/tests/unit/router/github.test.ts b/tests/unit/router/github.test.ts index 1298cc02..f81012da 100644 --- a/tests/unit/router/github.test.ts +++ b/tests/unit/router/github.test.ts @@ -34,12 +34,15 @@ vi.mock('../../../src/github/personas.js', () => ({ import { findProjectByRepo } from '../../../src/config/provider.js'; import { isCascadeBot, resolvePersonaIdentities } from '../../../src/github/personas.js'; -import { resolveGitHubTokenForAck } from '../../../src/router/acknowledgments.js'; +import { generateAckMessage } from '../../../src/router/ackMessageGenerator.js'; +import { postGitHubAck, resolveGitHubTokenForAck } from '../../../src/router/acknowledgments.js'; +import { loadProjectConfig } from '../../../src/router/config.js'; import { firePreActions, handleGitHubWebhook, isSelfAuthoredGitHubComment, processGitHubWebhookEvent, + tryPostGitHubAck, } from '../../../src/router/github.js'; import { extractPRNumber } from '../../../src/router/notifications.js'; import { addEyesReactionToPR } from '../../../src/router/pre-actions.js'; @@ -227,4 +230,128 @@ describe('processGitHubWebhookEvent', () => { expect(sendAcknowledgeReaction).not.toHaveBeenCalled(); }); + + it('stores ackCommentId and ackMessage on the job when ack succeeds', async () => { + // Setup: make tryPostGitHubAck succeed by mocking its dependencies + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ + agentType: 'review', + }); + vi.mocked(generateAckMessage).mockResolvedValue('Looking into the PR now...'); + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'proj-1' }, + } as never); + vi.mocked(extractPRNumber).mockReturnValue(42); + vi.mocked(postGitHubAck).mockResolvedValue(12345); + vi.mocked(addJob).mockResolvedValue('job-1'); + + await processGitHubWebhookEvent( + 'pull_request', + 'owner/repo', + { repository: { full_name: 'owner/repo' } }, + mockTriggerRegistry, + ); + + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'github', + ackCommentId: 12345, + ackMessage: 'Looking into the PR now...', + }), + ); + }); + + it('leaves ackMessage undefined when ack fails', async () => { + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue(null); + vi.mocked(addJob).mockResolvedValue('job-1'); + + await processGitHubWebhookEvent( + 'pull_request', + 'owner/repo', + { repository: { full_name: 'owner/repo' } }, + mockTriggerRegistry, + ); + + expect(addJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'github', + ackCommentId: undefined, + ackMessage: undefined, + }), + ); + }); +}); + +describe('tryPostGitHubAck', () => { + it('returns commentId and message on success', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ + agentType: 'review', + }); + vi.mocked(generateAckMessage).mockResolvedValue('Checking it out...'); + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'proj-1' }, + } as never); + vi.mocked(extractPRNumber).mockReturnValue(5); + vi.mocked(postGitHubAck).mockResolvedValue(777); + + const result = await tryPostGitHubAck( + 'pull_request_review', + 'owner/repo', + {}, + mockTriggerRegistry, + ); + + expect(result).toEqual({ commentId: 777, message: 'Checking it out...' }); + }); + + it('returns undefined when no trigger matches', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue(null); + + const result = await tryPostGitHubAck('pull_request', 'owner/repo', {}, mockTriggerRegistry); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when postGitHubAck returns null', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ + agentType: 'review', + }); + vi.mocked(generateAckMessage).mockResolvedValue('Msg'); + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'proj-1' }, + } as never); + vi.mocked(extractPRNumber).mockReturnValue(5); + vi.mocked(postGitHubAck).mockResolvedValue(null); + + const result = await tryPostGitHubAck( + 'pull_request_review', + 'owner/repo', + {}, + mockTriggerRegistry, + ); + + expect(result).toBeUndefined(); + }); }); diff --git a/tests/unit/triggers/webhook-queue.test.ts b/tests/unit/triggers/webhook-queue.test.ts index 534e9c19..308fb7e3 100644 --- a/tests/unit/triggers/webhook-queue.test.ts +++ b/tests/unit/triggers/webhook-queue.test.ts @@ -28,6 +28,7 @@ describe('processNextQueuedWebhook', () => { { action: 'test' }, undefined, // eventType comes from getEventType, not the queued entry undefined, // no ackCommentId + undefined, // no ackMessage ); }); @@ -39,7 +40,12 @@ describe('processNextQueuedWebhook', () => { await new Promise((resolve) => setImmediate(resolve)); - expect(processWebhook).toHaveBeenCalledWith({ action: 'test' }, 'pull_request', undefined); + expect(processWebhook).toHaveBeenCalledWith( + { action: 'test' }, + 'pull_request', + undefined, + undefined, + ); }); it('forwards ackCommentId through the queue', async () => { @@ -50,7 +56,12 @@ describe('processNextQueuedWebhook', () => { await new Promise((resolve) => setImmediate(resolve)); - expect(processWebhook).toHaveBeenCalledWith({ action: 'test' }, 'issue_comment', 'ack-123'); + expect(processWebhook).toHaveBeenCalledWith( + { action: 'test' }, + 'issue_comment', + 'ack-123', + undefined, + ); }); it('forwards numeric ackCommentId through the queue', async () => { @@ -61,7 +72,23 @@ describe('processNextQueuedWebhook', () => { await new Promise((resolve) => setImmediate(resolve)); - expect(processWebhook).toHaveBeenCalledWith({ action: 'test' }, undefined, 10646); + expect(processWebhook).toHaveBeenCalledWith({ action: 'test' }, undefined, 10646, undefined); + }); + + it('forwards ackMessage through the queue', async () => { + const processWebhook = vi.fn().mockResolvedValue(undefined); + enqueueWebhook({ action: 'test' }, 'issue_comment', 'ack-123', 'Looking into it...'); + + processNextQueuedWebhook(processWebhook, 'Test', (entry) => entry.eventType); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(processWebhook).toHaveBeenCalledWith( + { action: 'test' }, + 'issue_comment', + 'ack-123', + 'Looking into it...', + ); }); it('processes items in FIFO order preserving ackCommentId', async () => { @@ -73,12 +100,12 @@ describe('processNextQueuedWebhook', () => { processNextQueuedWebhook(processWebhook, 'Test'); await new Promise((resolve) => setImmediate(resolve)); - expect(processWebhook).toHaveBeenCalledWith({ order: 1 }, undefined, 'first-ack'); + expect(processWebhook).toHaveBeenCalledWith({ order: 1 }, undefined, 'first-ack', undefined); // Process second item processNextQueuedWebhook(processWebhook, 'Test'); await new Promise((resolve) => setImmediate(resolve)); - expect(processWebhook).toHaveBeenCalledWith({ order: 2 }, undefined, 'second-ack'); + expect(processWebhook).toHaveBeenCalledWith({ order: 2 }, undefined, 'second-ack', undefined); }); }); diff --git a/tests/unit/utils/webhookQueue.test.ts b/tests/unit/utils/webhookQueue.test.ts index 0a830a97..4c36c098 100644 --- a/tests/unit/utils/webhookQueue.test.ts +++ b/tests/unit/utils/webhookQueue.test.ts @@ -163,6 +163,34 @@ describe('webhookQueue', () => { }); }); + describe('ackMessage', () => { + it('preserves ackMessage through enqueue/dequeue', () => { + enqueueWebhook({ action: 'test' }, 'issue_comment', 'ack-1', 'Looking into it...'); + + const item = dequeueWebhook(); + + expect(item?.ackMessage).toBe('Looking into it...'); + }); + + it('defaults ackMessage to undefined when not provided', () => { + enqueueWebhook({ action: 'test' }, undefined, 'ack-1'); + + const item = dequeueWebhook(); + + expect(item?.ackMessage).toBeUndefined(); + }); + + it('preserves ackMessage alongside ackCommentId and eventType', () => { + enqueueWebhook({ action: 'test' }, 'pull_request', 42, 'On it — checking the PR...'); + + const item = dequeueWebhook(); + + expect(item?.eventType).toBe('pull_request'); + expect(item?.ackCommentId).toBe(42); + expect(item?.ackMessage).toBe('On it — checking the PR...'); + }); + }); + describe('getMaxQueueSize', () => { it('returns the maximum queue size', () => { const maxSize = getMaxQueueSize(); From 8d81311a81601d907309c5c1eca22776ef91857e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 19:08:27 +0000 Subject: [PATCH 02/14] feat(review): add configurable review trigger modes (ownPrsOnly, externalPrs, onReviewRequested) --- CLAUDE.md | 51 +++++ .../dashboard/projects/review-trigger-set.ts | 86 +++++++ src/config/triggerConfig.ts | 61 ++++- src/triggers/github/check-suite-success.ts | 26 ++- src/triggers/github/review-requested.ts | 5 +- tests/unit/config/triggerConfig.test.ts | 93 ++++++++ .../unit/triggers/check-suite-success.test.ts | 210 ++++++++++++++++++ tests/unit/triggers/review-requested.test.ts | 72 +++++- web/src/lib/trigger-agent-mapping.ts | 24 +- 9 files changed, 610 insertions(+), 18 deletions(-) create mode 100644 src/cli/dashboard/projects/review-trigger-set.ts diff --git a/CLAUDE.md b/CLAUDE.md index c91b116e..6c08a6bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,57 @@ const openrouterKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); Role definitions and env-var-key mappings are in `src/config/integrationRoles.ts`. +### Review Agent Trigger Modes + +The review agent supports three independent trigger modes via the `reviewTrigger` config in the SCM integration triggers. **All modes default to `false`** — existing behavior is preserved via a legacy fallback. + +| Mode | Description | +|------|-------------| +| `ownPrsOnly` | Trigger review when CI passes on PRs authored by the **implementer** persona | +| `externalPrs` | Trigger review when CI passes on PRs authored by **anyone** (including external contributors) | +| `onReviewRequested` | Trigger review when a CASCADE persona is **explicitly requested** as reviewer | + +#### Setting via CLI + +```bash +# Enable review for implementer PRs only (most common) +cascade projects review-trigger-set --own-prs-only + +# Enable review for external contributor PRs +cascade projects review-trigger-set --external-prs + +# Enable both CI-triggered modes +cascade projects review-trigger-set --own-prs-only --external-prs + +# Enable review when explicitly requested +cascade projects review-trigger-set --on-review-requested + +# Disable a mode +cascade projects review-trigger-set --no-own-prs-only +``` + +#### Setting via Dashboard + +In the **Agent Configs** tab, the `review` agent section shows three toggles under the SCM integration: +- **Own PRs Only** — CI-triggered review for implementer-authored PRs +- **External PRs** — CI-triggered review for all other PR authors +- **On Review Requested** — review triggered when a persona is explicitly requested + +#### Direct JSON Config + +```bash +cascade projects integration-set \ + --category scm --provider github --config '{}' \ + --triggers '{"reviewTrigger":{"ownPrsOnly":true,"externalPrs":false,"onReviewRequested":true}}' +``` + +#### Backward Compatibility + +When `reviewTrigger` is absent, the system falls back to legacy booleans: +- `checkSuiteSuccess` → `ownPrsOnly` (default `true` for existing projects) +- `reviewRequested` → `onReviewRequested` (default `false`) +- `externalPrs` always `false` in legacy mode (no legacy equivalent) + ## Claude Code Backend CASCADE supports using Claude Code SDK as an alternative agent backend. Configure per-project: diff --git a/src/cli/dashboard/projects/review-trigger-set.ts b/src/cli/dashboard/projects/review-trigger-set.ts new file mode 100644 index 00000000..b8e016ea --- /dev/null +++ b/src/cli/dashboard/projects/review-trigger-set.ts @@ -0,0 +1,86 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for configuring the review agent's trigger modes. + * + * Usage: + * cascade projects review-trigger-set [--own-prs-only] [--external-prs] [--on-review-requested] + * + * At least one flag must be provided. Pass `--no-` to disable a mode. + * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the + * `reviewTrigger` nested object in the project's SCM integration triggers. + */ +export default class ProjectsReviewTriggerSet extends DashboardCommand { + static override description = + 'Configure review trigger modes for a project (which PRs the review agent should review).'; + + static override aliases = ['projects:review-trigger-set']; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'own-prs-only': Flags.boolean({ + description: + 'Enable review agent for PRs authored by the implementer persona (after CI passes).', + allowNo: true, + default: undefined, + }), + 'external-prs': Flags.boolean({ + description: + 'Enable review agent for PRs authored by anyone outside the CASCADE personas (after CI passes).', + allowNo: true, + default: undefined, + }), + 'on-review-requested': Flags.boolean({ + description: + 'Enable review agent when a CASCADE persona is explicitly requested as reviewer.', + allowNo: true, + default: undefined, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsReviewTriggerSet); + + const ownPrsOnly = flags['own-prs-only']; + const externalPrs = flags['external-prs']; + const onReviewRequested = flags['on-review-requested']; + + if (ownPrsOnly === undefined && externalPrs === undefined && onReviewRequested === undefined) { + this.error( + 'At least one flag must be provided: --own-prs-only, --external-prs, --on-review-requested (use --no- to disable).', + ); + } + + // Build the nested reviewTrigger object with only the provided flags + const reviewTrigger: Record = {}; + if (ownPrsOnly !== undefined) reviewTrigger.ownPrsOnly = ownPrsOnly; + if (externalPrs !== undefined) reviewTrigger.externalPrs = externalPrs; + if (onReviewRequested !== undefined) reviewTrigger.onReviewRequested = onReviewRequested; + + try { + await this.client.projects.integrations.updateTriggers.mutate({ + projectId: args.id, + category: 'scm', + triggers: { reviewTrigger }, + }); + + if (flags.json) { + this.outputJson({ ok: true, reviewTrigger }); + return; + } + + const lines: string[] = [`Review trigger modes updated for project: ${args.id}`]; + if (ownPrsOnly !== undefined) lines.push(` ownPrsOnly: ${ownPrsOnly}`); + if (externalPrs !== undefined) lines.push(` externalPrs: ${externalPrs}`); + if (onReviewRequested !== undefined) lines.push(` onReviewRequested: ${onReviewRequested}`); + this.log(lines.join('\n')); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts index b28df502..e9a1f0a8 100644 --- a/src/config/triggerConfig.ts +++ b/src/config/triggerConfig.ts @@ -43,6 +43,21 @@ export const JiraTriggerConfigSchema = z.object({ commentMention: z.boolean().default(true), }); +/** + * Structured review trigger configuration with three independent modes. + * All modes default to `false` (safe default — users must explicitly opt in). + */ +export const ReviewTriggerConfigSchema = z.object({ + /** Trigger review for PRs authored by the implementer persona. */ + ownPrsOnly: z.boolean().default(false), + /** Trigger review for PRs authored by anyone (not just the implementer). */ + externalPrs: z.boolean().default(false), + /** Trigger review when a CASCADE persona is explicitly requested as reviewer. */ + onReviewRequested: z.boolean().default(false), +}); + +export type ReviewTriggerConfig = z.infer; + /** * Trigger configuration for GitHub integrations. * Existing triggers default to `true`; new triggers (`reviewRequested`, `prOpened`) default to `false`. @@ -54,16 +69,58 @@ export const GitHubTriggerConfigSchema = z.object({ prCommentMention: z.boolean().default(true), prReadyToMerge: z.boolean().default(true), prMerged: z.boolean().default(true), - /** New trigger: fires review agent when review is requested from a CASCADE persona. Default false (opt-in). */ + /** Legacy trigger: fires review agent when review is requested from a CASCADE persona. Default false (opt-in). */ reviewRequested: z.boolean().default(false), /** PR opened trigger. Default false (disabled until reviewed). */ prOpened: z.boolean().default(false), + /** + * Structured review trigger config with three independent modes. + * When present, takes precedence over the legacy `reviewRequested` / `checkSuiteSuccess` booleans. + */ + reviewTrigger: ReviewTriggerConfigSchema.optional(), }); export type TrelloTriggerConfig = z.infer; export type JiraTriggerConfig = z.infer; export type GitHubTriggerConfig = z.infer; +// ============================================================================ +// Review Trigger Resolution +// ============================================================================ + +/** + * Resolve the structured review trigger config from GitHub trigger config. + * + * Precedence: + * 1. `reviewTrigger` object (new structured config) — wins when present + * 2. Legacy booleans: `checkSuiteSuccess` → `ownPrsOnly`, `reviewRequested` → `onReviewRequested` + * 3. Bare defaults (no config) — all modes false + * + * This helper is the single source of truth for determining which review trigger modes are active. + */ +export function resolveReviewTriggerConfig( + config: Partial | undefined, +): ReviewTriggerConfig { + // New structured config wins when present + if (config?.reviewTrigger !== undefined) { + return { + ownPrsOnly: config.reviewTrigger.ownPrsOnly ?? false, + externalPrs: config.reviewTrigger.externalPrs ?? false, + onReviewRequested: config.reviewTrigger.onReviewRequested ?? false, + }; + } + + // Legacy fallback: map old boolean flags to structured modes + const legacyOwnPrsOnly = config?.checkSuiteSuccess ?? true; // existing default was true + const legacyOnReviewRequested = config?.reviewRequested ?? false; + + return { + ownPrsOnly: legacyOwnPrsOnly, + externalPrs: false, // no legacy equivalent — always false + onReviewRequested: legacyOnReviewRequested, + }; +} + // ============================================================================ // Helpers // ============================================================================ @@ -151,5 +208,7 @@ export function resolveGitHubTriggerEnabled( if (key === 'reviewRequested' || key === 'prOpened') return false; return true; } + // reviewTrigger is an object, not a boolean — skip it in this function + if (typeof value !== 'boolean') return true; return value; } diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index 566825ca..fb989719 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -1,4 +1,4 @@ -import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { resolveReviewTriggerConfig } from '../../config/triggerConfig.js'; import { type CheckSuiteStatus, githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -66,8 +66,9 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubCheckSuitePayload(ctx.payload)) return false; - // Check trigger config — default enabled for backward compatibility - if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'checkSuiteSuccess')) { + // Check trigger config — at least one CI-based review mode must be active + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + if (!reviewConfig.ownPrsOnly && !reviewConfig.externalPrs) { return false; } @@ -99,13 +100,24 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { // Fetch PR details const prDetails = await githubClient.getPR(owner, repo, prNumber); - // Gate on PR author being the implementer persona + // Gate on PR author based on configured review trigger modes if (!ctx.personaIdentities) return null; const implLogin = ctx.personaIdentities.implementer; - if (prDetails.user.login !== implLogin && prDetails.user.login !== `${implLogin}[bot]`) { - logger.info('PR not authored by implementer persona, skipping', { + const isImplementerPR = + prDetails.user.login === implLogin || prDetails.user.login === `${implLogin}[bot]`; + + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + const shouldTrigger = + (reviewConfig.ownPrsOnly && isImplementerPR) || + (reviewConfig.externalPrs && !isImplementerPR); + + if (!shouldTrigger) { + logger.info('PR author does not match any enabled review trigger mode, skipping', { prNumber, prAuthor: prDetails.user.login, + isImplementerPR, + ownPrsOnly: reviewConfig.ownPrsOnly, + externalPrs: reviewConfig.externalPrs, }); return null; } @@ -177,7 +189,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { return null; } - logger.info('All CI checks passed on implementer PR - triggering review', { + logger.info('All CI checks passed - triggering review', { prNumber, workItemId, headSha, diff --git a/src/triggers/github/review-requested.ts b/src/triggers/github/review-requested.ts index 14d4524c..af7fc097 100644 --- a/src/triggers/github/review-requested.ts +++ b/src/triggers/github/review-requested.ts @@ -1,4 +1,4 @@ -import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { resolveReviewTriggerConfig } from '../../config/triggerConfig.js'; import { isCascadeBot } from '../../github/personas.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -32,7 +32,8 @@ export class ReviewRequestedTrigger implements TriggerHandler { if (ctx.payload.action !== 'review_requested') return false; // Check trigger config — opt-in trigger, default disabled - if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'reviewRequested')) { + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + if (!reviewConfig.onReviewRequested) { return false; } diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 1da86683..0b912414 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -6,6 +6,7 @@ import { resolveGitHubTriggerEnabled, resolveJiraTriggerEnabled, resolveReadyToProcessEnabled, + resolveReviewTriggerConfig, resolveTrelloTriggerEnabled, } from '../../../src/config/triggerConfig.js'; @@ -71,6 +72,22 @@ describe('GitHubTriggerConfigSchema', () => { expect(result.reviewRequested).toBe(false); expect(result.prOpened).toBe(false); }); + + it('accepts reviewTrigger nested object', () => { + const result = GitHubTriggerConfigSchema.parse({ + reviewTrigger: { ownPrsOnly: true, externalPrs: false, onReviewRequested: true }, + }); + expect(result.reviewTrigger).toEqual({ + ownPrsOnly: true, + externalPrs: false, + onReviewRequested: true, + }); + }); + + it('reviewTrigger optional — absent by default', () => { + const result = GitHubTriggerConfigSchema.parse({}); + expect(result.reviewTrigger).toBeUndefined(); + }); }); describe('resolveTrelloTriggerEnabled', () => { @@ -212,3 +229,79 @@ describe('resolveReadyToProcessEnabled', () => { expect(resolveReadyToProcessEnabled(config, 'unknown-agent')).toBe(true); }); }); + +describe('resolveReviewTriggerConfig', () => { + it('maps legacy defaults when config is undefined (backward compatible)', () => { + // No config → legacy fallback: checkSuiteSuccess defaults to true → ownPrsOnly=true + // This preserves the existing behavior for projects with no trigger config + const result = resolveReviewTriggerConfig(undefined); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: false, onReviewRequested: false }); + }); + + it('returns ownPrsOnly=true (legacy default) when config has no review-related keys', () => { + // checkSuiteSuccess is undefined → legacy default is true → ownPrsOnly=true + const result = resolveReviewTriggerConfig({ checkSuiteFailure: true }); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: false, onReviewRequested: false }); + }); + + describe('new structured reviewTrigger config takes precedence', () => { + it('uses reviewTrigger object when present', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: true }, + // Legacy booleans present but should be ignored + checkSuiteSuccess: false, + reviewRequested: false, + }); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: true, onReviewRequested: true }); + }); + + it('uses reviewTrigger partial — missing fields default to false', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: true, externalPrs: false, onReviewRequested: false }, + }); + expect(result.ownPrsOnly).toBe(true); + expect(result.externalPrs).toBe(false); + expect(result.onReviewRequested).toBe(false); + }); + + it('externalPrs can be independently enabled', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: false, externalPrs: true, onReviewRequested: false }, + }); + expect(result.ownPrsOnly).toBe(false); + expect(result.externalPrs).toBe(true); + expect(result.onReviewRequested).toBe(false); + }); + }); + + describe('legacy boolean fallback', () => { + it('maps checkSuiteSuccess=true to ownPrsOnly=true (legacy default)', () => { + const result = resolveReviewTriggerConfig({ checkSuiteSuccess: true }); + expect(result.ownPrsOnly).toBe(true); + expect(result.externalPrs).toBe(false); + }); + + it('maps checkSuiteSuccess=false to ownPrsOnly=false', () => { + const result = resolveReviewTriggerConfig({ checkSuiteSuccess: false }); + expect(result.ownPrsOnly).toBe(false); + }); + + it('maps reviewRequested=true to onReviewRequested=true', () => { + const result = resolveReviewTriggerConfig({ reviewRequested: true }); + expect(result.onReviewRequested).toBe(true); + }); + + it('maps reviewRequested=false to onReviewRequested=false', () => { + const result = resolveReviewTriggerConfig({ reviewRequested: false }); + expect(result.onReviewRequested).toBe(false); + }); + + it('externalPrs is always false in legacy mode (no legacy equivalent)', () => { + const result = resolveReviewTriggerConfig({ + checkSuiteSuccess: true, + reviewRequested: true, + }); + expect(result.externalPrs).toBe(false); + }); + }); +}); diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index 02ec10d5..d8c977db 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -721,4 +721,214 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result?.workItemId).toBe('db-work-item'); }); }); + + describe('reviewTrigger mode-aware behavior', () => { + /** Project with only externalPrs enabled */ + const mockProjectExternalOnly = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: true, onReviewRequested: false }, + }, + }, + }; + + /** Project with both ownPrsOnly and externalPrs enabled */ + const mockProjectBothModes = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: false }, + }, + }, + }; + + /** Project with all modes disabled */ + const mockProjectNoModes = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: false }, + }, + }, + }; + + it('does not match when all modes are disabled', () => { + const ctx: TriggerContext = { + project: mockProjectNoModes, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('matches when externalPrs is enabled', () => { + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('matches when both modes are enabled', () => { + const ctx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('triggers for external PR author when externalPrs=true', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'External PR', + body: 'https://trello.com/c/abc123', + state: 'open', + headRef: 'feature/external', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'external-contributor' }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + + it('skips implementer PR when only externalPrs=true', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Implementer PR', + body: 'https://trello.com/c/abc123', + state: 'open', + headRef: 'feature/impl', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'cascade-impl' }, + }); + + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + + it('triggers for both authors when ownPrsOnly=true and externalPrs=true', async () => { + const setupMocks = (authorLogin: string) => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Test PR', + body: null, + state: 'open', + headRef: 'feature/test', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: authorLogin }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + }; + + // Implementer PR + setupMocks('cascade-impl'); + const implCtx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + const implResult = await trigger.handle(implCtx); + expect(implResult).not.toBeNull(); + + // External PR + vi.clearAllMocks(); + vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); + setupMocks('external-contributor'); + const extCtx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + const extResult = await trigger.handle(extCtx); + expect(extResult).not.toBeNull(); + }); + + it('backward compat: legacy checkSuiteSuccess=true still triggers for implementer PRs', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Test PR', + body: null, + state: 'open', + headRef: 'feature/test', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'cascade-impl' }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + // mockProject has no github triggers — resolves to legacy defaults (ownPrsOnly=true) + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + + it('backward compat: legacy checkSuiteSuccess=false skips even implementer PRs', () => { + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { triggers: { checkSuiteSuccess: false } }, + }, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); }); diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index b6156bba..2dcbb6e3 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -28,7 +28,7 @@ describe('ReviewRequestedTrigger', () => { // Review-requested is opt-in, default disabled }; - /** Project with reviewRequested trigger explicitly enabled */ + /** Project with reviewRequested trigger explicitly enabled (legacy style) */ const mockProjectWithReviewRequested = { ...mockProject, github: { @@ -36,6 +36,16 @@ describe('ReviewRequestedTrigger', () => { }, }; + /** Project with new structured reviewTrigger.onReviewRequested enabled */ + const mockProjectWithOnReviewRequested = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: true }, + }, + }, + }; + const mockPersonaIdentities = { implementer: 'cascade-impl', reviewer: 'cascade-reviewer', @@ -218,4 +228,64 @@ describe('ReviewRequestedTrigger', () => { expect(result?.agentType).toBe('review'); }); }); + + describe('new structured reviewTrigger config', () => { + it('matches when reviewTrigger.onReviewRequested=true', () => { + const ctx: TriggerContext = { + project: mockProjectWithOnReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match when reviewTrigger.onReviewRequested=false', () => { + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: false }, + }, + }, + }, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('new config takes precedence over legacy reviewRequested=false', () => { + // reviewTrigger.onReviewRequested=true wins even when legacy reviewRequested=false + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { + triggers: { + reviewRequested: false, // legacy says disabled + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: true }, + }, + }, + }, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('triggers review agent using new config', async () => { + const ctx: TriggerContext = { + project: mockProjectWithOnReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload('cascade-reviewer'), + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + }); }); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index 0f5b9e49..6dd6a810 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -123,18 +123,28 @@ export const AGENT_TRIGGER_MAP: Record = { ], review: [ { - key: 'checkSuiteSuccess', - label: 'Check Suite Success', - description: 'Trigger review agent when all CI checks pass.', - defaultValue: true, + key: 'reviewTrigger.ownPrsOnly', + label: 'Own PRs Only', + description: + 'Trigger review agent when CI passes on PRs authored by the implementer persona.', + defaultValue: false, + scmProvider: 'github', + category: 'scm', + }, + { + key: 'reviewTrigger.externalPrs', + label: 'External PRs', + description: + 'Trigger review agent when CI passes on PRs authored by anyone (not just the implementer).', + defaultValue: false, scmProvider: 'github', category: 'scm', }, { - key: 'reviewRequested', - label: 'Review Requested (opt-in)', + key: 'reviewTrigger.onReviewRequested', + label: 'On Review Requested', description: - 'Trigger review agent when review is requested from a CASCADE persona. Default disabled.', + 'Trigger review agent when a CASCADE persona is explicitly requested as reviewer.', defaultValue: false, scmProvider: 'github', category: 'scm', From af400c973a6283f5dbfead25342ea8c550a71bf8 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 20:13:35 +0000 Subject: [PATCH 03/14] refactor(router): extract shared credential resolution & platform API helpers --- src/router/acknowledgments.ts | 114 ++----- src/router/notifications.ts | 42 +-- src/router/platformClients.ts | 179 +++++++++++ src/router/pre-actions.ts | 20 +- src/router/reactions.ts | 45 +-- tests/unit/router/platformClients.test.ts | 357 ++++++++++++++++++++++ 6 files changed, 596 insertions(+), 161 deletions(-) create mode 100644 src/router/platformClients.ts create mode 100644 tests/unit/router/platformClients.test.ts diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index fd1f059f..3a78c9ce 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -11,14 +11,14 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; -import { - findProjectById, - findProjectByRepo, - getIntegrationCredential, -} from '../config/provider.js'; -import { getJiraConfig } from '../pm/config.js'; +import { findProjectByRepo } from '../config/provider.js'; import { markdownToAdf } from '../pm/jira/adf.js'; import type { ProjectConfig } from '../types/index.js'; +import { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './platformClients.js'; // --------------------------------------------------------------------------- // Trello @@ -29,17 +29,13 @@ export async function postTrelloAck( cardId: string, message: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) { console.warn('[Ack] Missing Trello credentials, skipping ack comment'); return null; } - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -61,16 +57,10 @@ export async function deleteTrelloAck( cardId: string, commentId: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { - return; - } + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return; - const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; try { await fetch(url, { method: 'DELETE' }); console.log('[Ack] Trello orphan ack deleted:', commentId); @@ -92,12 +82,7 @@ export async function postGitHubAck( const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; const response = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json', - }, + headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), body: JSON.stringify({ body: message }), }); @@ -120,11 +105,7 @@ export async function deleteGitHubAck( try { await fetch(url, { method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: resolveGitHubHeaders(token), }); console.log('[Ack] GitHub orphan ack deleted:', commentId); } catch (err) { @@ -141,27 +122,18 @@ export async function postJiraAck( issueKey: string, message: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const creds = await resolveJiraCredentials(projectId); + if (!creds) { console.warn('[Ack] Missing JIRA credentials, skipping ack comment'); return null; } - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); const adfBody = markdownToAdf(message); - const url = `${jiraBaseUrl}/rest/api/3/issue/${issueKey}/comment`; + const url = `${creds.baseUrl}/rest/api/3/issue/${issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: adfBody }), @@ -182,26 +154,15 @@ export async function deleteJiraAck( issueKey: string, commentId: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { - return; - } + const creds = await resolveJiraCredentials(projectId); + if (!creds) return; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); - const url = `${jiraBaseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; + const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; try { await fetch(url, { method: 'DELETE', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, }); @@ -227,23 +188,12 @@ export async function resolveJiraBotAccountId(projectId: string): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(job.projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(job.projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(job.projectId); + if (!creds) { console.warn('[Notifications] Missing Trello credentials in DB, skipping timeout notification'); return; } @@ -93,7 +90,7 @@ async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(job.projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(job.projectId, 'pm', 'api_token'); - const project = await findProjectById(job.projectId); - jiraBaseUrl = project?.jira?.baseUrl ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const creds = await resolveJiraCredentials(job.projectId); + if (!creds) { console.warn('[Notifications] Missing JIRA credentials in DB, skipping timeout notification'); return; } @@ -182,12 +167,11 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise // Use v2 API which accepts plain text, avoiding the pm/jira/adf dependency // (the router image doesn't include pm/ modules) - const url = `${jiraBaseUrl}/rest/api/2/issue/${job.issueKey}/comment`; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); + const url = `${creds.baseUrl}/rest/api/2/issue/${job.issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: message }), diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts new file mode 100644 index 00000000..e8148ded --- /dev/null +++ b/src/router/platformClients.ts @@ -0,0 +1,179 @@ +/** + * Shared, credential-aware platform API helpers for router modules. + * + * Resolves credentials once per call and exposes typed methods for + * posting comments to Trello, GitHub, and JIRA. All errors are caught + * and logged — never propagated (fire-and-forget contract). + * + * Uses raw `fetch()` throughout — the router Docker image does not bundle + * `src/trello/client.ts` or `src/github/client.ts`. + */ + +import { findProjectById, getIntegrationCredential } from '../config/provider.js'; +import { getJiraConfig } from '../pm/config.js'; +import { markdownToAdf } from '../pm/jira/adf.js'; + +// --------------------------------------------------------------------------- +// Credential resolution helpers +// --------------------------------------------------------------------------- + +export interface TrelloCredentials { + apiKey: string; + token: string; +} + +export interface JiraCredentials { + email: string; + apiToken: string; + baseUrl: string; + /** Pre-computed Base64 Basic auth value: `email:apiToken` */ + auth: string; +} + +/** + * Resolve Trello credentials for a project. + * Returns `{ apiKey, token }` or `null` if credentials are missing. + */ +export async function resolveTrelloCredentials( + projectId: string, +): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const token = await getIntegrationCredential(projectId, 'pm', 'token'); + return { apiKey, token }; + } catch { + return null; + } +} + +/** + * Resolve JIRA credentials for a project. + * Returns `{ email, apiToken, baseUrl, auth }` or `null` if credentials/config are missing. + * The `auth` field is the pre-computed Base64 Basic auth string. + */ +export async function resolveJiraCredentials(projectId: string): Promise { + try { + const email = await getIntegrationCredential(projectId, 'pm', 'email'); + const apiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); + const project = await findProjectById(projectId); + const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; + if (!baseUrl) throw new Error('Missing JIRA base URL'); + const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); + return { email, apiToken, baseUrl, auth }; + } catch { + return null; + } +} + +/** + * Build standard GitHub API request headers for a given token. + * Used in place of the 6+ inline header objects scattered across router files. + */ +export function resolveGitHubHeaders( + token: string, + extra?: Record, +): Record { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// High-level platform API helpers +// --------------------------------------------------------------------------- + +/** + * Post a comment to a Trello card. + * Resolves credentials, posts, and returns the new comment ID — or `null` on any failure. + */ +export async function postTrelloComment( + projectId: string, + cardId: string, + text: string, +): Promise { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return null; + + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: string }; + return data.id ?? null; + } catch { + return null; + } +} + +/** + * Post a comment to a GitHub issue or PR. + * Returns the new comment ID — or `null` on any failure. + */ +export async function postGitHubComment( + token: string, + repoFullName: string, + prNumber: number, + body: string, +): Promise { + const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; + try { + const response = await fetch(url, { + method: 'POST', + headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body }), + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: number }; + return data.id ?? null; + } catch { + return null; + } +} + +/** + * Post a comment to a JIRA issue. + * + * @param useAdf - When `true` (default), converts `body` from Markdown to ADF + * and posts to the v3 API. When `false`, posts plain text to the v2 API. + * Use `false` when the router image does not bundle the ADF converter. + * + * Returns the new comment ID — or `null` on any failure. + */ +export async function postJiraComment( + projectId: string, + issueKey: string, + body: string, + useAdf = true, +): Promise { + const creds = await resolveJiraCredentials(projectId); + if (!creds) return null; + + const apiVersion = useAdf ? '3' : '2'; + const url = `${creds.baseUrl}/rest/api/${apiVersion}/issue/${issueKey}/comment`; + const requestBody = useAdf + ? JSON.stringify({ body: markdownToAdf(body) }) + : JSON.stringify({ body }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + body: requestBody, + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: string }; + return data.id ?? null; + } catch { + return null; + } +} diff --git a/src/router/pre-actions.ts b/src/router/pre-actions.ts index f37baef2..a62e8c61 100644 --- a/src/router/pre-actions.ts +++ b/src/router/pre-actions.ts @@ -1,5 +1,6 @@ import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; import { parseRepoFullName } from '../utils/repo.js'; +import { resolveGitHubHeaders } from './platformClients.js'; import type { GitHubJob } from './queue.js'; /** @@ -25,11 +26,7 @@ async function getReviewerUsername(projectId: string, token: string): Promise { const { owner, repo } = parseRepoFullName(repoFullName); const reviewsUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`; const reviewsResponse = await fetch(reviewsUrl, { - headers: { - Authorization: `Bearer ${reviewerToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: resolveGitHubHeaders(reviewerToken), }); if (!reviewsResponse.ok) { @@ -133,12 +126,7 @@ export async function addEyesReactionToPR(job: GitHubJob): Promise { const reactionUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/reactions`; const reactionResponse = await fetch(reactionUrl, { method: 'POST', - headers: { - Authorization: `Bearer ${reviewerToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json', - }, + headers: resolveGitHubHeaders(reviewerToken, { 'Content-Type': 'application/json' }), body: JSON.stringify({ content: 'eyes' }), }); diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 976f1757..9defa981 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -9,12 +9,15 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; import { type PersonaIdentities, isCascadeBot } from '../github/personas.js'; -import { getJiraConfig } from '../pm/config.js'; import { trelloClient, withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; +import { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './platformClients.js'; // In-memory JIRA CloudId cache keyed by baseUrl const jiraCloudIdCache = new Map(); @@ -75,12 +78,8 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const actionId = action.id as string | undefined; if (!actionId) return; - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) { console.warn('[Reactions] Missing Trello credentials, skipping reaction'); return; } @@ -88,7 +87,7 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const emoji = { shortName: 'eyes', native: '👀', unified: '1f440' }; try { - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, async () => { + await withTrelloCredentials({ apiKey: creds.apiKey, token: creds.token }, async () => { await trelloClient.addActionReaction(actionId, emoji); }); console.log('[Reactions] Trello reaction sent for action:', actionId); @@ -171,12 +170,7 @@ async function sendGitHubReaction( const response = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${githubToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json', - }, + headers: resolveGitHubHeaders(githubToken, { 'Content-Type': 'application/json' }), body: JSON.stringify({ content: 'eyes' }), }); @@ -203,33 +197,22 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise ({ + getIntegrationCredential: vi.fn(), + findProjectById: vi.fn(), +})); + +// Mock config cache (imported transitively) +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; +import { + postGitHubComment, + postJiraComment, + postTrelloComment, + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from '../../../src/router/platformClients.js'; + +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockFindProjectById = vi.mocked(findProjectById); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'trello-key', + 'pm/token': 'trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'jira-api-token', +}; + +const MOCK_PROJECT_WITH_JIRA = { + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + baseUrl: 'https://test.atlassian.net', + projectKey: 'PROJ', + statuses: {}, + labels: {}, + }, +}; + +beforeEach(() => { + mockFetch.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA); +}); + +// --------------------------------------------------------------------------- +// resolveTrelloCredentials +// --------------------------------------------------------------------------- + +describe('resolveTrelloCredentials', () => { + it('returns apiKey and token on success', async () => { + const result = await resolveTrelloCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.apiKey).toBe('trello-key'); + expect(result?.token).toBe('trello-token'); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveTrelloCredentials('proj1'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveJiraCredentials +// --------------------------------------------------------------------------- + +describe('resolveJiraCredentials', () => { + it('returns email, apiToken, baseUrl, and pre-computed auth on success', async () => { + const result = await resolveJiraCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('bot@example.com'); + expect(result?.apiToken).toBe('jira-api-token'); + expect(result?.baseUrl).toBe('https://test.atlassian.net'); + // auth is base64 of email:apiToken + const expected = Buffer.from('bot@example.com:jira-api-token').toString('base64'); + expect(result?.auth).toBe(expected); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when project has no JIRA base URL', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when project is not found', async () => { + mockFindProjectById.mockResolvedValue(undefined); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveGitHubHeaders +// --------------------------------------------------------------------------- + +describe('resolveGitHubHeaders', () => { + it('returns standard GitHub API headers', () => { + const headers = resolveGitHubHeaders('ghp_token'); + + expect(headers).toEqual({ + Authorization: 'Bearer ghp_token', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }); + }); + + it('merges extra headers without overwriting standard ones', () => { + const headers = resolveGitHubHeaders('ghp_token', { 'Content-Type': 'application/json' }); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Authorization).toBe('Bearer ghp_token'); + }); + + it('allows overriding standard headers with extra', () => { + const headers = resolveGitHubHeaders('ghp_token', { Accept: 'text/plain' }); + + expect(headers.Accept).toBe('text/plain'); + }); +}); + +// --------------------------------------------------------------------------- +// postTrelloComment +// --------------------------------------------------------------------------- + +describe('postTrelloComment', () => { + it('posts a comment and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'comment-abc' }), + }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBe('comment-abc'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/cards/card1/actions/comments'); + expect(url).toContain('key=trello-key'); + expect(url).toContain('token=trello-token'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ text: 'Hello!' }); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// postGitHubComment +// --------------------------------------------------------------------------- + +describe('postGitHubComment', () => { + it('posts a comment and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Working on it...'); + + expect(result).toBe(42); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/owner/repo/issues/5/comments'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toBe('Bearer ghp_token'); + expect(options.headers.Accept).toBe('application/vnd.github+json'); + expect(JSON.parse(options.body)).toEqual({ body: 'Working on it...' }); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// postJiraComment +// --------------------------------------------------------------------------- + +describe('postJiraComment', () => { + it('posts an ADF comment (v3) and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'jira-comment-1' }), + }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello JIRA!'); + + expect(result).toBe('jira-comment-1'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toMatch(/^Basic /); + // body should be ADF (has type: 'doc') + const parsed = JSON.parse(options.body); + expect(parsed.body).toHaveProperty('type', 'doc'); + }); + + it('posts a plain-text comment (v2) when useAdf=false', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'jira-comment-2' }), + }); + + const result = await postJiraComment('proj1', 'PROJ-2', 'Plain text', false); + + expect(result).toBe('jira-comment-2'); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-2/comment'); + const parsed = JSON.parse(options.body); + expect(parsed.body).toBe('Plain text'); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); +}); From df49bcdb4dd7de7ffa56f6edb6f7901c30e58521 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 20:30:04 +0000 Subject: [PATCH 04/14] fix(router): remove unused high-level helpers and fix stale comment - Remove dead-code `postTrelloComment`, `postGitHubComment`, `postJiraComment` exports from platformClients.ts (zero consumers outside tests) - Remove unused `markdownToAdf` import from platformClients.ts - Remove 15 corresponding tests for the removed helpers - Fix stale comment in notifications.ts that incorrectly referenced pm/jira/adf dependency avoidance (now transitively imported) Co-Authored-By: Claude Opus 4.6 --- src/router/notifications.ts | 4 +- src/router/platformClients.ts | 107 +----------- tests/unit/router/platformClients.test.ts | 190 ---------------------- 3 files changed, 5 insertions(+), 296 deletions(-) diff --git a/src/router/notifications.ts b/src/router/notifications.ts index 83b54a4d..ec1f91ef 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -165,8 +165,8 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise 'Transition the issue back to the trigger status to retry.', ); - // Use v2 API which accepts plain text, avoiding the pm/jira/adf dependency - // (the router image doesn't include pm/ modules) + // Use v2 API which accepts plain text — no Markdown-to-ADF conversion needed + // for simple timeout messages const url = `${creds.baseUrl}/rest/api/2/issue/${job.issueKey}/comment`; const response = await fetch(url, { method: 'POST', diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts index e8148ded..bd0141ad 100644 --- a/src/router/platformClients.ts +++ b/src/router/platformClients.ts @@ -1,17 +1,13 @@ /** - * Shared, credential-aware platform API helpers for router modules. + * Shared credential resolution and platform API header helpers for router modules. * - * Resolves credentials once per call and exposes typed methods for - * posting comments to Trello, GitHub, and JIRA. All errors are caught - * and logged — never propagated (fire-and-forget contract). - * - * Uses raw `fetch()` throughout — the router Docker image does not bundle + * Resolves credentials once per call and returns typed objects. + * Callers use raw `fetch()` — the router Docker image does not bundle * `src/trello/client.ts` or `src/github/client.ts`. */ import { findProjectById, getIntegrationCredential } from '../config/provider.js'; import { getJiraConfig } from '../pm/config.js'; -import { markdownToAdf } from '../pm/jira/adf.js'; // --------------------------------------------------------------------------- // Credential resolution helpers @@ -80,100 +76,3 @@ export function resolveGitHubHeaders( ...extra, }; } - -// --------------------------------------------------------------------------- -// High-level platform API helpers -// --------------------------------------------------------------------------- - -/** - * Post a comment to a Trello card. - * Resolves credentials, posts, and returns the new comment ID — or `null` on any failure. - */ -export async function postTrelloComment( - projectId: string, - cardId: string, - text: string, -): Promise { - const creds = await resolveTrelloCredentials(projectId); - if (!creds) return null; - - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; - try { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: string }; - return data.id ?? null; - } catch { - return null; - } -} - -/** - * Post a comment to a GitHub issue or PR. - * Returns the new comment ID — or `null` on any failure. - */ -export async function postGitHubComment( - token: string, - repoFullName: string, - prNumber: number, - body: string, -): Promise { - const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; - try { - const response = await fetch(url, { - method: 'POST', - headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ body }), - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: number }; - return data.id ?? null; - } catch { - return null; - } -} - -/** - * Post a comment to a JIRA issue. - * - * @param useAdf - When `true` (default), converts `body` from Markdown to ADF - * and posts to the v3 API. When `false`, posts plain text to the v2 API. - * Use `false` when the router image does not bundle the ADF converter. - * - * Returns the new comment ID — or `null` on any failure. - */ -export async function postJiraComment( - projectId: string, - issueKey: string, - body: string, - useAdf = true, -): Promise { - const creds = await resolveJiraCredentials(projectId); - if (!creds) return null; - - const apiVersion = useAdf ? '3' : '2'; - const url = `${creds.baseUrl}/rest/api/${apiVersion}/issue/${issueKey}/comment`; - const requestBody = useAdf - ? JSON.stringify({ body: markdownToAdf(body) }) - : JSON.stringify({ body }); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - body: requestBody, - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: string }; - return data.id ?? null; - } catch { - return null; - } -} diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index 6e962861..78d06cc0 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -21,9 +21,6 @@ vi.mock('../../../src/config/configCache.js', () => ({ import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; import { - postGitHubComment, - postJiraComment, - postTrelloComment, resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, @@ -168,190 +165,3 @@ describe('resolveGitHubHeaders', () => { expect(headers.Accept).toBe('text/plain'); }); }); - -// --------------------------------------------------------------------------- -// postTrelloComment -// --------------------------------------------------------------------------- - -describe('postTrelloComment', () => { - it('posts a comment and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'comment-abc' }), - }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBe('comment-abc'); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain('https://api.trello.com/1/cards/card1/actions/comments'); - expect(url).toContain('key=trello-key'); - expect(url).toContain('token=trello-token'); - expect(options.method).toBe('POST'); - expect(JSON.parse(options.body)).toEqual({ text: 'Hello!' }); - }); - - it('returns null when credentials are missing', async () => { - mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// postGitHubComment -// --------------------------------------------------------------------------- - -describe('postGitHubComment', () => { - it('posts a comment and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 42 }), - }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Working on it...'); - - expect(result).toBe(42); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://api.github.com/repos/owner/repo/issues/5/comments'); - expect(options.method).toBe('POST'); - expect(options.headers.Authorization).toBe('Bearer ghp_token'); - expect(options.headers.Accept).toBe('application/vnd.github+json'); - expect(JSON.parse(options.body)).toEqual({ body: 'Working on it...' }); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// postJiraComment -// --------------------------------------------------------------------------- - -describe('postJiraComment', () => { - it('posts an ADF comment (v3) and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'jira-comment-1' }), - }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello JIRA!'); - - expect(result).toBe('jira-comment-1'); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); - expect(options.method).toBe('POST'); - expect(options.headers.Authorization).toMatch(/^Basic /); - // body should be ADF (has type: 'doc') - const parsed = JSON.parse(options.body); - expect(parsed.body).toHaveProperty('type', 'doc'); - }); - - it('posts a plain-text comment (v2) when useAdf=false', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'jira-comment-2' }), - }); - - const result = await postJiraComment('proj1', 'PROJ-2', 'Plain text', false); - - expect(result).toBe('jira-comment-2'); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-2/comment'); - const parsed = JSON.parse(options.body); - expect(parsed.body).toBe('Plain text'); - }); - - it('returns null when credentials are missing', async () => { - mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); -}); From 032cc00f22aaf539844c851aee39f40a5fbaed3e Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Feb 2026 09:26:39 +0100 Subject: [PATCH 05/14] fix(repo): clone repository on configured baseBranch instead of default (#495) cloneRepo() always cloned the GitHub default branch, ignoring the project's baseBranch setting from the database. This meant projects configured with e.g. baseBranch=develop would still start work from main. Pass --branch to git clone so the checkout always matches the project config. Also fixes a pre-existing lint warning (noExplicitAny) in the ack message generator test. Co-authored-by: Claude Opus 4.6 --- src/utils/repo.ts | 5 +++-- tests/unit/router/ackMessageGenerator.test.ts | 6 ++--- tests/unit/utils/repo.test.ts | 22 +++++++++++++++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/utils/repo.ts b/src/utils/repo.ts index 98bb64e1..9f2d5e0c 100644 --- a/src/utils/repo.ts +++ b/src/utils/repo.ts @@ -41,9 +41,10 @@ export async function cloneRepo( const cloneToken = token ?? (await getProjectGitHubToken(project)); const cloneUrl = `https://${cloneToken}@github.com/${project.repo}.git`; - logger.info('Cloning repository', { repo: project.repo, targetDir }); + const branch = project.baseBranch ?? 'main'; + logger.info('Cloning repository', { repo: project.repo, targetDir, branch }); - execSync(`git clone ${cloneUrl} ${targetDir}`, { + execSync(`git clone --branch ${branch} ${cloneUrl} ${targetDir}`, { stdio: 'pipe', env: { ...process.env }, }); diff --git a/tests/unit/router/ackMessageGenerator.test.ts b/tests/unit/router/ackMessageGenerator.test.ts index 4cec7f74..d2e165ba 100644 --- a/tests/unit/router/ackMessageGenerator.test.ts +++ b/tests/unit/router/ackMessageGenerator.test.ts @@ -46,9 +46,9 @@ import { 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; +// Access llmist mocks — test-only mock internals +const llmistModule = (await import('llmist')) as Record; +const mockRun = llmistModule.__mockRun as ReturnType; beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/utils/repo.test.ts b/tests/unit/utils/repo.test.ts index 8047f93c..a773e6bf 100644 --- a/tests/unit/utils/repo.test.ts +++ b/tests/unit/utils/repo.test.ts @@ -102,7 +102,7 @@ describe('repo utils', () => { }); describe('cloneRepo', () => { - it('clones repo and configures git user', async () => { + it('clones repo on baseBranch and configures git user', async () => { const project = { id: 'test', name: 'Test', @@ -115,7 +115,7 @@ describe('repo utils', () => { await cloneRepo(project, '/tmp/repo'); expect(execSync).toHaveBeenCalledWith( - expect.stringContaining('git clone'), + expect.stringContaining('git clone --branch main'), expect.objectContaining({ stdio: 'pipe' }), ); expect(execSync).toHaveBeenCalledWith( @@ -127,6 +127,24 @@ describe('repo utils', () => { expect.objectContaining({ cwd: '/tmp/repo' }), ); }); + + it('clones repo on non-default baseBranch', async () => { + const project = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'develop', + branchPrefix: 'feature/', + trello: { boardId: 'board', lists: {}, labels: {} }, + }; + + await cloneRepo(project, '/tmp/repo'); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('git clone --branch develop'), + expect.objectContaining({ stdio: 'pipe' }), + ); + }); }); describe('cleanupTempDir', () => { From 233ad37c3e9249aa264eb39be11fade4df7562d0 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 11:21:43 +0100 Subject: [PATCH 06/14] refactor(server): extract generic webhook handler factory to eliminate triplicated code (#496) --- src/router/index.ts | 167 +++---- src/server.ts | 270 ++--------- src/server/webhookHandlers.ts | 369 ++++++++++++++ tests/unit/server/webhookHandlers.test.ts | 562 ++++++++++++++++++++++ 4 files changed, 1024 insertions(+), 344 deletions(-) create mode 100644 src/server/webhookHandlers.ts create mode 100644 tests/unit/server/webhookHandlers.test.ts diff --git a/src/router/index.ts b/src/router/index.ts index 3b814fea..8232c82b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,13 +1,17 @@ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; +import { + createWebhookHandler, + parseGitHubPayload, + parseJiraPayload, + parseTrelloPayload, +} from '../server/webhookHandlers.js'; import { registerBuiltInTriggers } from '../triggers/builtins.js'; import { createTriggerRegistry } from '../triggers/registry.js'; -import { logWebhookCall } from '../utils/webhookLogger.js'; import { handleGitHubWebhook } from './github.js'; import { handleJiraWebhook } from './jira.js'; import { getQueueStats } from './queue.js'; import { handleTrelloWebhook } from './trello.js'; -import { extractRawHeaders, parseGitHubWebhookPayload } from './webhookParsing.js'; import { getActiveWorkerCount, getActiveWorkers, @@ -39,42 +43,25 @@ app.on(['HEAD', 'GET'], '/trello/webhook', (c) => { }); // Trello webhook handler -app.post('/trello/webhook', async (c) => { - const rawHeaders = extractRawHeaders(c); - let payload: unknown; - try { - payload = await c.req.json(); - } catch { - logWebhookCall({ - source: 'trello', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - statusCode: 400, - processed: false, - }); - return c.text('Bad Request', 400); - } - - const { shouldProcess, project, actionType, cardId } = await handleTrelloWebhook( - payload, - triggerRegistry, - ); - - logWebhookCall({ +app.post( + '/trello/webhook', + createWebhookHandler({ source: 'trello', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - projectId: project?.id, - eventType: actionType, - processed: shouldProcess && !!project && !!cardId, - }); - - return c.text('OK', 200); -}); + checkCapacity: false, + fireAndForget: false, + parsePayload: parseTrelloPayload, + processWebhook: async (payload) => { + const { shouldProcess, project, cardId } = await handleTrelloWebhook( + payload, + triggerRegistry, + ); + return { + processed: shouldProcess && !!project && !!cardId, + projectId: project?.id, + }; + }, + }), +); // GitHub webhook verification app.get('/github/webhook', (c) => { @@ -82,47 +69,23 @@ app.get('/github/webhook', (c) => { }); // GitHub webhook handler -app.post('/github/webhook', async (c) => { - const eventType = c.req.header('X-GitHub-Event') || 'unknown'; - const contentType = c.req.header('Content-Type') || ''; - const rawHeaders = extractRawHeaders(c); - - const parseResult = await parseGitHubWebhookPayload(c, contentType); - if (!parseResult.ok) { - console.log('[Router] GitHub webhook parse error:', { - error: parseResult.error, - contentType, - eventType, - }); - logWebhookCall({ - source: 'github', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - bodyRaw: parseResult.error, - statusCode: 400, - eventType, - processed: false, - }); - return c.text('Bad Request', 400); - } - const payload = parseResult.payload; - - const { shouldProcess } = await handleGitHubWebhook(eventType, payload, triggerRegistry); - - logWebhookCall({ +app.post( + '/github/webhook', + createWebhookHandler({ source: 'github', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - eventType, - processed: shouldProcess, - }); - - return c.text('OK', 200); -}); + checkCapacity: false, + fireAndForget: false, + parsePayload: parseGitHubPayload, + processWebhook: async (payload, eventType) => { + const { shouldProcess } = await handleGitHubWebhook( + eventType ?? 'unknown', + payload, + triggerRegistry, + ); + return { processed: shouldProcess }; + }, + }), +); // JIRA webhook verification app.get('/jira/webhook', (c) => { @@ -130,42 +93,22 @@ app.get('/jira/webhook', (c) => { }); // JIRA webhook handler -app.post('/jira/webhook', async (c) => { - const rawHeaders = extractRawHeaders(c); - let payload: unknown; - try { - payload = await c.req.json(); - } catch { - logWebhookCall({ - source: 'jira', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - statusCode: 400, - processed: false, - }); - return c.text('Bad Request', 400); - } - - const { shouldProcess, project, webhookEvent } = await handleJiraWebhook( - payload, - triggerRegistry, - ); - - logWebhookCall({ +app.post( + '/jira/webhook', + createWebhookHandler({ source: 'jira', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - projectId: project?.id, - eventType: webhookEvent || undefined, - processed: !!shouldProcess, - }); - - return c.text('OK', 200); -}); + checkCapacity: false, + fireAndForget: false, + parsePayload: parseJiraPayload, + processWebhook: async (payload) => { + const { shouldProcess, project } = await handleJiraWebhook(payload, triggerRegistry); + return { + processed: !!shouldProcess, + projectId: project?.id, + }; + }, + }), +); // Graceful shutdown async function shutdown(signal: string): Promise { diff --git a/src/server.ts b/src/server.ts index 62f946ca..4d586382 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,13 +11,17 @@ import { logoutHandler } from './api/auth/logout.js'; import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; 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 { + buildGitHubReactionSender, + buildJiraReactionSender, + buildTrelloReactionSender, + createWebhookHandler, + parseGitHubPayload, + parseJiraPayload, + parseTrelloPayload, +} from './server/webhookHandlers.js'; import type { CascadeConfig } from './types/index.js'; -import { canAcceptWebhook, isCurrentlyProcessing, logger } from './utils/index.js'; -import { logWebhookCall } from './utils/webhookLogger.js'; +import { logger } from './utils/index.js'; export interface ServerDependencies { config: CascadeConfig; @@ -77,169 +81,31 @@ export function createServer(deps: ServerDependencies): Hono { }); // Trello webhook - POST for events - app.post('/trello/webhook', async (c) => { - if (isCurrentlyProcessing() && !canAcceptWebhook()) { - logger.warn('Machine at capacity, returning 503'); - return c.text('Service Unavailable', 503); - } - - const rawHeaders = extractRawHeaders(c); - - try { - const payload = await c.req.json(); - const eventType = (payload as Record)?.action - ? ((payload as Record>).action.type as string | undefined) - : undefined; - logger.debug('Received Trello webhook', { action: eventType }); - - logWebhookCall({ - source: 'trello', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - eventType, - processed: true, - }); - - // Fire-and-forget acknowledgment reaction — only for comment actions - if (eventType === 'commentCard') { - const boardId = (payload as Record>).model?.id as - | string - | undefined; - const project = deps.config.projects.find((p) => p.trello?.boardId === boardId); - if (project) { - void sendAcknowledgeReaction('trello', project.id, payload).catch((err) => - logger.error('[Server] Trello reaction error:', { error: String(err) }), - ); - } - } - - // Process asynchronously - respond immediately - setImmediate(() => { - deps.onTrelloWebhook(payload).catch((err) => { - logger.error('Error processing Trello webhook', { - error: String(err), - stack: err instanceof Error ? err.stack : undefined, - }); - }); - }); - - return c.text('OK', 200); - } catch (err) { - logger.error('Failed to parse Trello webhook', { error: String(err) }); - logWebhookCall({ - source: 'trello', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - bodyRaw: String(err), - statusCode: 400, - processed: false, - }); - return c.text('Bad Request', 400); - } - }); + app.post( + '/trello/webhook', + createWebhookHandler({ + source: 'trello', + parsePayload: parseTrelloPayload, + sendReaction: buildTrelloReactionSender(deps.config), + processWebhook: (payload) => deps.onTrelloWebhook(payload), + }), + ); - // Future: GitHub webhook - GET/HEAD for verification + // GitHub webhook - GET/HEAD for verification app.get('/github/webhook', (c) => { return c.text('OK', 200); }); - app.post('/github/webhook', async (c) => { - if (isCurrentlyProcessing() && !canAcceptWebhook()) { - logger.warn('Machine at capacity, returning 503'); - return c.text('Service Unavailable', 503); - } - - const eventType = c.req.header('X-GitHub-Event') || 'unknown'; - const contentType = c.req.header('Content-Type') || ''; - const rawHeaders = extractRawHeaders(c); - - const parseResult = await parseGitHubWebhookPayload(c, contentType); - if (!parseResult.ok) { - logger.error('Failed to parse GitHub webhook', { - error: parseResult.error, - contentType, - eventType, - }); - logWebhookCall({ - source: 'github', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - bodyRaw: parseResult.error, - statusCode: 400, - eventType, - processed: false, - }); - return c.text('Bad Request', 400); - } - - const payload = parseResult.payload; - - logger.info('Received GitHub webhook', { - event: eventType, - contentType, - action: (payload as Record)?.action, - repository: ((payload as Record)?.repository as Record) - ?.full_name, - }); - - logWebhookCall({ + // GitHub webhook - POST for events + app.post( + '/github/webhook', + createWebhookHandler({ source: 'github', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - eventType, - processed: true, - }); - - // Fire-and-forget acknowledgment reaction — only for comment events - if (eventType === 'issue_comment' || eventType === 'pull_request_review_comment') { - const repoFullName = ( - (payload as Record)?.repository as Record - )?.full_name as string | undefined; - if (repoFullName) { - void (async () => { - try { - const project = await findProjectByRepo(repoFullName); - if (!project) { - logger.warn('[Server] 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) { - logger.error('[Server] GitHub reaction error:', { error: String(err) }); - } - })(); - } - } - - // Process asynchronously - respond immediately - setImmediate(() => { - deps.onGitHubWebhook(payload, eventType).catch((err) => { - logger.error('Error processing GitHub webhook', { - error: String(err), - stack: err instanceof Error ? err.stack : undefined, - }); - }); - }); - - return c.text('OK', 200); - }); + parsePayload: parseGitHubPayload, + sendReaction: buildGitHubReactionSender(), + processWebhook: (payload, eventType) => deps.onGitHubWebhook(payload, eventType ?? 'unknown'), + }), + ); // JIRA webhook - GET/HEAD for verification app.get('/jira/webhook', (c) => { @@ -247,75 +113,15 @@ export function createServer(deps: ServerDependencies): Hono { }); // JIRA webhook - POST for events - app.post('/jira/webhook', async (c) => { - if (isCurrentlyProcessing() && !canAcceptWebhook()) { - logger.warn('Machine at capacity, returning 503'); - return c.text('Service Unavailable', 503); - } - - const rawHeaders = extractRawHeaders(c); - - try { - const payload = await c.req.json(); - const eventType = (payload as Record)?.webhookEvent as string | undefined; - logger.info('Received JIRA webhook', { - event: eventType, - issueKey: ((payload as Record)?.issue as Record)?.key, - }); - - logWebhookCall({ - source: 'jira', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - body: payload, - statusCode: 200, - eventType, - processed: true, - }); - - // Fire-and-forget acknowledgment reaction — only for comment events - if (eventType?.startsWith('comment_')) { - const jiraProjectKey = ( - ((payload as Record)?.issue as Record) - ?.fields as Record - )?.project as Record | undefined; - const projectKey = jiraProjectKey?.key as string | undefined; - const project = projectKey - ? deps.config.projects.find((p) => p.jira?.projectKey === projectKey) - : undefined; - if (project) { - void sendAcknowledgeReaction('jira', project.id, payload).catch((err) => - logger.error('[Server] JIRA reaction error:', { error: String(err) }), - ); - } - } - - // Process asynchronously - respond immediately - setImmediate(() => { - deps.onJiraWebhook(payload).catch((err) => { - logger.error('Error processing JIRA webhook', { - error: String(err), - stack: err instanceof Error ? err.stack : undefined, - }); - }); - }); - - return c.text('OK', 200); - } catch (err) { - logger.error('Failed to parse JIRA webhook', { error: String(err) }); - logWebhookCall({ - source: 'jira', - method: c.req.method, - path: c.req.path, - headers: rawHeaders, - bodyRaw: String(err), - statusCode: 400, - processed: false, - }); - return c.text('Bad Request', 400); - } - }); + app.post( + '/jira/webhook', + createWebhookHandler({ + source: 'jira', + parsePayload: parseJiraPayload, + sendReaction: buildJiraReactionSender(deps.config), + processWebhook: (payload) => deps.onJiraWebhook(payload), + }), + ); // ========================================================================= // Static file serving (production — built frontend) diff --git a/src/server/webhookHandlers.ts b/src/server/webhookHandlers.ts new file mode 100644 index 00000000..90ed93f6 --- /dev/null +++ b/src/server/webhookHandlers.ts @@ -0,0 +1,369 @@ +/** + * Generic webhook handler factory for Trello, GitHub, and JIRA endpoints. + * + * Eliminates the three near-identical 50-60 line POST handler blocks that + * previously existed in both `src/server.ts` and `src/router/index.ts` by + * extracting the shared flow (capacity check, header extraction, parse, + * log, react, process) into a single parameterized factory. + * + * Supports two processing modes via `fireAndForget`: + * - `true` (default, server mode): respond 200 immediately, process later. + * - `false` (router mode): await processing so 200 means "job queued." + * Errors propagate to Hono's error handler (500), preserving the old + * router behavior. + * + * Supports log enrichment via the return value of `processWebhook`. When + * the callback returns `WebhookLogOverrides`, those fields override the + * defaults in the webhook log entry. This is request-scoped and safe under + * concurrent requests (no shared mutable state). + */ + +import type { Context, Handler } from 'hono'; +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'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Result returned by a payload parser. */ +export type ParseResult = + | { ok: true; payload: unknown; eventType?: string } + | { ok: false; error: string; eventType?: string }; + +/** + * Fields that can enrich the webhook log entry. + * Returned from `processWebhook` to override default log values. + */ +export interface WebhookLogOverrides { + processed?: boolean; + projectId?: string; +} + +/** + * Configuration object that drives a platform-specific webhook handler. + * Each platform provides implementations for parsing and reaction dispatching; + * the factory handles the common scaffolding around them. + */ +export interface WebhookHandlerConfig { + /** Platform label used for logging and webhook log source field. */ + source: 'trello' | 'github' | 'jira'; + + /** + * Parse the raw Hono request into a structured payload. + * Return `{ ok: false, error }` to short-circuit with a 400 response. + */ + parsePayload: (c: Context) => Promise; + + /** + * Fire-and-forget acknowledgment reaction. + * Called only when `parsePayload` succeeds. + * Errors are caught internally — must never propagate. + */ + sendReaction?: (payload: unknown, eventType: string | undefined) => void; + + /** + * Processing callback. By default invoked via `setImmediate` (fire-and-forget) + * after a 200 is returned to the caller. When `fireAndForget` is `false`, the + * handler awaits this callback before responding — useful when processing must + * complete (e.g. job queuing) before acknowledging the webhook. + * + * May optionally return `WebhookLogOverrides` to enrich the webhook log entry + * (e.g. `processed`, `projectId`). This is the recommended way to communicate + * processing outcome to the log — it avoids shared mutable state and is + * inherently safe under concurrent requests. + * + * When `fireAndForget` is `true`, returned overrides are ignored (logging + * happens before processing starts). When `fireAndForget` is `false`, they + * are used to enrich the log after processing completes. + */ + processWebhook: ( + payload: unknown, + eventType: string | undefined, + // biome-ignore lint/suspicious/noConfusingVoidType: void needed for Promise compat + ) => Promise; + + /** + * Whether to apply the global capacity gate (isCurrentlyProcessing && + * !canAcceptWebhook → 503). Set to `false` for the router deployment + * mode which handles back-pressure differently. + * Defaults to `true`. + */ + checkCapacity?: boolean; + + /** + * Whether to schedule `processWebhook` asynchronously via `setImmediate` + * (fire-and-forget) or await it before responding. + * + * - `true` (default) — server mode: respond 200 immediately, process later. + * - `false` — router mode: await processing so 200 means "job queued." + * Errors from `processWebhook` propagate to Hono's error handler (500), + * matching the old router behavior where a failure was not acknowledged + * with 200. + */ + fireAndForget?: boolean; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** Log a successful webhook call, optionally enriched by log overrides. */ +function logSuccessfulWebhook( + source: WebhookHandlerConfig['source'], + c: Context, + rawHeaders: Record, + payload: unknown, + eventType: string | undefined, + // biome-ignore lint/suspicious/noConfusingVoidType: matches processWebhook return type + logOverrides?: WebhookLogOverrides | void, +): void { + logWebhookCall({ + source, + method: c.req.method, + path: c.req.path, + headers: rawHeaders, + body: payload, + statusCode: 200, + eventType, + processed: logOverrides?.processed ?? true, + projectId: logOverrides?.projectId, + }); +} + +/** Wrap processWebhook with standard error logging. */ +function handleProcessingError(source: WebhookHandlerConfig['source'], err: unknown): void { + logger.error(`Error processing ${source} webhook`, { + error: String(err), + stack: err instanceof Error ? err.stack : undefined, + }); +} + +/** + * Build a Hono POST handler for a webhook endpoint. + * + * The handler: + * 1. Optionally checks machine capacity (503 if over limit). + * 2. Parses the request payload via `config.parsePayload`. + * 3. Logs the webhook call to the database (both success and failure paths). + * 4. Fires a fire-and-forget acknowledgment reaction on success. + * 5. Processes the webhook (fire-and-forget or awaited, per `fireAndForget`). + * 6. Returns 200 immediately (or 400/503 on failure). + */ +export function createWebhookHandler(config: WebhookHandlerConfig): Handler { + const { + source, + parsePayload, + sendReaction, + processWebhook, + checkCapacity = true, + fireAndForget = true, + } = config; + + return async (c: Context) => { + // --- Capacity gate (server mode only) --- + if (checkCapacity && isCurrentlyProcessing() && !canAcceptWebhook()) { + logger.warn('Machine at capacity, returning 503'); + return c.text('Service Unavailable', 503); + } + + const rawHeaders = extractRawHeaders(c); + + // --- Parse --- + const parseResult = await parsePayload(c); + + if (!parseResult.ok) { + logger.error(`Failed to parse ${source} webhook`, { error: parseResult.error }); + logWebhookCall({ + source, + method: c.req.method, + path: c.req.path, + headers: rawHeaders, + bodyRaw: parseResult.error, + statusCode: 400, + eventType: parseResult.eventType, + processed: false, + }); + return c.text('Bad Request', 400); + } + + const { payload, eventType } = parseResult; + + // --- Reaction (fire-and-forget) --- + if (sendReaction) { + sendReaction(payload, eventType); + } + + if (fireAndForget) { + // --- Log then process asynchronously (server mode) --- + // Log overrides from processWebhook are not available in this mode + // because processing hasn't started yet. + logSuccessfulWebhook(source, c, rawHeaders, payload, eventType); + setImmediate(() => { + processWebhook(payload, eventType).catch((err) => handleProcessingError(source, err)); + }); + } else { + // --- Await processing then log (router mode) --- + // Process synchronously so 200 means "job queued." + // Errors propagate to Hono's error handler (500), matching old router + // behavior where a processing failure was not acknowledged with 200. + const logOverrides = await processWebhook(payload, eventType); + logSuccessfulWebhook(source, c, rawHeaders, payload, eventType, logOverrides); + } + + return c.text('OK', 200); + }; +} + +// --------------------------------------------------------------------------- +// Platform-specific parser helpers +// --------------------------------------------------------------------------- + +/** + * Parse a Trello webhook request (plain JSON). + * Extracts `action.type` as the event type. + */ +export async function parseTrelloPayload(c: Context): Promise { + try { + const payload = await c.req.json(); + const eventType = (payload as Record)?.action + ? ((payload as Record>).action.type as string | undefined) + : undefined; + logger.debug('Received Trello webhook', { action: eventType }); + return { ok: true, payload, eventType }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +/** + * Parse a GitHub webhook request (JSON or form-encoded). + * Event type comes from the `X-GitHub-Event` header. + */ +export async function parseGitHubPayload(c: Context): Promise { + const eventType = c.req.header('X-GitHub-Event') || 'unknown'; + const contentType = c.req.header('Content-Type') || ''; + const result = await parseGitHubWebhookPayload(c, contentType); + if (!result.ok) { + logger.error('Failed to parse GitHub webhook', { + error: result.error, + contentType, + eventType, + }); + return { ok: false, error: result.error, eventType }; + } + const payload = result.payload; + logger.info('Received GitHub webhook', { + event: eventType, + contentType, + action: (payload as Record)?.action, + repository: ((payload as Record)?.repository as Record) + ?.full_name, + }); + return { ok: true, payload, eventType }; +} + +/** + * Parse a JIRA webhook request (plain JSON). + * Extracts `webhookEvent` as the event type. + */ +export async function parseJiraPayload(c: Context): Promise { + try { + const payload = await c.req.json(); + const eventType = (payload as Record)?.webhookEvent as string | undefined; + logger.info('Received JIRA webhook', { + event: eventType, + issueKey: ((payload as Record)?.issue as Record)?.key, + }); + return { ok: true, payload, eventType }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +// --------------------------------------------------------------------------- +// Platform-specific reaction helpers (fire-and-forget wrappers) +// --------------------------------------------------------------------------- + +/** + * Build a fire-and-forget Trello reaction sender. + * Only reacts on `commentCard` events. + */ +export function buildTrelloReactionSender( + config: CascadeConfig, +): (payload: unknown, eventType: string | undefined) => void { + return (payload, eventType) => { + if (eventType !== 'commentCard') return; + const boardId = (payload as Record>).model?.id as + | string + | undefined; + const project = config.projects.find((p) => p.trello?.boardId === boardId); + if (!project) return; + void sendAcknowledgeReaction('trello', project.id, payload).catch((err) => + logger.error('[Server] Trello reaction error:', { error: String(err) }), + ); + }; +} + +/** + * Build a fire-and-forget GitHub reaction sender. + * Only reacts on `issue_comment` or `pull_request_review_comment` events. + */ +export function buildGitHubReactionSender(): ( + payload: unknown, + eventType: string | undefined, +) => void { + return (payload, eventType) => { + if (eventType !== 'issue_comment' && eventType !== 'pull_request_review_comment') return; + const repoFullName = ( + (payload as Record)?.repository as Record + )?.full_name as string | undefined; + if (!repoFullName) return; + void (async () => { + try { + const project = await findProjectByRepo(repoFullName); + if (!project) { + logger.warn('[Server] 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) { + logger.error('[Server] GitHub reaction error:', { error: String(err) }); + } + })(); + }; +} + +/** + * Build a fire-and-forget JIRA reaction sender. + * Only reacts on events whose name starts with `comment_`. + */ +export function buildJiraReactionSender( + config: CascadeConfig, +): (payload: unknown, eventType: string | undefined) => void { + return (payload, eventType) => { + if (!eventType?.startsWith('comment_')) return; + const jiraProjectKey = ( + ((payload as Record)?.issue as Record)?.fields as Record< + string, + unknown + > + )?.project as Record | undefined; + const projectKey = jiraProjectKey?.key as string | undefined; + const project = projectKey + ? config.projects.find((p) => p.jira?.projectKey === projectKey) + : undefined; + if (!project) return; + void sendAcknowledgeReaction('jira', project.id, payload).catch((err) => + logger.error('[Server] JIRA reaction error:', { error: String(err) }), + ); + }; +} diff --git a/tests/unit/server/webhookHandlers.test.ts b/tests/unit/server/webhookHandlers.test.ts new file mode 100644 index 00000000..54138fb0 --- /dev/null +++ b/tests/unit/server/webhookHandlers.test.ts @@ -0,0 +1,562 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Must mock heavy imports BEFORE importing the module under test +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../src/config/provider.js', () => ({ + findProjectByRepo: vi.fn(), +})); + +vi.mock('../../../src/github/personas.js', () => ({ + resolvePersonaIdentities: vi.fn(), +})); + +vi.mock('../../../src/utils/index.js', () => ({ + canAcceptWebhook: vi.fn().mockReturnValue(true), + isCurrentlyProcessing: vi.fn().mockReturnValue(false), + logger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../src/utils/webhookLogger.js', () => ({ + logWebhookCall: vi.fn(), +})); + +import { findProjectByRepo } from '../../../src/config/provider.js'; +import { resolvePersonaIdentities } from '../../../src/github/personas.js'; +import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import { + buildGitHubReactionSender, + buildJiraReactionSender, + buildTrelloReactionSender, + createWebhookHandler, + parseGitHubPayload, + parseJiraPayload, + parseTrelloPayload, +} from '../../../src/server/webhookHandlers.js'; +import { canAcceptWebhook, isCurrentlyProcessing } from '../../../src/utils/index.js'; +import { logWebhookCall } from '../../../src/utils/webhookLogger.js'; + +const mockLogWebhookCall = vi.mocked(logWebhookCall); +const mockIsCurrentlyProcessing = vi.mocked(isCurrentlyProcessing); +const mockCanAcceptWebhook = vi.mocked(canAcceptWebhook); +const mockSendAcknowledgeReaction = vi.mocked(sendAcknowledgeReaction); +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); +const mockResolvePersonaIdentities = vi.mocked(resolvePersonaIdentities); + +/** Build a minimal Hono app with the handler mounted at POST /webhook */ +function buildApp(handler: ReturnType): Hono { + const app = new Hono(); + app.post('/webhook', handler); + return app; +} + +async function postJson( + app: Hono, + body: unknown, + headers: Record = {}, +): Promise { + const request = new Request('http://localhost/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + return app.fetch(request); +} + +// --------------------------------------------------------------------------- +// createWebhookHandler — core factory behaviour +// --------------------------------------------------------------------------- + +describe('createWebhookHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsCurrentlyProcessing.mockReturnValue(false); + mockCanAcceptWebhook.mockReturnValue(true); + }); + + it('returns 503 when at capacity (checkCapacity=true)', async () => { + mockIsCurrentlyProcessing.mockReturnValue(true); + mockCanAcceptWebhook.mockReturnValue(false); + + const handler = createWebhookHandler({ + source: 'trello', + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'test' }), + processWebhook: vi.fn().mockResolvedValue(undefined), + checkCapacity: true, + }); + + const app = buildApp(handler); + const res = await postJson(app, {}); + expect(res.status).toBe(503); + }); + + it('does NOT return 503 when checkCapacity=false even at capacity', async () => { + mockIsCurrentlyProcessing.mockReturnValue(true); + mockCanAcceptWebhook.mockReturnValue(false); + + const handler = createWebhookHandler({ + source: 'trello', + checkCapacity: false, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'test' }), + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + const res = await postJson(app, {}); + expect(res.status).toBe(200); + }); + + it('returns 400 when parsePayload fails', async () => { + const handler = createWebhookHandler({ + source: 'jira', + parsePayload: async () => ({ ok: false, error: 'bad json' }), + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + const res = await postJson(app, 'not-json'); + expect(res.status).toBe(400); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 400, + processed: false, + bodyRaw: 'bad json', + }), + ); + }); + + it('returns 200 and logs on success', async () => { + const payload = { foo: 'bar' }; + const handler = createWebhookHandler({ + source: 'github', + parsePayload: async () => ({ ok: true, payload, eventType: 'push' }), + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + const res = await postJson(app, payload); + expect(res.status).toBe(200); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'github', + statusCode: 200, + processed: true, + eventType: 'push', + body: payload, + }), + ); + }); + + it('calls processWebhook asynchronously via setImmediate', async () => { + vi.useFakeTimers(); + const processWebhook = vi.fn().mockResolvedValue(undefined); + const handler = createWebhookHandler({ + source: 'trello', + parsePayload: async () => ({ ok: true, payload: { x: 1 }, eventType: 'commentCard' }), + processWebhook, + }); + + const app = buildApp(handler); + await postJson(app, { x: 1 }); + + // Not yet called — setImmediate hasn't fired + expect(processWebhook).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + expect(processWebhook).toHaveBeenCalledWith({ x: 1 }, 'commentCard'); + vi.useRealTimers(); + }); + + it('calls sendReaction when provided and parse succeeds', async () => { + const sendReaction = vi.fn(); + const handler = createWebhookHandler({ + source: 'trello', + parsePayload: async () => ({ ok: true, payload: { a: 1 }, eventType: 'commentCard' }), + sendReaction, + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + await postJson(app, { a: 1 }); + + expect(sendReaction).toHaveBeenCalledWith({ a: 1 }, 'commentCard'); + }); + + it('does NOT call sendReaction when parse fails', async () => { + const sendReaction = vi.fn(); + const handler = createWebhookHandler({ + source: 'trello', + parsePayload: async () => ({ ok: false, error: 'parse error' }), + sendReaction, + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + await postJson(app, {}); + + expect(sendReaction).not.toHaveBeenCalled(); + }); + + it('awaits processWebhook when fireAndForget=false', async () => { + const callOrder: string[] = []; + const processWebhook = vi.fn().mockImplementation(async () => { + callOrder.push('process'); + }); + const handler = createWebhookHandler({ + source: 'trello', + fireAndForget: false, + parsePayload: async () => ({ ok: true, payload: { x: 1 }, eventType: 'commentCard' }), + processWebhook, + }); + + const app = buildApp(handler); + const res = await postJson(app, { x: 1 }); + + // processWebhook was called synchronously before response + expect(res.status).toBe(200); + expect(processWebhook).toHaveBeenCalledWith({ x: 1 }, 'commentCard'); + expect(callOrder).toEqual(['process']); + }); + + it('uses processWebhook return value to enrich log when fireAndForget=false', async () => { + const handler = createWebhookHandler({ + source: 'trello', + fireAndForget: false, + parsePayload: async () => ({ ok: true, payload: { x: 1 }, eventType: 'commentCard' }), + processWebhook: vi.fn().mockResolvedValue({ processed: false, projectId: 'proj-123' }), + }); + + const app = buildApp(handler); + await postJson(app, { x: 1 }); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + processed: false, + projectId: 'proj-123', + }), + ); + }); + + it('ignores processWebhook return value when fireAndForget=true (logs before processing)', async () => { + vi.useFakeTimers(); + const handler = createWebhookHandler({ + source: 'github', + fireAndForget: true, + parsePayload: async () => ({ ok: true, payload: { y: 2 }, eventType: 'push' }), + processWebhook: vi.fn().mockResolvedValue({ processed: false, projectId: 'proj-456' }), + }); + + const app = buildApp(handler); + await postJson(app, { y: 2 }); + + // In fire-and-forget mode, log happens before processing, so overrides are not available + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + processed: true, // default, not the override + }), + ); + vi.useRealTimers(); + }); + + it('logs processed:true by default when processWebhook returns void', async () => { + const handler = createWebhookHandler({ + source: 'jira', + fireAndForget: false, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'issue_updated' }), + processWebhook: vi.fn().mockResolvedValue(undefined), + }); + + const app = buildApp(handler); + await postJson(app, {}); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + processed: true, + }), + ); + }); + + it('log overrides reflect actual processing outcome when fireAndForget=false', async () => { + const handler = createWebhookHandler({ + source: 'trello', + fireAndForget: false, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'commentCard' }), + processWebhook: async () => { + // Simulate actual processing that determines outcome + return { processed: true, projectId: 'proj-789' }; + }, + }); + + const app = buildApp(handler); + await postJson(app, {}); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + processed: true, + projectId: 'proj-789', + }), + ); + }); + + it('lets processWebhook errors propagate when fireAndForget=false', async () => { + const handler = createWebhookHandler({ + source: 'jira', + fireAndForget: false, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'issue_updated' }), + processWebhook: vi.fn().mockRejectedValue(new Error('queue connection failed')), + }); + + const app = new Hono(); + // Register an error handler to capture the propagated error + app.post('/webhook', handler); + app.onError((err, c) => { + return c.text(`Error: ${err.message}`, 500); + }); + + const res = await postJson(app, {}); + expect(res.status).toBe(500); + const body = await res.text(); + expect(body).toContain('queue connection failed'); + }); +}); + +// --------------------------------------------------------------------------- +// Platform parsers +// --------------------------------------------------------------------------- + +describe('parseTrelloPayload', () => { + it('extracts eventType from action.type', async () => { + const app = new Hono(); + app.post('/test', async (c) => { + const result = await parseTrelloPayload(c); + return c.json(result); + }); + const res = await app.fetch( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: { type: 'commentCard' } }), + }), + ); + const body = await res.json(); + expect(body).toMatchObject({ ok: true, eventType: 'commentCard' }); + }); + + it('returns ok:false for invalid JSON', async () => { + const app = new Hono(); + app.post('/test', async (c) => { + const result = await parseTrelloPayload(c); + return c.json(result); + }); + const res = await app.fetch( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'not-json', + }), + ); + const body = await res.json(); + expect(body.ok).toBe(false); + }); +}); + +describe('parseJiraPayload', () => { + it('extracts eventType from webhookEvent', async () => { + const app = new Hono(); + app.post('/test', async (c) => { + const result = await parseJiraPayload(c); + return c.json(result); + }); + const res = await app.fetch( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ webhookEvent: 'comment_created', issue: { key: 'PROJ-1' } }), + }), + ); + const body = await res.json(); + expect(body).toMatchObject({ ok: true, eventType: 'comment_created' }); + }); +}); + +describe('parseGitHubPayload', () => { + it('extracts eventType from X-GitHub-Event header', async () => { + const app = new Hono(); + app.post('/test', async (c) => { + const result = await parseGitHubPayload(c); + return c.json(result); + }); + const res = await app.fetch( + new Request('http://localhost/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Event': 'issue_comment', + }, + body: JSON.stringify({ action: 'created', repository: { full_name: 'owner/repo' } }), + }), + ); + const body = await res.json(); + expect(body).toMatchObject({ ok: true, eventType: 'issue_comment' }); + }); +}); + +// --------------------------------------------------------------------------- +// Reaction senders +// --------------------------------------------------------------------------- + +describe('buildTrelloReactionSender', () => { + const config = { + defaults: {} as never, + projects: [ + { + id: 'proj-1', + trello: { boardId: 'board-abc' }, + } as never, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends reaction for commentCard events', async () => { + vi.useFakeTimers(); + const sender = buildTrelloReactionSender(config); + const payload = { model: { id: 'board-abc' }, action: { type: 'commentCard' } }; + sender(payload, 'commentCard'); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).toHaveBeenCalledWith('trello', 'proj-1', payload); + vi.useRealTimers(); + }); + + it('does not send reaction for non-commentCard events', () => { + const sender = buildTrelloReactionSender(config); + sender({ model: { id: 'board-abc' } }, 'updateCard'); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + }); + + it('does not send reaction when board not found', async () => { + vi.useFakeTimers(); + const sender = buildTrelloReactionSender(config); + sender({ model: { id: 'unknown-board' } }, 'commentCard'); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); + +describe('buildGitHubReactionSender', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends reaction for issue_comment events', async () => { + vi.useFakeTimers(); + const mockProject = { id: 'proj-1' } as never; + mockFindProjectByRepo.mockResolvedValue(mockProject); + mockResolvePersonaIdentities.mockResolvedValue({ + implementer: 'bot-impl', + reviewer: 'bot-rev', + }); + + const sender = buildGitHubReactionSender(); + const payload = { repository: { full_name: 'owner/repo' }, comment: { id: 1 } }; + sender(payload, 'issue_comment'); + await vi.runAllTimersAsync(); + + expect(mockFindProjectByRepo).toHaveBeenCalledWith('owner/repo'); + expect(mockSendAcknowledgeReaction).toHaveBeenCalledWith( + 'github', + 'owner/repo', + payload, + { implementer: 'bot-impl', reviewer: 'bot-rev' }, + mockProject, + ); + vi.useRealTimers(); + }); + + it('does not send reaction for push events', async () => { + vi.useFakeTimers(); + const sender = buildGitHubReactionSender(); + sender({ repository: { full_name: 'owner/repo' } }, 'push'); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('does not send reaction when repo is missing', async () => { + vi.useFakeTimers(); + const sender = buildGitHubReactionSender(); + sender({}, 'issue_comment'); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); + +describe('buildJiraReactionSender', () => { + const config = { + defaults: {} as never, + projects: [ + { + id: 'jira-proj-1', + jira: { projectKey: 'PROJ' }, + } as never, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends reaction for comment_created events', async () => { + vi.useFakeTimers(); + const sender = buildJiraReactionSender(config); + const payload = { + webhookEvent: 'comment_created', + issue: { fields: { project: { key: 'PROJ' } } }, + }; + sender(payload, 'comment_created'); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).toHaveBeenCalledWith('jira', 'jira-proj-1', payload); + vi.useRealTimers(); + }); + + it('does not send reaction for non-comment_ events', async () => { + vi.useFakeTimers(); + const sender = buildJiraReactionSender(config); + sender( + { webhookEvent: 'jira:issue_updated', issue: { fields: { project: { key: 'PROJ' } } } }, + 'jira:issue_updated', + ); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('does not send reaction when project key not found', async () => { + vi.useFakeTimers(); + const sender = buildJiraReactionSender(config); + sender( + { webhookEvent: 'comment_created', issue: { fields: { project: { key: 'UNKNOWN' } } } }, + 'comment_created', + ); + await vi.runAllTimersAsync(); + expect(mockSendAcknowledgeReaction).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); From 36a69a6921f23f49b6d07e0a6c595caae81d319b Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 12:20:59 +0100 Subject: [PATCH 07/14] feat(triggers): add per-agent JIRA issue-transitioned toggles and pm-trigger-set CLI (#497) --- CLAUDE.md | 64 ++++++ src/cli/dashboard/projects/pm-trigger-set.ts | 196 ++++++++++++++++++ src/config/triggerConfig.ts | 50 ++++- src/triggers/jira/issue-transitioned.ts | 14 +- tests/unit/config/projects.test.ts | 26 +++ tests/unit/config/triggerConfig.test.ts | 87 +++++++- .../triggers/jira-issue-transitioned.test.ts | 84 +++++++- web/src/lib/trigger-agent-mapping.ts | 36 +++- 8 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 src/cli/dashboard/projects/pm-trigger-set.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6c08a6bd..3e8e8ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,6 +240,70 @@ When `reviewTrigger` is absent, the system falls back to legacy booleans: - `reviewRequested` → `onReviewRequested` (default `false`) - `externalPrs` always `false` in legacy mode (no legacy equivalent) +### PM Agent Trigger Modes + +Briefing, planning, and implementation agents each have independent toggles for their PM triggers. **All modes default to `true`** for backward compatibility. + +#### Trello card-moved triggers + +| Flag | Description | +|------|-------------| +| `cardMovedToBriefing` | Trigger briefing agent when a card is moved to the Briefing list | +| `cardMovedToPlanning` | Trigger planning agent when a card is moved to the Planning list | +| `cardMovedToTodo` | Trigger implementation agent when a card is moved to the Todo list | + +#### JIRA issue-transitioned triggers (per-agent) + +The `issueTransitioned` field supports both a legacy boolean (applies to all agents) and a nested per-agent object: + +| Agent | Field | Description | +|-------|-------|-------------| +| briefing | `issueTransitioned.briefing` | Trigger briefing when issue transitions to Briefing status | +| planning | `issueTransitioned.planning` | Trigger planning when issue transitions to Planning status | +| implementation | `issueTransitioned.implementation` | Trigger implementation when issue transitions to Todo status | + +#### Setting via CLI + +```bash +# Disable Trello card-moved trigger for briefing agent +cascade projects pm-trigger-set --no-card-moved-to-briefing + +# Disable JIRA issue-transitioned for implementation agent only +cascade projects pm-trigger-set --no-issue-transitioned-implementation + +# Enable JIRA triggers for briefing and planning, disable for implementation +cascade projects pm-trigger-set \ + --issue-transitioned-briefing \ + --issue-transitioned-planning \ + --no-issue-transitioned-implementation + +# Disable all Trello card-moved triggers +cascade projects pm-trigger-set \ + --no-card-moved-to-briefing \ + --no-card-moved-to-planning \ + --no-card-moved-to-todo +``` + +#### Setting via Dashboard + +In the **Agent Configs** tab, the briefing, planning, and implementation agent sections each show: +- **Card moved to [list]** — Trello card-moved toggle (Trello projects only) +- **Issue Transitioned** — JIRA per-agent transition toggle (JIRA projects only) +- **Ready to Process label** — label-based trigger toggle + +#### Direct JSON Config + +```bash +# Disable JIRA issue-transitioned for implementation only +cascade projects integration-set \ + --category pm --provider jira --config '{"projectKey":"PROJ","statuses":{...}}' \ + --triggers '{"issueTransitioned":{"briefing":true,"planning":true,"implementation":false}}' +``` + +#### Backward Compatibility + +The legacy `issueTransitioned: true/false` boolean is still supported — it applies to all agents uniformly. + ## Claude Code Backend CASCADE supports using Claude Code SDK as an alternative agent backend. Configure per-project: diff --git a/src/cli/dashboard/projects/pm-trigger-set.ts b/src/cli/dashboard/projects/pm-trigger-set.ts new file mode 100644 index 00000000..b56709ce --- /dev/null +++ b/src/cli/dashboard/projects/pm-trigger-set.ts @@ -0,0 +1,196 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for configuring PM trigger modes per agent type. + * + * Usage: + * cascade projects pm-trigger-set [--card-moved-to-briefing] [--issue-transitioned-briefing] ... + * + * At least one flag must be provided. Pass `--no-` to disable a mode. + * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the + * PM integration triggers config for the project. + * + * Trello flags update the top-level boolean keys (cardMovedToBriefing, etc.). + * JIRA flags update the nested `issueTransitioned` object per agent type. + */ +export default class ProjectsPmTriggerSet extends DashboardCommand { + static override description = + 'Configure PM trigger modes per agent type (card-moved for Trello, issue-transitioned for JIRA).'; + + static override aliases = ['projects:pm-trigger-set']; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + // Trello card-moved triggers + 'card-moved-to-briefing': Flags.boolean({ + description: 'Enable briefing agent when a card is moved to the Briefing list (Trello).', + allowNo: true, + default: undefined, + }), + 'card-moved-to-planning': Flags.boolean({ + description: 'Enable planning agent when a card is moved to the Planning list (Trello).', + allowNo: true, + default: undefined, + }), + 'card-moved-to-todo': Flags.boolean({ + description: 'Enable implementation agent when a card is moved to the Todo list (Trello).', + allowNo: true, + default: undefined, + }), + // JIRA issue-transitioned triggers (per-agent) + 'issue-transitioned-briefing': Flags.boolean({ + description: + 'Enable briefing agent when a JIRA issue transitions to the configured Briefing status.', + allowNo: true, + default: undefined, + }), + 'issue-transitioned-planning': Flags.boolean({ + description: + 'Enable planning agent when a JIRA issue transitions to the configured Planning status.', + allowNo: true, + default: undefined, + }), + 'issue-transitioned-implementation': Flags.boolean({ + description: + 'Enable implementation agent when a JIRA issue transitions to the configured Todo status.', + allowNo: true, + default: undefined, + }), + }; + + /** Build the triggers patch object from parsed flag values. */ + private buildTriggers(parsedFlags: { + cardMovedToBriefing: boolean | undefined; + cardMovedToPlanning: boolean | undefined; + cardMovedToTodo: boolean | undefined; + issueTransitionedBriefing: boolean | undefined; + issueTransitionedPlanning: boolean | undefined; + issueTransitionedImplementation: boolean | undefined; + }): Record> { + const { + cardMovedToBriefing, + cardMovedToPlanning, + cardMovedToTodo, + issueTransitionedBriefing, + issueTransitionedPlanning, + issueTransitionedImplementation, + } = parsedFlags; + + const triggers: Record> = {}; + + if (cardMovedToBriefing !== undefined) triggers.cardMovedToBriefing = cardMovedToBriefing; + if (cardMovedToPlanning !== undefined) triggers.cardMovedToPlanning = cardMovedToPlanning; + if (cardMovedToTodo !== undefined) triggers.cardMovedToTodo = cardMovedToTodo; + + const issueTransitioned: Record = {}; + if (issueTransitionedBriefing !== undefined) + issueTransitioned.briefing = issueTransitionedBriefing; + if (issueTransitionedPlanning !== undefined) + issueTransitioned.planning = issueTransitionedPlanning; + if (issueTransitionedImplementation !== undefined) + issueTransitioned.implementation = issueTransitionedImplementation; + + if (Object.keys(issueTransitioned).length > 0) { + triggers.issueTransitioned = issueTransitioned; + } + + return triggers; + } + + /** Format a human-readable summary of changed triggers. */ + private formatOutput( + projectId: string, + parsedFlags: { + cardMovedToBriefing: boolean | undefined; + cardMovedToPlanning: boolean | undefined; + cardMovedToTodo: boolean | undefined; + issueTransitionedBriefing: boolean | undefined; + issueTransitionedPlanning: boolean | undefined; + issueTransitionedImplementation: boolean | undefined; + }, + ): string { + const { + cardMovedToBriefing, + cardMovedToPlanning, + cardMovedToTodo, + issueTransitionedBriefing, + issueTransitionedPlanning, + issueTransitionedImplementation, + } = parsedFlags; + + const lines: string[] = [`PM trigger modes updated for project: ${projectId}`]; + if (cardMovedToBriefing !== undefined) + lines.push(` cardMovedToBriefing: ${cardMovedToBriefing}`); + if (cardMovedToPlanning !== undefined) + lines.push(` cardMovedToPlanning: ${cardMovedToPlanning}`); + if (cardMovedToTodo !== undefined) lines.push(` cardMovedToTodo: ${cardMovedToTodo}`); + if (issueTransitionedBriefing !== undefined) + lines.push(` issueTransitioned.briefing: ${issueTransitionedBriefing}`); + if (issueTransitionedPlanning !== undefined) + lines.push(` issueTransitioned.planning: ${issueTransitionedPlanning}`); + if (issueTransitionedImplementation !== undefined) + lines.push(` issueTransitioned.implementation: ${issueTransitionedImplementation}`); + return lines.join('\n'); + } + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsPmTriggerSet); + + const cardMovedToBriefing = flags['card-moved-to-briefing']; + const cardMovedToPlanning = flags['card-moved-to-planning']; + const cardMovedToTodo = flags['card-moved-to-todo']; + const issueTransitionedBriefing = flags['issue-transitioned-briefing']; + const issueTransitionedPlanning = flags['issue-transitioned-planning']; + const issueTransitionedImplementation = flags['issue-transitioned-implementation']; + + const hasAnyFlag = + cardMovedToBriefing !== undefined || + cardMovedToPlanning !== undefined || + cardMovedToTodo !== undefined || + issueTransitionedBriefing !== undefined || + issueTransitionedPlanning !== undefined || + issueTransitionedImplementation !== undefined; + + if (!hasAnyFlag) { + this.error( + 'At least one flag must be provided: ' + + '--card-moved-to-briefing, --card-moved-to-planning, --card-moved-to-todo, ' + + '--issue-transitioned-briefing, --issue-transitioned-planning, --issue-transitioned-implementation ' + + '(use --no- to disable).', + ); + } + + const parsedFlags = { + cardMovedToBriefing, + cardMovedToPlanning, + cardMovedToTodo, + issueTransitionedBriefing, + issueTransitionedPlanning, + issueTransitionedImplementation, + }; + + const triggers = this.buildTriggers(parsedFlags); + + try { + await this.client.projects.integrations.updateTriggers.mutate({ + projectId: args.id, + category: 'pm', + triggers, + }); + + if (flags.json) { + this.outputJson({ ok: true, triggers }); + return; + } + + this.log(this.formatOutput(args.id, parsedFlags)); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts index e9a1f0a8..a95e320f 100644 --- a/src/config/triggerConfig.ts +++ b/src/config/triggerConfig.ts @@ -33,12 +33,29 @@ export const TrelloTriggerConfigSchema = z.object({ commentMention: z.boolean().default(true), }); +/** + * Per-agent issue-transitioned configuration for JIRA. + * Each agent type can independently toggle whether the issue-transitioned trigger fires for it. + */ +export const IssueTransitionedSchema = z + .union([ + z.boolean(), + z.object({ + briefing: z.boolean().default(true), + planning: z.boolean().default(true), + implementation: z.boolean().default(true), + }), + ]) + .optional(); + +export type IssueTransitionedConfig = z.infer; + /** * Trigger configuration for JIRA integrations. * All triggers default to `true` for backward compatibility. */ export const JiraTriggerConfigSchema = z.object({ - issueTransitioned: z.boolean().default(true), + issueTransitioned: IssueTransitionedSchema, readyToProcessLabel: ReadyToProcessLabelSchema, commentMention: z.boolean().default(true), }); @@ -170,6 +187,30 @@ export function resolveReadyToProcessEnabled( return true; } +/** + * Resolve whether the issue-transitioned trigger is enabled for a specific agent type. + * Supports both the new nested object format and the legacy boolean format. + * Returns `true` when no config is present (backward compatible). + */ +export function resolveIssueTransitionedEnabled( + config: Partial | undefined, + agentType: string, +): boolean { + if (!config) return true; + const it = config.issueTransitioned as IssueTransitionedConfig; + if (it === undefined) return true; + if (typeof it === 'boolean') { + // Legacy: boolean applies to all agents + return it; + } + // Nested object: check per-agent toggle + if (agentType === 'briefing') return it.briefing ?? true; + if (agentType === 'planning') return it.planning ?? true; + if (agentType === 'implementation') return it.implementation ?? true; + // Unknown agent type — default to enabled + return true; +} + /** * Resolve whether a JIRA trigger is enabled based on project trigger config. * Returns `true` (enabled) when no config is present (backward compatible). @@ -186,6 +227,13 @@ export function resolveJiraTriggerEnabled( if (typeof rtp === 'boolean') return rtp; return rtp.briefing || rtp.planning || rtp.implementation; } + if (key === 'issueTransitioned') { + const it = value as IssueTransitionedConfig; + if (it === undefined) return true; + if (typeof it === 'boolean') return it; + // Object form: enabled if any agent is enabled + return it.briefing || it.planning || it.implementation; + } return value === undefined ? true : (value as boolean); } diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts index 1c3d647c..7d5fea27 100644 --- a/src/triggers/jira/issue-transitioned.ts +++ b/src/triggers/jira/issue-transitioned.ts @@ -5,7 +5,10 @@ * a CASCADE agent type (briefing, planning, implementation). */ -import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; +import { + resolveIssueTransitionedEnabled, + resolveJiraTriggerEnabled, +} from '../../config/triggerConfig.js'; import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -121,6 +124,15 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { return null; } + // Check per-agent toggle for issueTransitioned + if (!resolveIssueTransitionedEnabled(jiraConfig?.triggers, agentType)) { + logger.debug('JIRA issue-transitioned trigger disabled for agent', { + issueKey, + agentType, + }); + return null; + } + logger.info('JIRA issue transitioned to agent-triggering status', { issueKey, fromStatus: statusChange.fromString, diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index e7375d81..b1f493f7 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -170,6 +170,15 @@ describe('config provider', () => { }); describe('getIntegrationCredential', () => { + // These tests go through getIntegrationCredentialOrNull which checks process.env first. + // Use vi.stubEnv to prevent any env vars from shadowing the DB mock. + beforeEach(() => { + vi.stubEnv('TRELLO_API_KEY', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('resolves credential from DB', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('db-secret-value'); @@ -187,6 +196,14 @@ describe('config provider', () => { }); describe('getIntegrationCredentialOrNull', () => { + // Clear any env vars that might shadow the mock (implementer_token maps to GITHUB_TOKEN_IMPLEMENTER). + beforeEach(() => { + vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('returns credential value when found', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('secret-value'); @@ -227,6 +244,15 @@ describe('config provider', () => { }); describe('getProjectGitHubToken', () => { + // getProjectGitHubToken calls getIntegrationCredentialOrNull which checks process.env first. + // Use vi.stubEnv to prevent the env var from shadowing the mock. + beforeEach(() => { + vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('returns implementer token when available', async () => { vi.mocked(resolveIntegrationCredential).mockResolvedValue('implementer-token'); diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 0b912414..78993637 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -4,6 +4,7 @@ import { JiraTriggerConfigSchema, TrelloTriggerConfigSchema, resolveGitHubTriggerEnabled, + resolveIssueTransitionedEnabled, resolveJiraTriggerEnabled, resolveReadyToProcessEnabled, resolveReviewTriggerConfig, @@ -46,14 +47,28 @@ describe('TrelloTriggerConfigSchema', () => { }); describe('JiraTriggerConfigSchema', () => { - it('defaults boolean fields to true, readyToProcessLabel optional', () => { + it('defaults commentMention to true, issueTransitioned and readyToProcessLabel optional', () => { const result = JiraTriggerConfigSchema.parse({}); - expect(result).toEqual({ - issueTransitioned: true, - commentMention: true, - }); + expect(result.commentMention).toBe(true); + expect(result.issueTransitioned).toBeUndefined(); expect(result.readyToProcessLabel).toBeUndefined(); }); + + it('accepts legacy boolean issueTransitioned', () => { + const result = JiraTriggerConfigSchema.parse({ issueTransitioned: false }); + expect(result.issueTransitioned).toBe(false); + }); + + it('accepts per-agent issueTransitioned object', () => { + const result = JiraTriggerConfigSchema.parse({ + issueTransitioned: { briefing: true, planning: false, implementation: true }, + }); + expect(result.issueTransitioned).toEqual({ + briefing: true, + planning: false, + implementation: true, + }); + }); }); describe('GitHubTriggerConfigSchema', () => { @@ -145,7 +160,7 @@ describe('resolveJiraTriggerEnabled', () => { expect(resolveJiraTriggerEnabled(undefined, 'commentMention')).toBe(true); }); - it('returns false when key is explicitly disabled', () => { + it('returns false when issueTransitioned is explicitly false (legacy boolean)', () => { expect(resolveJiraTriggerEnabled({ issueTransitioned: false }, 'issueTransitioned')).toBe( false, ); @@ -154,6 +169,24 @@ describe('resolveJiraTriggerEnabled', () => { it('returns true when config is empty (no explicit settings)', () => { expect(resolveJiraTriggerEnabled({}, 'issueTransitioned')).toBe(true); }); + + it('returns true for issueTransitioned object when any agent is enabled', () => { + expect( + resolveJiraTriggerEnabled( + { issueTransitioned: { briefing: false, planning: true, implementation: false } }, + 'issueTransitioned', + ), + ).toBe(true); + }); + + it('returns false for issueTransitioned object when all agents disabled', () => { + expect( + resolveJiraTriggerEnabled( + { issueTransitioned: { briefing: false, planning: false, implementation: false } }, + 'issueTransitioned', + ), + ).toBe(false); + }); }); describe('resolveGitHubTriggerEnabled', () => { @@ -230,6 +263,48 @@ describe('resolveReadyToProcessEnabled', () => { }); }); +describe('resolveIssueTransitionedEnabled', () => { + it('returns true when config is undefined (backward compatible)', () => { + expect(resolveIssueTransitionedEnabled(undefined, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(undefined, 'planning')).toBe(true); + expect(resolveIssueTransitionedEnabled(undefined, 'implementation')).toBe(true); + }); + + it('returns true when issueTransitioned is not set', () => { + expect(resolveIssueTransitionedEnabled({}, 'briefing')).toBe(true); + }); + + it('applies legacy boolean true to all agents', () => { + const config = { issueTransitioned: true as const }; + expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(true); + }); + + it('applies legacy boolean false to all agents', () => { + const config = { issueTransitioned: false as const }; + expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(false); + expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(false); + expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(false); + }); + + it('returns per-agent value from nested object', () => { + const config = { + issueTransitioned: { briefing: true, planning: false, implementation: true }, + }; + expect(resolveIssueTransitionedEnabled(config, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(false); + expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(true); + }); + + it('defaults to true for unknown agent types', () => { + const config = { + issueTransitioned: { briefing: false, planning: false, implementation: false }, + }; + expect(resolveIssueTransitionedEnabled(config, 'unknown-agent')).toBe(true); + }); +}); + describe('resolveReviewTriggerConfig', () => { it('maps legacy defaults when config is undefined (backward compatible)', () => { // No config → legacy fallback: checkSuiteSuccess defaults to true → ownPrsOnly=true diff --git a/tests/unit/triggers/jira-issue-transitioned.test.ts b/tests/unit/triggers/jira-issue-transitioned.test.ts index d9a2efe0..dc962132 100644 --- a/tests/unit/triggers/jira-issue-transitioned.test.ts +++ b/tests/unit/triggers/jira-issue-transitioned.test.ts @@ -36,9 +36,15 @@ function buildCtx( issueKey?: string; statusChangeItems?: Array<{ field?: string; fromString?: string; toString?: string }>; noJiraConfig?: boolean; + triggers?: Record; } = {}, ): TriggerContext { - const project = overrides.noJiraConfig ? { ...mockProject, jira: undefined } : mockProject; + const baseJira = overrides.triggers + ? { ...mockProject.jira, triggers: overrides.triggers } + : mockProject.jira; + const project = overrides.noJiraConfig + ? { ...mockProject, jira: undefined } + : { ...mockProject, jira: baseJira }; return { project: project as TriggerContext['project'], @@ -238,5 +244,81 @@ describe('JiraIssueTransitionedTrigger', () => { expect(result).toBeNull(); }); + + describe('per-agent issueTransitioned toggle', () => { + it('fires when issueTransitioned toggle is true for agent (legacy boolean)', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + triggers: { issueTransitioned: true }, + }); + + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('briefing'); + }); + + it('returns null when issueTransitioned disabled globally (legacy boolean false)', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + triggers: { issueTransitioned: false }, + }); + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + + it('fires when per-agent issueTransitioned.briefing is enabled', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + triggers: { + issueTransitioned: { briefing: true, planning: false, implementation: false }, + }, + }); + + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('briefing'); + }); + + it('returns null when per-agent issueTransitioned.briefing is disabled', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], + triggers: { + issueTransitioned: { briefing: false, planning: true, implementation: true }, + }, + }); + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + + it('fires planning agent when issueTransitioned.planning is enabled', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Briefing', toString: 'Planning' }], + triggers: { + issueTransitioned: { briefing: false, planning: true, implementation: false }, + }, + }); + + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('planning'); + }); + + it('returns null when per-agent issueTransitioned.implementation is disabled', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], + triggers: { + issueTransitioned: { briefing: true, planning: true, implementation: false }, + }, + }); + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + }); }); }); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index 6dd6a810..b7cdda9c 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -44,15 +44,6 @@ export const LIFECYCLE_TRIGGERS: TriggerDef[] = [ * Displayed once in a dedicated section rather than duplicated per-agent. */ export const SHARED_PM_TRIGGERS: TriggerDef[] = [ - { - key: 'issueTransitioned', - label: 'Issue Transitioned', - description: - 'Trigger agent when a JIRA issue transitions to a configured status. Affects briefing, planning, and implementation agents.', - defaultValue: true, - pmProvider: 'jira', - category: 'pm', - }, { key: 'commentMention', label: 'Comment @mention', @@ -76,6 +67,15 @@ export const AGENT_TRIGGER_MAP: Record = { pmProvider: 'trello', category: 'pm', }, + { + key: 'issueTransitioned.briefing', + label: 'Issue Transitioned', + description: + 'Trigger briefing agent when a JIRA issue transitions to the configured Briefing status.', + defaultValue: true, + pmProvider: 'jira', + category: 'pm', + }, { key: 'readyToProcessLabel.briefing', label: 'Ready to Process label', @@ -94,6 +94,15 @@ export const AGENT_TRIGGER_MAP: Record = { pmProvider: 'trello', category: 'pm', }, + { + key: 'issueTransitioned.planning', + label: 'Issue Transitioned', + description: + 'Trigger planning agent when a JIRA issue transitions to the configured Planning status.', + defaultValue: true, + pmProvider: 'jira', + category: 'pm', + }, { key: 'readyToProcessLabel.planning', label: 'Ready to Process label', @@ -112,6 +121,15 @@ export const AGENT_TRIGGER_MAP: Record = { pmProvider: 'trello', category: 'pm', }, + { + key: 'issueTransitioned.implementation', + label: 'Issue Transitioned', + description: + 'Trigger implementation agent when a JIRA issue transitions to the configured Todo status.', + defaultValue: true, + pmProvider: 'jira', + category: 'pm', + }, { key: 'readyToProcessLabel.implementation', label: 'Ready to Process label', From ed230013750bc9636ccff31bd77d7ba42eab1a27 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 12:53:10 +0100 Subject: [PATCH 08/14] feat(pm): replace PR comment links with native attachments/remote links (#498) Co-authored-by: Cascade Bot --- src/jira/client.ts | 17 +++++ src/pm/jira/adapter.ts | 4 ++ src/pm/lifecycle.ts | 30 +++++++-- src/pm/trello/adapter.ts | 4 ++ src/pm/types.ts | 3 + tests/helpers/mockPMProvider.ts | 1 + tests/unit/jira/client.test.ts | 71 +++++++++++++++++++++ tests/unit/pm/jira/adapter.test.ts | 15 +++++ tests/unit/pm/lifecycle.test.ts | 92 +++++++++++++++++++++------- tests/unit/pm/trello/adapter.test.ts | 14 +++++ 10 files changed, 225 insertions(+), 26 deletions(-) diff --git a/src/jira/client.ts b/src/jira/client.ts index 7a20fce7..0fc0e1fb 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -253,4 +253,21 @@ export const jiraClient = { }, }); }, + + async addRemoteLink(issueKey: string, url: string, title: string): Promise { + logger.debug('Adding JIRA remote link', { issueKey, url, title }); + await getClient().issueRemoteLinks.createOrUpdateRemoteIssueLink({ + issueIdOrKey: issueKey, + globalId: url, + relationship: 'Pull Request', + object: { + url, + title, + icon: { + url16x16: 'https://github.com/favicon.ico', + title: 'GitHub', + }, + }, + }); + }, }; diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index 3bb983a4..34786c4d 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -331,6 +331,10 @@ export class JiraPMProvider implements PMProvider { await this.addComment(_workItemId, `Attachment: [${name}](${url})`); } + async linkPR(workItemId: string, prUrl: string, prTitle: string): Promise { + await jiraClient.addRemoteLink(workItemId, prUrl, prTitle); + } + async addAttachmentFile( workItemId: string, buffer: Buffer, diff --git a/src/pm/lifecycle.ts b/src/pm/lifecycle.ts index cbd3c6c5..8880c0c6 100644 --- a/src/pm/lifecycle.ts +++ b/src/pm/lifecycle.ts @@ -29,6 +29,15 @@ export interface ProjectPMConfig { }; } +/** + * Extract a human-readable PR title from a GitHub PR URL. + * E.g. "https://github.com/owner/repo/pull/123" → "Pull Request #123" + */ +export function extractPRTitle(prUrl: string): string { + const match = prUrl.match(/\/pull\/(\d+)/); + return match ? `Pull Request #${match[1]}` : 'Pull Request'; +} + /** * Resolve PM-specific config (labels, statuses) from project configuration. * Delegates to the registered integration's resolveLifecycleConfig(). @@ -64,11 +73,22 @@ export class PMLifecycleManager { if (agentType === 'implementation') { await this.safeMove(workItemId, this.pmConfig.statuses.inReview); if (prUrl) { - if (progressCommentId) { - // Replace the progress comment with the "PR created" message - await this.safeUpdateOrAddComment(workItemId, progressCommentId, `PR created: ${prUrl}`); - } else { - await this.safeAddComment(workItemId, `PR created: ${prUrl}`); + const prTitle = extractPRTitle(prUrl); + let linked = false; + try { + await this.provider.linkPR(workItemId, prUrl, prTitle); + linked = true; + } catch { + // linkPR failed — fall through to comment fallback + } + if (!linked) { + const message = `PR created: ${prUrl}`; + if (progressCommentId) { + // Replace the progress comment with the "PR created" message + await this.safeUpdateOrAddComment(workItemId, progressCommentId, message); + } else { + await this.safeAddComment(workItemId, message); + } } } } diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts index ff42461f..f879c576 100644 --- a/src/pm/trello/adapter.ts +++ b/src/pm/trello/adapter.ts @@ -196,6 +196,10 @@ export class TrelloPMProvider implements PMProvider { await trelloClient.addAttachment(workItemId, url, name); } + async linkPR(workItemId: string, prUrl: string, prTitle: string): Promise { + await trelloClient.addAttachment(workItemId, prUrl, prTitle); + } + async addAttachmentFile( workItemId: string, buffer: Buffer, diff --git a/src/pm/types.ts b/src/pm/types.ts index 5c90a3d6..00cd326d 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -101,6 +101,9 @@ export interface PMProvider { getCustomFieldNumber(workItemId: string, fieldId: string): Promise; updateCustomFieldNumber(workItemId: string, fieldId: string, value: number): Promise; + // PR linking + linkPR(workItemId: string, prUrl: string, prTitle: string): Promise; + // Utility getWorkItemUrl(id: string): string; getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }>; diff --git a/tests/helpers/mockPMProvider.ts b/tests/helpers/mockPMProvider.ts index 573ba462..c846cdc1 100644 --- a/tests/helpers/mockPMProvider.ts +++ b/tests/helpers/mockPMProvider.ts @@ -33,6 +33,7 @@ export function createMockPMProvider() { deleteChecklistItem: vi.fn(), addAttachment: vi.fn(), addAttachmentFile: vi.fn(), + linkPR: vi.fn().mockResolvedValue(undefined), getCustomFieldNumber: vi.fn(), updateCustomFieldNumber: vi.fn(), getWorkItemUrl: vi.fn(), diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index 23413dcb..db763e9d 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -15,6 +15,7 @@ const { mockIssueComments, mockIssueSearch, mockIssueAttachments, + mockIssueRemoteLinks, mockMyself, mockProjects, } = vi.hoisted(() => ({ @@ -37,6 +38,9 @@ const { mockIssueAttachments: { addAttachment: vi.fn(), }, + mockIssueRemoteLinks: { + createOrUpdateRemoteIssueLink: vi.fn(), + }, mockMyself: { getCurrentUser: vi.fn(), }, @@ -51,6 +55,7 @@ vi.mock('jira.js', () => ({ issueComments: mockIssueComments, issueSearch: mockIssueSearch, issueAttachments: mockIssueAttachments, + issueRemoteLinks: mockIssueRemoteLinks, myself: mockMyself, projects: mockProjects, })), @@ -84,6 +89,7 @@ describe('jiraClient', () => { mockIssueComments.updateComment.mockReset(); mockIssueSearch.searchForIssuesUsingJql.mockReset(); mockIssueAttachments.addAttachment.mockReset(); + mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockReset(); mockMyself.getCurrentUser.mockReset(); mockProjects.getProject.mockReset(); _resetCloudIdCache(); @@ -567,6 +573,71 @@ describe('jiraClient', () => { }); }); + describe('addRemoteLink', () => { + it('calls createOrUpdateRemoteIssueLink with correct params', async () => { + mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockResolvedValue({ id: 'link-1' }); + + await withJiraCredentials(creds, () => + jiraClient.addRemoteLink( + 'TEST-1', + 'https://github.com/owner/repo/pull/42', + 'Pull Request #42', + ), + ); + + expect(mockIssueRemoteLinks.createOrUpdateRemoteIssueLink).toHaveBeenCalledWith( + expect.objectContaining({ + issueIdOrKey: 'TEST-1', + globalId: 'https://github.com/owner/repo/pull/42', + relationship: 'Pull Request', + object: expect.objectContaining({ + url: 'https://github.com/owner/repo/pull/42', + title: 'Pull Request #42', + }), + }), + ); + }); + + it('uses PR URL as globalId for idempotency', async () => { + mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockResolvedValue({ id: 'link-2' }); + const prUrl = 'https://github.com/owner/repo/pull/99'; + + await withJiraCredentials(creds, () => + jiraClient.addRemoteLink('PROJ-5', prUrl, 'Pull Request #99'), + ); + + expect(mockIssueRemoteLinks.createOrUpdateRemoteIssueLink).toHaveBeenCalledWith( + expect.objectContaining({ + globalId: prUrl, + }), + ); + }); + + it('sets GitHub favicon icon on the remote link object', async () => { + mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockResolvedValue({}); + + await withJiraCredentials(creds, () => + jiraClient.addRemoteLink('TEST-1', 'https://github.com/owner/repo/pull/1', 'PR #1'), + ); + + expect(mockIssueRemoteLinks.createOrUpdateRemoteIssueLink).toHaveBeenCalledWith( + expect.objectContaining({ + object: expect.objectContaining({ + icon: expect.objectContaining({ + url16x16: 'https://github.com/favicon.ico', + }), + }), + }), + ); + }); + + it('throws when called outside withJiraCredentials scope', async () => { + await expect( + jiraClient.addRemoteLink('TEST-1', 'https://github.com/pr/1', 'PR #1'), + ).rejects.toThrow('No JIRA credentials in scope'); + }); + }); + describe('getIssueComments', () => { it('returns comments array', async () => { const comments = [{ id: 'c1', body: 'First comment' }]; diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index d6a93135..e5609f12 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -17,6 +17,7 @@ const { mockJiraClient, mockAdfToPlainText, mockMarkdownToAdf } = vi.hoisted(() getIssueLabels: vi.fn(), updateLabels: vi.fn(), addAttachmentFile: vi.fn(), + addRemoteLink: vi.fn(), getCustomFieldValue: vi.fn(), updateCustomField: vi.fn(), getMyself: vi.fn(), @@ -685,6 +686,20 @@ describe('JiraPMProvider', () => { }); }); + describe('linkPR', () => { + it('delegates to jiraClient.addRemoteLink with workItemId, prUrl, and prTitle', async () => { + mockJiraClient.addRemoteLink.mockResolvedValue(undefined); + + await provider.linkPR('PROJ-1', 'https://github.com/owner/repo/pull/42', 'Pull Request #42'); + + expect(mockJiraClient.addRemoteLink).toHaveBeenCalledWith( + 'PROJ-1', + 'https://github.com/owner/repo/pull/42', + 'Pull Request #42', + ); + }); + }); + describe('getCustomFieldNumber', () => { it('returns numeric custom field value', async () => { mockJiraClient.getCustomFieldValue.mockResolvedValue(99); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index f45c7374..34e190d9 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -28,12 +28,33 @@ import '../../../src/pm/index.js'; import { PMLifecycleManager, type ProjectPMConfig, + extractPRTitle, resolveProjectPMConfig, } from '../../../src/pm/lifecycle.js'; import type { PMProvider } from '../../../src/pm/types.js'; import type { ProjectConfig } from '../../../src/types/index.js'; describe('pm/lifecycle', () => { + describe('extractPRTitle', () => { + it('extracts PR number from a standard GitHub PR URL', () => { + expect(extractPRTitle('https://github.com/owner/repo/pull/123')).toBe('Pull Request #123'); + }); + + it('extracts PR number from a PR URL with trailing path', () => { + expect(extractPRTitle('https://github.com/owner/repo/pull/42/files')).toBe( + 'Pull Request #42', + ); + }); + + it('returns generic title when URL does not contain /pull/', () => { + expect(extractPRTitle('https://example.com/no-pull-here')).toBe('Pull Request'); + }); + + it('returns generic title for empty string', () => { + expect(extractPRTitle('')).toBe('Pull Request'); + }); + }); + describe('resolveProjectPMConfig', () => { it('returns JIRA config when project type is jira', () => { const project: ProjectConfig = { @@ -275,6 +296,7 @@ describe('pm/lifecycle', () => { moveWorkItem: vi.fn().mockResolvedValue(undefined), addComment: vi.fn().mockResolvedValue(undefined), updateComment: vi.fn().mockResolvedValue(undefined), + linkPR: vi.fn().mockResolvedValue(undefined), // Other PMProvider methods (not used by lifecycle manager) getWorkItem: vi.fn(), getWorkItemComments: vi.fn(), @@ -361,18 +383,31 @@ describe('pm/lifecycle', () => { expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-review'); }); - it('adds PR comment when prUrl is provided for implementation agent', async () => { - await manager.handleSuccess('work-item-1', 'implementation', 'https://github.com/pr/123'); + it('calls linkPR when prUrl is provided for implementation agent', async () => { + await manager.handleSuccess( + 'work-item-1', + 'implementation', + 'https://github.com/owner/repo/pull/123', + ); - expect(mockProvider.addComment).toHaveBeenCalledWith( + expect(mockProvider.linkPR).toHaveBeenCalledWith( 'work-item-1', - 'PR created: https://github.com/pr/123', + 'https://github.com/owner/repo/pull/123', + 'Pull Request #123', ); }); - it('does not add PR comment when prUrl is not provided', async () => { + it('does not post comment when linkPR succeeds', async () => { + await manager.handleSuccess('work-item-1', 'implementation', 'https://github.com/pr/123'); + + expect(mockProvider.addComment).not.toHaveBeenCalled(); + expect(mockProvider.updateComment).not.toHaveBeenCalled(); + }); + + it('does not call linkPR when prUrl is not provided', async () => { await manager.handleSuccess('work-item-1', 'implementation'); + expect(mockProvider.linkPR).not.toHaveBeenCalled(); expect(mockProvider.addComment).not.toHaveBeenCalled(); }); @@ -382,24 +417,34 @@ describe('pm/lifecycle', () => { expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); - it('updates existing progress comment when progressCommentId provided', async () => { + it('does not call linkPR for non-implementation agents even with prUrl', async () => { + await manager.handleSuccess('work-item-1', 'briefing', 'https://github.com/pr/123'); + + expect(mockProvider.linkPR).not.toHaveBeenCalled(); + }); + + it('falls back to addComment when linkPR fails and no progressCommentId', async () => { + vi.mocked(mockProvider.linkPR).mockRejectedValue(new Error('Permission denied')); + await manager.handleSuccess( 'work-item-1', 'implementation', - 'https://github.com/pr/123', - 'comment-abc', + 'https://github.com/owner/repo/pull/123', ); - expect(mockProvider.updateComment).toHaveBeenCalledWith( + expect(mockProvider.linkPR).toHaveBeenCalledWith( 'work-item-1', - 'comment-abc', - 'PR created: https://github.com/pr/123', + 'https://github.com/owner/repo/pull/123', + 'Pull Request #123', + ); + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'PR created: https://github.com/owner/repo/pull/123', ); - expect(mockProvider.addComment).not.toHaveBeenCalled(); }); - it('falls back to addComment when updateComment fails', async () => { - vi.mocked(mockProvider.updateComment).mockRejectedValue(new Error('Comment not found')); + it('falls back to updateComment when linkPR fails and progressCommentId provided', async () => { + vi.mocked(mockProvider.linkPR).mockRejectedValue(new Error('Permission denied')); await manager.handleSuccess( 'work-item-1', @@ -408,25 +453,30 @@ describe('pm/lifecycle', () => { 'comment-abc', ); + expect(mockProvider.linkPR).toHaveBeenCalled(); expect(mockProvider.updateComment).toHaveBeenCalledWith( 'work-item-1', 'comment-abc', 'PR created: https://github.com/pr/123', ); - expect(mockProvider.addComment).toHaveBeenCalledWith( - 'work-item-1', - 'PR created: https://github.com/pr/123', - ); + expect(mockProvider.addComment).not.toHaveBeenCalled(); }); - it('uses addComment when progressCommentId is not provided', async () => { - await manager.handleSuccess('work-item-1', 'implementation', 'https://github.com/pr/123'); + it('falls back to addComment when linkPR fails and updateComment also fails', async () => { + vi.mocked(mockProvider.linkPR).mockRejectedValue(new Error('Permission denied')); + vi.mocked(mockProvider.updateComment).mockRejectedValue(new Error('Comment not found')); + + await manager.handleSuccess( + 'work-item-1', + 'implementation', + 'https://github.com/pr/123', + 'comment-abc', + ); expect(mockProvider.addComment).toHaveBeenCalledWith( 'work-item-1', 'PR created: https://github.com/pr/123', ); - expect(mockProvider.updateComment).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/pm/trello/adapter.test.ts b/tests/unit/pm/trello/adapter.test.ts index 5072d3c7..d26585f6 100644 --- a/tests/unit/pm/trello/adapter.test.ts +++ b/tests/unit/pm/trello/adapter.test.ts @@ -419,6 +419,20 @@ describe('TrelloPMProvider', () => { }); }); + describe('linkPR', () => { + it('delegates to trelloClient.addAttachment with prUrl and prTitle', async () => { + mockTrelloClient.addAttachment.mockResolvedValue(undefined); + + await provider.linkPR('card-1', 'https://github.com/owner/repo/pull/42', 'Pull Request #42'); + + expect(mockTrelloClient.addAttachment).toHaveBeenCalledWith( + 'card-1', + 'https://github.com/owner/repo/pull/42', + 'Pull Request #42', + ); + }); + }); + describe('getCustomFieldNumber', () => { it('returns the parsed number from custom field items', async () => { mockTrelloClient.getCardCustomFieldItems.mockResolvedValue([ From a5f9b9c2fc106991e6ba78e4913408d5d0cfcdad Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 13:12:41 +0100 Subject: [PATCH 09/14] feat(dashboard): replace free-text key inputs with dropdowns in PM integration form (#499) Co-authored-by: Cascade Bot --- .../components/projects/integration-form.tsx | 172 +++++++++++++++++- 1 file changed, 165 insertions(+), 7 deletions(-) diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 0a752bc7..0db0b7fa 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -156,6 +156,141 @@ function CredentialSelector({ ); } +// ============================================================================ +// Known key constants for constrained editors +// ============================================================================ + +interface KeyOption { + value: string; + label: string; +} + +const TRELLO_LIST_KEYS: KeyOption[] = [ + { value: 'briefing', label: 'briefing' }, + { value: 'stories', label: 'stories' }, + { value: 'planning', label: 'planning' }, + { value: 'todo', label: 'todo' }, + { value: 'inProgress', label: 'inProgress' }, + { value: 'inReview', label: 'inReview' }, + { value: 'done', label: 'done' }, + { value: 'merged', label: 'merged' }, + { value: 'debug', label: 'debug' }, +]; + +const TRELLO_LABEL_KEYS: KeyOption[] = [ + { value: 'readyToProcess', label: 'readyToProcess' }, + { value: 'processing', label: 'processing' }, + { value: 'processed', label: 'processed' }, + { value: 'error', label: 'error' }, +]; + +const JIRA_STATUS_KEYS: KeyOption[] = [ + { value: 'briefing', label: 'briefing' }, + { value: 'planning', label: 'planning' }, + { value: 'todo', label: 'todo' }, + { value: 'inProgress', label: 'inProgress' }, + { value: 'inReview', label: 'inReview' }, + { value: 'done', label: 'done' }, + { value: 'merged', label: 'merged' }, +]; + +const JIRA_LABEL_KEYS: KeyOption[] = [ + { value: 'processing', label: 'processing' }, + { value: 'processed', label: 'processed' }, + { value: 'error', label: 'error' }, + { value: 'readyToProcess', label: 'readyToProcess' }, +]; + +// ============================================================================ +// ConstrainedKeyValueEditor — key column is a dropdown of allowed keys +// ============================================================================ + +function ConstrainedKeyValueEditor({ + label, + pairs, + onChange, + allowedKeys, + valuePlaceholder, +}: { + label: string; + pairs: KVPair[]; + onChange: (pairs: KVPair[]) => void; + allowedKeys: KeyOption[]; + valuePlaceholder?: string; +}) { + const usedKeys = new Set(pairs.map((p) => p.key)); + const availableKeys = allowedKeys.filter((k) => !usedKeys.has(k.value)); + const allUsed = availableKeys.length === 0; + + const handleAdd = () => { + // Pick the first unused allowed key, or empty string if all used + const firstAvailable = availableKeys[0]?.value ?? ''; + onChange([...pairs, { key: firstAvailable, value: '' }]); + }; + + return ( +
+
+ + +
+ {pairs.map((pair, i) => { + // Keys available for this row: allowed keys not used by OTHER rows + const otherUsedKeys = new Set(pairs.filter((_, j) => j !== i).map((p) => p.key)); + // Build options: all allowed keys not used elsewhere + current key if it's custom + const rowOptions = allowedKeys.filter((k) => !otherUsedKeys.has(k.value)); + const isCustomKey = pair.key !== '' && !allowedKeys.some((k) => k.value === pair.key); + + return ( +
+ + { + const next = [...pairs]; + next[i] = { ...next[i], value: e.target.value }; + onChange(next); + }} + placeholder={valuePlaceholder ?? 'Value'} + className="flex-1" + /> + +
+ ); + })} + {pairs.length === 0 &&

No entries

} +
+ ); +} + // ============================================================================ // Provider-specific credential role definitions // ============================================================================ @@ -419,8 +554,20 @@ function PMTab({ placeholder="Trello board ID" /> - - + +
- +

- Map CASCADE statuses (briefing, planning, todo, inProgress, inReview, done, merged) to - JIRA status names. + Map each CASCADE status key to the corresponding JIRA status name.

- +

- JIRA label names used by CASCADE. Keys: processing, processed, error, readyToProcess. + Map each CASCADE label key to the corresponding JIRA label name.

From f2e6e75c9f8103f4806463d15e4d6ec11fffa811 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 13:35:05 +0100 Subject: [PATCH 10/14] feat(progress): add agent-specific emojis and labels to progress update headers (#500) Co-authored-by: Cascade Bot --- src/backends/progressModel.ts | 6 +- src/config/agentMessages.ts | 27 +++++++++ src/config/statusUpdateConfig.ts | 5 +- tests/unit/config/statusUpdateConfig.test.ts | 59 +++++++++++++++++++- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 44a27fb2..3222c19e 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -7,6 +7,7 @@ import { AgentBuilder, LLMist, type ModelSpec } from 'llmist'; +import { getAgentLabel } from '../config/agentMessages.js'; import type { Todo } from '../gadgets/todo/storage.js'; export interface ProgressContext { @@ -21,7 +22,7 @@ export interface ProgressContext { completedTasks?: { subject: string; summary: string; timestamp: number }[]; } -const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Synthesize the agent's own commentary, tool call details (file paths, commands), and completed task summaries into a coherent narrative — do not just list tool names. Use markdown formatting. Write in first person (e.g. "I'm implementing...", "I've completed...", "I'm currently working on..."). Start with a bold header like "**Progress update** (X min)". Do not include a progress bar — the system adds that separately.`; +const PROGRESS_SYSTEM_PROMPT = `You are a progress reporter for an AI coding agent called CASCADE. Write a brief, informative progress update based on the agent's current state. Be concise (3-5 sentences max). Focus on what has been accomplished, what's currently in progress, and what remains. Synthesize the agent's own commentary, tool call details (file paths, commands), and completed task summaries into a coherent narrative — do not just list tool names. Use markdown formatting. Write in first person (e.g. "I'm implementing...", "I've completed...", "I'm currently working on..."). Start with a bold header using the exact header provided in the user prompt context (e.g. "**🧑‍💻 Implementation Update** (X min)"). Do not include a progress bar — the system adds that separately.`; function formatProgressUserPrompt(context: ProgressContext): string { const { @@ -35,8 +36,11 @@ function formatProgressUserPrompt(context: ProgressContext): string { completedTasks, } = context; + const { emoji, label } = getAgentLabel(agentType); + const sections: string[] = [ `Agent: ${agentType}`, + `Progress header: **${emoji} ${label}** (${Math.round(elapsedMinutes)} min)`, `Task: ${taskDescription.slice(0, 500)}`, `Time elapsed: ${Math.round(elapsedMinutes)} minutes`, `Iterations: ${iteration}`, diff --git a/src/config/agentMessages.ts b/src/config/agentMessages.ts index fc2b7d18..06171d6e 100644 --- a/src/config/agentMessages.ts +++ b/src/config/agentMessages.ts @@ -1,3 +1,30 @@ +/** + * Agent-specific emoji and label for progress update headers. + * + * Used by: + * - progressModel.ts — LLM prompt to produce correct header + * - statusUpdateConfig.ts — template fallback header + */ +export const AGENT_LABELS: Record = { + briefing: { emoji: '📋', label: 'Briefing Update' }, + planning: { emoji: '🗺️', label: 'Planning Update' }, + implementation: { emoji: '🧑‍💻', label: 'Implementation Update' }, + review: { emoji: '🔍', label: 'Code Review Update' }, + 'respond-to-planning-comment': { emoji: '💬', label: 'Planning Response Update' }, + 'respond-to-review': { emoji: '🔧', label: 'Review Response Update' }, + 'respond-to-pr-comment': { emoji: '💬', label: 'PR Comment Response Update' }, + 'respond-to-ci': { emoji: '🔧', label: 'CI Fix Update' }, + debug: { emoji: '🐛', label: 'Debug Update' }, +}; + +/** + * Get the emoji and label for a given agent type. + * Falls back to a generic label for unknown agent types. + */ +export function getAgentLabel(agentType: string): { emoji: string; label: string } { + return AGENT_LABELS[agentType] ?? { emoji: '⚙️', label: 'Progress Update' }; +} + /** * Human-readable initial messages per agent type. * diff --git a/src/config/statusUpdateConfig.ts b/src/config/statusUpdateConfig.ts index c9453cf6..edbaaaa9 100644 --- a/src/config/statusUpdateConfig.ts +++ b/src/config/statusUpdateConfig.ts @@ -6,6 +6,7 @@ */ import { formatTodoList, loadTodos } from '../gadgets/todo/storage.js'; +import { getAgentLabel } from './agentMessages.js'; /** * Configuration for periodic status updates. @@ -70,8 +71,10 @@ export function formatStatusMessage( const doneCount = todos.filter((t) => t.status === 'done').length; const totalCount = todos.length; + const { emoji, label } = getAgentLabel(agentType); + const lines = [ - `**I'm making progress** (${agentType})`, + `**${emoji} ${label}** (${agentType})`, '', `${progressBar} ${progress}% (iteration ${iteration}/${maxIterations})`, ]; diff --git a/tests/unit/config/statusUpdateConfig.test.ts b/tests/unit/config/statusUpdateConfig.test.ts index 3b406285..049b8d11 100644 --- a/tests/unit/config/statusUpdateConfig.test.ts +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getAgentLabel } from '../../../src/config/agentMessages.js'; import { formatGitHubProgressComment, formatStatusMessage, @@ -54,13 +55,65 @@ describe('config/statusUpdateConfig', () => { }); }); + describe('getAgentLabel', () => { + it('returns correct emoji and label for implementation', () => { + const result = getAgentLabel('implementation'); + expect(result).toEqual({ emoji: '🧑‍💻', label: 'Implementation Update' }); + }); + + it('returns correct emoji and label for review', () => { + const result = getAgentLabel('review'); + expect(result).toEqual({ emoji: '🔍', label: 'Code Review Update' }); + }); + + it('returns correct emoji and label for briefing', () => { + const result = getAgentLabel('briefing'); + expect(result).toEqual({ emoji: '📋', label: 'Briefing Update' }); + }); + + it('returns correct emoji and label for planning', () => { + const result = getAgentLabel('planning'); + expect(result).toEqual({ emoji: '🗺️', label: 'Planning Update' }); + }); + + it('returns correct emoji and label for respond-to-review', () => { + const result = getAgentLabel('respond-to-review'); + expect(result).toEqual({ emoji: '🔧', label: 'Review Response Update' }); + }); + + it('returns correct emoji and label for respond-to-ci', () => { + const result = getAgentLabel('respond-to-ci'); + expect(result).toEqual({ emoji: '🔧', label: 'CI Fix Update' }); + }); + + it('returns correct emoji and label for respond-to-pr-comment', () => { + const result = getAgentLabel('respond-to-pr-comment'); + expect(result).toEqual({ emoji: '💬', label: 'PR Comment Response Update' }); + }); + + it('returns correct emoji and label for respond-to-planning-comment', () => { + const result = getAgentLabel('respond-to-planning-comment'); + expect(result).toEqual({ emoji: '💬', label: 'Planning Response Update' }); + }); + + it('returns correct emoji and label for debug', () => { + const result = getAgentLabel('debug'); + expect(result).toEqual({ emoji: '🐛', label: 'Debug Update' }); + }); + + it('returns default fallback for unknown agent types', () => { + const result = getAgentLabel('future-unknown-agent'); + expect(result).toEqual({ emoji: '⚙️', label: 'Progress Update' }); + }); + }); + describe('formatStatusMessage', () => { - it('includes agent type and progress bar', () => { + it('includes agent-specific emoji/label and progress bar', () => { vi.mocked(loadTodos).mockReturnValue([]); const message = formatStatusMessage(5, 20, 'implementation'); - expect(message).toContain("**I'm making progress**"); + expect(message).toContain('**🧑‍💻 Implementation Update**'); expect(message).toContain('implementation'); expect(message).toContain('25%'); // (5/20) * 100 expect(message).toContain('iteration 5/20'); @@ -151,7 +204,7 @@ describe('config/statusUpdateConfig', () => { const message = formatStatusMessage(10, 20, 'implementation'); const lines = message.split('\n'); - expect(lines[0]).toBe("**I'm making progress** (implementation)"); + expect(lines[0]).toBe('**🧑‍💻 Implementation Update** (implementation)'); expect(lines[1]).toBe(''); expect(lines[2]).toContain('[█████░░░░░]'); }); From a4189fef26d831abe9325f44d783263a211d683b Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 13:50:13 +0100 Subject: [PATCH 11/14] fix(dashboard): fix review trigger toggles showing under PM section (#501) Co-authored-by: Cascade Bot --- tests/unit/web/triggerAgentMapping.test.ts | 88 +++++++++++++++++++ .../projects/project-agent-configs.tsx | 9 +- web/src/lib/trigger-agent-mapping.ts | 10 ++- 3 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 tests/unit/web/triggerAgentMapping.test.ts diff --git a/tests/unit/web/triggerAgentMapping.test.ts b/tests/unit/web/triggerAgentMapping.test.ts new file mode 100644 index 00000000..a9fb0198 --- /dev/null +++ b/tests/unit/web/triggerAgentMapping.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { getTriggersForAgent } from '../../../web/src/lib/trigger-agent-mapping.js'; + +describe('getTriggersForAgent', () => { + it('returns all triggers when no opts given (backward compatibility)', () => { + const triggers = getTriggersForAgent('review'); + expect(triggers).toHaveLength(3); + expect(triggers.map((t) => t.key)).toEqual([ + 'reviewTrigger.ownPrsOnly', + 'reviewTrigger.externalPrs', + 'reviewTrigger.onReviewRequested', + ]); + }); + + it('returns empty array for review agent with category: pm', () => { + const triggers = getTriggersForAgent('review', { category: 'pm' }); + expect(triggers).toHaveLength(0); + }); + + it('returns 3 review triggers for review agent with category: scm', () => { + const triggers = getTriggersForAgent('review', { category: 'scm' }); + expect(triggers).toHaveLength(3); + for (const t of triggers) { + expect(t.category).toBe('scm'); + } + }); + + it('returns PM-only triggers for briefing with category: pm and pmProvider: trello', () => { + const triggers = getTriggersForAgent('briefing', { category: 'pm', pmProvider: 'trello' }); + expect(triggers.length).toBeGreaterThan(0); + for (const t of triggers) { + expect(t.category).toBe('pm'); + // Should not include JIRA-only triggers + if (t.pmProvider) { + expect(t.pmProvider).toBe('trello'); + } + } + const keys = triggers.map((t) => t.key); + expect(keys).toContain('cardMovedToBriefing'); + expect(keys).toContain('readyToProcessLabel.briefing'); + expect(keys).not.toContain('issueTransitioned.briefing'); + }); + + it('returns empty array for briefing with category: scm', () => { + const triggers = getTriggersForAgent('briefing', { category: 'scm' }); + expect(triggers).toHaveLength(0); + }); + + it('filters by pmProvider without category', () => { + const jiraTriggers = getTriggersForAgent('briefing', { pmProvider: 'jira' }); + const trelloTriggers = getTriggersForAgent('briefing', { pmProvider: 'trello' }); + // JIRA provider should exclude cardMovedToBriefing (trello-only) + expect(jiraTriggers.map((t) => t.key)).not.toContain('cardMovedToBriefing'); + // Trello provider should exclude issueTransitioned.briefing (jira-only) + expect(trelloTriggers.map((t) => t.key)).not.toContain('issueTransitioned.briefing'); + }); + + it('returns empty array for unknown agent type', () => { + const triggers = getTriggersForAgent('unknown-agent', { category: 'pm' }); + expect(triggers).toHaveLength(0); + }); +}); + +describe('getTriggersForAgent — review trigger dot-notation keys and defaults', () => { + it('returns dot-notation keys for review SCM triggers', () => { + const triggerDefs = getTriggersForAgent('review', { category: 'scm' }); + + // Verify that the trigger definitions have the expected dot-notation keys + expect(triggerDefs.map((t) => t.key)).toEqual([ + 'reviewTrigger.ownPrsOnly', + 'reviewTrigger.externalPrs', + 'reviewTrigger.onReviewRequested', + ]); + + // Verify each trigger has the correct category + for (const t of triggerDefs) { + expect(t.category).toBe('scm'); + } + }); + + it('returns correct defaultValues for review triggers', () => { + const triggers = getTriggersForAgent('review', { category: 'scm' }); + const defaults = Object.fromEntries(triggers.map((t) => [t.key, t.defaultValue])); + expect(defaults['reviewTrigger.ownPrsOnly']).toBe(false); + expect(defaults['reviewTrigger.externalPrs']).toBe(false); + expect(defaults['reviewTrigger.onReviewRequested']).toBe(false); + }); +}); diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 8939a60a..4e242fab 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -115,8 +115,8 @@ function AgentSection({ const [pmSaved, setPmSaved] = useState(false); const [scmSaved, setScmSaved] = useState(false); - const agentPmTriggers = getTriggersForAgent(agentType, pmProvider); - const agentScmTriggers = getTriggersForAgent(agentType).filter((t) => t.category === 'scm'); + const agentPmTriggers = getTriggersForAgent(agentType, { pmProvider, category: 'pm' }); + const agentScmTriggers = getTriggersForAgent(agentType, { category: 'scm' }); const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0; @@ -144,10 +144,7 @@ function AgentSection({ const handleSaveScm = async () => { setScmSaving(true); try { - const relevant: Record = {}; - for (const t of agentScmTriggers) { - relevant[t.key] = localScmTriggers[t.key] ?? t.defaultValue; - } + const relevant = extractRelevantTriggers(agentScmTriggers, localScmTriggers); await onSaveTriggers('scm', relevant, agentType); setScmSaved(true); setTimeout(() => setScmSaved(false), 2000); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index b7cdda9c..ac4f655a 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -211,12 +211,16 @@ export const AGENT_TRIGGER_MAP: Record = { }; /** - * Get trigger definitions for a specific agent type, filtered by PM provider. + * Get trigger definitions for a specific agent type, filtered by PM provider and/or category. */ -export function getTriggersForAgent(agentType: string, pmProvider?: string): TriggerDef[] { +export function getTriggersForAgent( + agentType: string, + opts?: { pmProvider?: string; category?: 'pm' | 'scm' }, +): TriggerDef[] { const triggers = AGENT_TRIGGER_MAP[agentType] ?? []; return triggers.filter((t) => { - if (t.pmProvider && pmProvider && t.pmProvider !== pmProvider) return false; + if (opts?.category && t.category !== opts.category) return false; + if (t.pmProvider && opts?.pmProvider && t.pmProvider !== opts.pmProvider) return false; return true; }); } From 39a083fef8ea4a38b53f1d9083f608b96696d4fe Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Feb 2026 14:02:43 +0100 Subject: [PATCH 12/14] feat(sentry): add error monitoring for router, worker, and dashboard (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate @sentry/node v10 across all three container types (router, worker, dashboard) using Node's --import flag for early SDK initialization. Core: - Add src/instrument.ts (module preload) and src/sentry.ts (no-op wrappers) - Wire Sentry captures into all Hono error handlers, webhook processing, worker lifecycle, watchdog timeouts, queue errors, and retry exhaustion - Forward SENTRY_* env vars from router to spawned worker containers Bug fixes found during review: - Fix tracesSampleRate=0 silently becoming 0.1 (|| vs nullish check) - Add Sentry flush before watchdog process.exit(1) to drain queued events - Remove redundant try/catch in router webhook handlers that swallowed queue failures (Redis down → 200 instead of 500), restoring correct HTTP semantics for webhook provider retries - Add Sentry capture to server mode's handleProcessingError (was log-only) Code quality: - Extract dispatchJob() from worker-entry main() to fix cognitive complexity lint warning (17 > 15) - Fix all import ordering and formatting issues Tests: - Add tests/unit/sentry.test.ts — no-op behavior, context propagation - Add tests/unit/instrument.test.ts — conditional init, tracesSampleRate=0 - Add Sentry flush assertion to lifecycle watchdog tests - Add handleProcessingError Sentry capture test to webhookHandlers Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 4 + Dockerfile.dashboard | 2 +- Dockerfile.router | 2 +- Dockerfile.worker | 2 +- package-lock.json | 841 +++++++++++++++++++++- package.json | 1 + src/agents/shared/lifecycle.ts | 18 +- src/config/env.ts | 3 + src/config/retryConfig.ts | 11 + src/dashboard.ts | 7 + src/instrument.ts | 14 + src/router/index.ts | 27 +- src/router/queue.ts | 2 + src/router/worker-manager.ts | 45 ++ src/sentry.ts | 46 ++ src/server.ts | 5 + src/server/webhookHandlers.ts | 4 + src/utils/lifecycle.ts | 2 + src/worker-entry.ts | 122 ++-- tests/unit/instrument.test.ts | 95 +++ tests/unit/sentry.test.ts | 129 ++++ tests/unit/server/webhookHandlers.test.ts | 31 + tests/unit/utils/lifecycle.test.ts | 26 +- 23 files changed, 1385 insertions(+), 54 deletions(-) create mode 100644 src/instrument.ts create mode 100644 src/sentry.ts create mode 100644 tests/unit/instrument.test.ts create mode 100644 tests/unit/sentry.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3e8e8ed0..1c0939c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,10 @@ Optional (infrastructure): - `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled) - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code backend (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. +- `SENTRY_DSN` - Sentry DSN for error monitoring (router + worker) +- `SENTRY_ENVIRONMENT` - Sentry environment tag (default: NODE_ENV or 'production') +- `SENTRY_RELEASE` - Release identifier for source maps (e.g., git SHA) +- `SENTRY_TRACES_SAMPLE_RATE` - Trace sampling rate 0.0-1.0 (default: 0.1) **Project credentials** (`GITHUB_TOKEN_IMPLEMENTER`, `GITHUB_TOKEN_REVIEWER`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `credentials` table (org-scoped, encrypted at rest when `CREDENTIAL_MASTER_KEY` is set). Integration-specific credentials (GitHub tokens, Trello keys, JIRA tokens) are linked to integrations via the `integration_credentials` join table with provider-defined roles. Non-integration credentials (LLM API keys) remain org-scoped defaults. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index 5a5a2556..0641d5cd 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -30,4 +30,4 @@ COPY --from=builder /app/src/agents/prompts/templates ./dist/agents/prompts/temp ENV PORT=3001 EXPOSE 3001 -CMD ["node", "dist/dashboard.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/dashboard.js"] diff --git a/Dockerfile.router b/Dockerfile.router index dba72b5b..1fe39de1 100644 --- a/Dockerfile.router +++ b/Dockerfile.router @@ -30,4 +30,4 @@ COPY config ./config ENV PORT=3000 EXPOSE 3000 -CMD ["node", "dist/router/index.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/router/index.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker index 01e588ca..0fb43bba 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -102,4 +102,4 @@ COPY --chown=node:node src/agents/prompts/templates ./dist/agents/prompts/templa COPY --chown=node:node config ./config # Worker entry point - processes a single job and exits -CMD ["node", "dist/worker-entry.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/worker-entry.js"] diff --git a/package-lock.json b/package-lock.json index e7c9b950..14d6b994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@sentry/node": "^10.39.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", @@ -127,6 +128,23 @@ } } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", + "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", + "license": "Apache-2.0" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", + "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.8.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -2662,6 +2680,530 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", + "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", + "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", + "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", + "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", + "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", + "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", + "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", + "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", + "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", + "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/instrumentation": "0.211.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", + "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", + "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", + "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", + "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", + "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", + "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", + "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", + "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", + "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", + "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", + "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", + "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", + "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", + "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/resources": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -2670,6 +3212,47 @@ "node": ">=14" } }, + "node_modules/@prisma/instrumentation": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", + "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -3020,6 +3603,163 @@ "win32" ] }, + "node_modules/@sentry/core": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz", + "integrity": "sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.39.0.tgz", + "integrity": "sha512-dx66DtU/xkCTPEDsjU+mYSIEbzu06pzKNQcDA2wvx7wvwsUciZ5yA32Ce/o6p2uHHgy0/joJX9rP5J/BIijaOA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.5.0", + "@opentelemetry/core": "^2.5.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/instrumentation-amqplib": "0.58.0", + "@opentelemetry/instrumentation-connect": "0.54.0", + "@opentelemetry/instrumentation-dataloader": "0.28.0", + "@opentelemetry/instrumentation-express": "0.59.0", + "@opentelemetry/instrumentation-fs": "0.30.0", + "@opentelemetry/instrumentation-generic-pool": "0.54.0", + "@opentelemetry/instrumentation-graphql": "0.58.0", + "@opentelemetry/instrumentation-hapi": "0.57.0", + "@opentelemetry/instrumentation-http": "0.211.0", + "@opentelemetry/instrumentation-ioredis": "0.59.0", + "@opentelemetry/instrumentation-kafkajs": "0.20.0", + "@opentelemetry/instrumentation-knex": "0.55.0", + "@opentelemetry/instrumentation-koa": "0.59.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", + "@opentelemetry/instrumentation-mongodb": "0.64.0", + "@opentelemetry/instrumentation-mongoose": "0.57.0", + "@opentelemetry/instrumentation-mysql": "0.57.0", + "@opentelemetry/instrumentation-mysql2": "0.57.0", + "@opentelemetry/instrumentation-pg": "0.63.0", + "@opentelemetry/instrumentation-redis": "0.59.0", + "@opentelemetry/instrumentation-tedious": "0.30.0", + "@opentelemetry/instrumentation-undici": "0.21.0", + "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/semantic-conventions": "^1.39.0", + "@prisma/instrumentation": "7.2.0", + "@sentry/core": "10.39.0", + "@sentry/node-core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", + "import-in-the-middle": "^2.0.6", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.39.0.tgz", + "integrity": "sha512-xdeBG00TmtAcGvXnZNbqOCvnZ5kY3s5aT/L8wUQ0w0TT2KmrC9XL/7UHUfJ45TLbjl10kZOtaMQXgUjpwSJW+g==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.3.1", + "@sentry/core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/node/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/node/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/node/node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.39.0.tgz", + "integrity": "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "license": "MIT", @@ -3135,6 +3875,15 @@ "@types/node": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.2", "dev": true, @@ -3174,6 +3923,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.3", "license": "MIT", @@ -3185,7 +3943,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3193,6 +3950,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3241,6 +4007,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@unblessed/core": { "version": "1.0.0-alpha.23", "license": "MIT", @@ -3402,6 +4177,27 @@ "node": ">=6.5" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/adm-zip": { "version": "0.5.16", "license": "MIT", @@ -4026,6 +4822,12 @@ "version": "1.1.4", "license": "ISC" }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -5703,6 +6505,12 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "license": "MIT" @@ -6215,6 +7023,18 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "dev": true, @@ -7160,6 +7980,12 @@ "npm": ">=6" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -7868,6 +8694,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index 8de0e504..266a76dd 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@sentry/node": "^10.39.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts index 447ca582..6ef06947 100644 --- a/src/agents/shared/lifecycle.ts +++ b/src/agents/shared/lifecycle.ts @@ -8,6 +8,7 @@ import { storeLlmCallsBulk, storeRunLogs, } from '../../db/repositories/runsRepository.js'; +import { addBreadcrumb, captureException } from '../../sentry.js'; import type { AgentResult } from '../../types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../../utils/cascadeEnv.js'; import { createFileLogger } from '../../utils/fileLogger.js'; @@ -238,6 +239,11 @@ export async function executeAgentLifecycle( const log = createAgentLogger(fileLogger); setWatchdogCleanup(async () => { + const durationMs = Date.now() - startTime; + captureException(new Error('Agent watchdog timeout'), { + tags: { source: 'watchdog_timeout', agent: options.loggerIdentifier }, + extra: { runId, durationMs }, + }); fileLogger.close(); await finalizeRun( runId, @@ -245,7 +251,7 @@ export async function executeAgentLifecycle( llmCallAccumulator, { status: 'timed_out', - durationMs: Date.now() - startTime, + durationMs, success: false, error: 'Watchdog timeout', }, @@ -279,6 +285,12 @@ export async function executeAgentLifecycle( runId, }); + addBreadcrumb({ + category: 'agent', + message: `Starting ${options.loggerIdentifier}`, + data: { model: ctx.model, maxIterations: ctx.maxIterations, runId }, + }); + try { process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; process.env.LLMIST_LOG_TEE = 'true'; @@ -370,6 +382,10 @@ export async function executeAgentLifecycle( identifier: options.loggerIdentifier, error: String(err), }); + captureException(err, { + tags: { source: 'agent_lifecycle', agent: options.loggerIdentifier }, + extra: { runId, durationMs: Date.now() - startTime }, + }); let logBuffer: Buffer | undefined; try { diff --git a/src/config/env.ts b/src/config/env.ts index 41dffffd..a39d7a9a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -2,6 +2,7 @@ export interface EnvConfig { port: number; logLevel: string; databaseUrl: string; + sentryDsn?: string; } function getEnvOrThrow(key: string): string { @@ -21,6 +22,7 @@ export function loadEnvConfig(): EnvConfig { port: Number.parseInt(getEnvOrDefault('PORT', '3000'), 10), logLevel: getEnvOrDefault('LOG_LEVEL', 'info'), databaseUrl: getEnvOrThrow('DATABASE_URL'), + sentryDsn: process.env.SENTRY_DSN, }; } @@ -29,5 +31,6 @@ export function loadEnvConfigSafe(): Omit & { database port: Number.parseInt(getEnvOrDefault('PORT', '3000'), 10), logLevel: getEnvOrDefault('LOG_LEVEL', 'info'), databaseUrl: process.env.DATABASE_URL, + sentryDsn: process.env.SENTRY_DSN, }; } diff --git a/src/config/retryConfig.ts b/src/config/retryConfig.ts index 9afc8c4f..418ef331 100644 --- a/src/config/retryConfig.ts +++ b/src/config/retryConfig.ts @@ -1,5 +1,6 @@ import { type RetryConfig, isRetryableError } from 'llmist'; import type { ILogObj, Logger } from 'llmist'; +import { addBreadcrumb, captureException } from '../sentry.js'; /** * Check if an error is a transient stream/connection error from undici/fetch. @@ -59,6 +60,12 @@ export function getRetryConfig(logger: Logger): RetryConfig { isStreamError, nextRetryDelayMs: baseDelay, }); + addBreadcrumb({ + category: 'llm', + message: `LLM retry attempt ${attempt}/5`, + level: 'warning', + data: { attempt, error: error.message, isStreamError, nextRetryDelayMs: baseDelay }, + }); }, onRetriesExhausted: (error: Error, attempts: number) => { @@ -67,6 +74,10 @@ export function getRetryConfig(logger: Logger): RetryConfig { error: error.message, totalWaitTimeMs: `~${1000 + 2000 + 4000 + 8000 + 16000}`, // Approximate total }); + captureException(error, { + tags: { source: 'llm_retries_exhausted' }, + extra: { attempts }, + }); }, }; } diff --git a/src/dashboard.ts b/src/dashboard.ts index 144f62e9..464ebd48 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -23,6 +23,9 @@ import { logoutHandler } from './api/auth/logout.js'; import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; +import { captureException, setTag } from './sentry.js'; + +setTag('role', 'dashboard'); const app = new Hono(); @@ -65,6 +68,10 @@ app.notFound((c) => c.json({ error: 'Not Found' }, 404)); // Error handler app.onError((err, c) => { console.error('Unhandled error', { error: String(err), path: c.req.path }); + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); return c.json({ error: 'Internal Server Error' }, 500); }); diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 00000000..aeddaadf --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; + +if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production', + release: process.env.SENTRY_RELEASE || undefined, + tracesSampleRate: + process.env.SENTRY_TRACES_SAMPLE_RATE != null + ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) + : 0.1, + sendDefaultPii: false, + }); +} diff --git a/src/router/index.ts b/src/router/index.ts index 8232c82b..d24edd06 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,5 +1,6 @@ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; +import { captureException, flush, setTag } from '../sentry.js'; import { createWebhookHandler, parseGitHubPayload, @@ -19,12 +20,22 @@ import { stopWorkerProcessor, } from './worker-manager.js'; +setTag('role', 'router'); + // Create trigger registry once at router startup for matchTrigger() calls const triggerRegistry = createTriggerRegistry(); registerBuiltInTriggers(triggerRegistry); const app = new Hono(); +app.onError((err, c) => { + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); + return c.text('Internal Server Error', 500); +}); + // Health check with queue stats app.get('/health', async (c) => { const queueStats = await getQueueStats(); @@ -114,12 +125,24 @@ app.post( async function shutdown(signal: string): Promise { console.log(`[Router] Received ${signal}, shutting down...`); await stopWorkerProcessor(); + await flush(3000); process.exit(0); } process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('uncaughtException', (err) => { + captureException(err, { tags: { source: 'uncaughtException' }, level: 'fatal' }); +}); + +process.on('unhandledRejection', (reason) => { + captureException(reason instanceof Error ? reason : new Error(String(reason)), { + tags: { source: 'unhandledRejection' }, + level: 'error', + }); +}); + // Start server and worker processor async function startRouter(): Promise { const port = Number(process.env.PORT) || 3000; @@ -128,7 +151,9 @@ async function startRouter(): Promise { serve({ fetch: app.fetch, port }); } -startRouter().catch((err) => { +startRouter().catch(async (err) => { console.error('[Router] Failed to start:', err); + captureException(err, { tags: { source: 'router_startup' }, level: 'fatal' }); + await flush(3000); process.exit(1); }); diff --git a/src/router/queue.ts b/src/router/queue.ts index 80714d32..cb298542 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -1,4 +1,5 @@ import { type ConnectionOptions, Queue } from 'bullmq'; +import { captureException } from '../sentry.js'; import { routerConfig } from './config.js'; // Parse Redis URL to connection options @@ -67,6 +68,7 @@ export const jobQueue = new Queue('cascade-jobs', { // Queue event logging jobQueue.on('error', (err) => { console.error('[Queue] Error:', err); + captureException(err, { tags: { source: 'job_queue' } }); }); console.log('[Queue] Initialized with Redis at', routerConfig.redisUrl); diff --git a/src/router/worker-manager.ts b/src/router/worker-manager.ts index 4db95646..558e3638 100644 --- a/src/router/worker-manager.ts +++ b/src/router/worker-manager.ts @@ -1,6 +1,7 @@ import { type Job, Worker } from 'bullmq'; import Docker from 'dockerode'; import { findProjectByRepo, getAllProjectCredentials } from '../config/provider.js'; +import { captureException } from '../sentry.js'; import { routerConfig } from './config.js'; import { notifyTimeout } from './notifications.js'; import type { CascadeJob } from './queue.js'; @@ -78,6 +79,11 @@ async function buildWorkerEnv(job: Job): Promise { projectId, error: String(err), }); + captureException(err, { + tags: { source: 'credential_resolution' }, + extra: { projectId }, + level: 'warning', + }); } } @@ -85,6 +91,12 @@ async function buildWorkerEnv(job: Job): Promise { if (process.env.CLAUDE_CODE_OAUTH_TOKEN) env.push(`CLAUDE_CODE_OAUTH_TOKEN=${process.env.CLAUDE_CODE_OAUTH_TOKEN}`); + // Forward Sentry env vars so worker containers report to the same project. + if (process.env.SENTRY_DSN) env.push(`SENTRY_DSN=${process.env.SENTRY_DSN}`); + if (process.env.SENTRY_ENVIRONMENT) + env.push(`SENTRY_ENVIRONMENT=${process.env.SENTRY_ENVIRONMENT}`); + if (process.env.SENTRY_RELEASE) env.push(`SENTRY_RELEASE=${process.env.SENTRY_RELEASE}`); + return env; } @@ -131,6 +143,11 @@ async function spawnWorker(job: Job): Promise { jobId, durationMs, }); + captureException(new Error(`Worker timeout after ${durationMs}ms`), { + tags: { source: 'worker_timeout', jobType: job.data.type }, + extra: { jobId, durationMs }, + level: 'warning', + }); killWorker(jobId).catch((err) => { console.error('[WorkerManager] Failed to kill timed-out worker:', err); }); @@ -173,6 +190,12 @@ async function spawnWorker(job: Job): Promise { // Container may already be removed — expected with AutoRemove } + if (result.StatusCode !== 0) { + captureException(new Error(`Worker exited with status ${result.StatusCode}`), { + tags: { source: 'worker_exit', jobType: job.data.type }, + extra: { jobId, statusCode: result.StatusCode }, + }); + } console.log('[WorkerManager] Worker exited:', { jobId, statusCode: result.StatusCode, @@ -181,6 +204,10 @@ async function spawnWorker(job: Job): Promise { }) .catch((err) => { console.error('[WorkerManager] Error waiting for container:', err); + captureException(err, { + tags: { source: 'worker_wait', jobType: job.data.type }, + extra: { jobId }, + }); cleanupWorker(jobId); }); } catch (err) { @@ -188,6 +215,10 @@ async function spawnWorker(job: Job): Promise { jobId, error: String(err), }); + captureException(err, { + tags: { source: 'worker_spawn', jobType: job.data.type }, + extra: { jobId }, + }); throw err; } } @@ -299,10 +330,17 @@ export function startWorkerProcessor(): void { jobId: job?.id, error: String(err), }); + captureException(err, { + tags: { source: 'bullmq_dispatch', queue: 'cascade-jobs' }, + extra: { jobId: job?.id }, + }); }); bullWorker.on('error', (err) => { console.error('[WorkerManager] Worker error:', err); + captureException(err, { + tags: { source: 'bullmq_error', queue: 'cascade-jobs' }, + }); }); // Dashboard jobs queue — manual runs, retries, debug analyses submitted @@ -333,10 +371,17 @@ export function startWorkerProcessor(): void { jobId: job?.id, error: String(err), }); + captureException(err, { + tags: { source: 'bullmq_dispatch', queue: 'cascade-dashboard-jobs' }, + extra: { jobId: job?.id }, + }); }); dashboardWorker.on('error', (err) => { console.error('[WorkerManager] Dashboard worker error:', err); + captureException(err, { + tags: { source: 'bullmq_error', queue: 'cascade-dashboard-jobs' }, + }); }); console.log('[WorkerManager] Started with max', routerConfig.maxWorkers, 'concurrent workers'); diff --git a/src/sentry.ts b/src/sentry.ts new file mode 100644 index 00000000..48b14850 --- /dev/null +++ b/src/sentry.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; + +export const sentryEnabled = !!process.env.SENTRY_DSN; + +export function captureException( + error: unknown, + context?: { + tags?: Record; + extra?: Record; + level?: Sentry.SeverityLevel; + }, +): void { + if (!sentryEnabled) return; + + Sentry.withScope((scope) => { + if (context?.tags) { + for (const [key, value] of Object.entries(context.tags)) { + scope.setTag(key, value); + } + } + if (context?.extra) { + for (const [key, value] of Object.entries(context.extra)) { + scope.setExtra(key, value); + } + } + if (context?.level) { + scope.setLevel(context.level); + } + Sentry.captureException(error); + }); +} + +export function addBreadcrumb(breadcrumb: Sentry.Breadcrumb): void { + if (!sentryEnabled) return; + Sentry.addBreadcrumb(breadcrumb); +} + +export function setTag(key: string, value: string): void { + if (!sentryEnabled) return; + Sentry.setTag(key, value); +} + +export async function flush(timeoutMs = 2000): Promise { + if (!sentryEnabled) return; + await Sentry.flush(timeoutMs); +} diff --git a/src/server.ts b/src/server.ts index 4d586382..ef743bb6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import { logoutHandler } from './api/auth/logout.js'; import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; +import { captureException } from './sentry.js'; import { buildGitHubReactionSender, buildJiraReactionSender, @@ -153,6 +154,10 @@ export function createServer(deps: ServerDependencies): Hono { // Error handler app.onError((err, c) => { logger.error('Unhandled error', { error: String(err), path: c.req.path }); + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); return c.json({ error: 'Internal Server Error' }, 500); }); diff --git a/src/server/webhookHandlers.ts b/src/server/webhookHandlers.ts index 90ed93f6..d855f623 100644 --- a/src/server/webhookHandlers.ts +++ b/src/server/webhookHandlers.ts @@ -23,6 +23,7 @@ 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 { captureException } from '../sentry.js'; import type { CascadeConfig } from '../types/index.js'; import { canAcceptWebhook, isCurrentlyProcessing, logger } from '../utils/index.js'; import { logWebhookCall } from '../utils/webhookLogger.js'; @@ -142,6 +143,9 @@ function handleProcessingError(source: WebhookHandlerConfig['source'], err: unkn error: String(err), stack: err instanceof Error ? err.stack : undefined, }); + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: `${source}_webhook` }, + }); } /** diff --git a/src/utils/lifecycle.ts b/src/utils/lifecycle.ts index 256e6b39..cfbc6a25 100644 --- a/src/utils/lifecycle.ts +++ b/src/utils/lifecycle.ts @@ -1,3 +1,4 @@ +import { flush } from '../sentry.js'; import { logger } from './logging.js'; let watchdogTimer: ReturnType | null = null; @@ -43,6 +44,7 @@ export function startWatchdog(timeoutMs: number): void { } } + await flush(3000); logger.error('Force exiting'); process.exit(1); }, timeoutMs); diff --git a/src/worker-entry.ts b/src/worker-entry.ts index ce709d07..17da73ff 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -16,7 +16,9 @@ import { loadEnvConfigSafe } from './config/env.js'; import { loadConfig } from './config/provider.js'; import { getDb } from './db/client.js'; +import { captureException, flush, setTag } from './sentry.js'; import { + type TriggerRegistry, createTriggerRegistry, processGitHubWebhook, processJiraWebhook, @@ -133,13 +135,76 @@ async function processDashboardJob(jobId: string, jobData: DashboardJobData): Pr } } +async function dispatchJob( + jobId: string, + jobData: JobData, + triggerRegistry: TriggerRegistry, +): Promise { + switch (jobData.type) { + case 'trello': + logger.info('[Worker] Processing Trello job', { + jobId, + cardId: jobData.cardId, + actionType: jobData.actionType, + ackCommentId: jobData.ackCommentId, + }); + await processTrelloWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + break; + case 'github': + logger.info('[Worker] Processing GitHub job', { + jobId, + eventType: jobData.eventType, + repoFullName: jobData.repoFullName, + ackCommentId: jobData.ackCommentId, + }); + await processGitHubWebhook( + jobData.payload, + jobData.eventType, + triggerRegistry, + jobData.ackCommentId, + jobData.ackMessage, + ); + break; + case 'jira': + logger.info('[Worker] Processing JIRA job', { + jobId, + issueKey: jobData.issueKey, + webhookEvent: jobData.webhookEvent, + ackCommentId: jobData.ackCommentId, + }); + await processJiraWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + break; + case 'manual-run': + case 'retry-run': + case 'debug-analysis': + await processDashboardJob(jobId, jobData); + break; + default: { + const unknownType = (jobData as { type: string }).type; + logger.error('[Worker] Unknown job type', { jobType: unknownType }); + captureException(new Error(`Unknown job type: ${unknownType}`), { + tags: { source: 'worker_unknown_job' }, + }); + await flush(); + process.exit(1); + } + } +} + async function main(): Promise { const jobId = process.env.JOB_ID; const jobType = process.env.JOB_TYPE; const jobDataRaw = process.env.JOB_DATA; + setTag('role', 'worker'); + if (jobId) setTag('jobId', jobId); + if (jobType) setTag('jobType', jobType); + if (!jobId || !jobType || !jobDataRaw) { - console.error('[Worker] Missing required environment variables: JOB_ID, JOB_TYPE, JOB_DATA'); + const err = new Error('Missing required environment variables: JOB_ID, JOB_TYPE, JOB_DATA'); + console.error(`[Worker] ${err.message}`); + captureException(err, { tags: { source: 'worker_env' } }); + await flush(); process.exit(1); } @@ -148,9 +213,15 @@ async function main(): Promise { jobData = JSON.parse(jobDataRaw); } catch (err) { console.error('[Worker] Failed to parse JOB_DATA:', err); + captureException(err, { tags: { source: 'worker_job_parse' } }); + await flush(); process.exit(1); } + // Set Sentry tags from parsed job data + if ('projectId' in jobData && jobData.projectId) setTag('projectId', jobData.projectId); + if ('agentType' in jobData && jobData.agentType) setTag('agentType', jobData.agentType); + // Load environment config const envConfig = loadEnvConfigSafe(); setLogLevel(envConfig.logLevel); @@ -182,56 +253,21 @@ async function main(): Promise { registerBuiltInTriggers(triggerRegistry); try { - if (jobData.type === 'trello') { - logger.info('[Worker] Processing Trello job', { - jobId, - cardId: jobData.cardId, - actionType: jobData.actionType, - ackCommentId: jobData.ackCommentId, - }); - await processTrelloWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); - } else if (jobData.type === 'github') { - logger.info('[Worker] Processing GitHub job', { - jobId, - eventType: jobData.eventType, - repoFullName: jobData.repoFullName, - ackCommentId: jobData.ackCommentId, - }); - await processGitHubWebhook( - jobData.payload, - jobData.eventType, - triggerRegistry, - jobData.ackCommentId, - jobData.ackMessage, - ); - } else if (jobData.type === 'jira') { - logger.info('[Worker] Processing JIRA job', { - jobId, - issueKey: jobData.issueKey, - webhookEvent: jobData.webhookEvent, - ackCommentId: jobData.ackCommentId, - }); - await processJiraWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); - } else if ( - jobData.type === 'manual-run' || - jobData.type === 'retry-run' || - jobData.type === 'debug-analysis' - ) { - await processDashboardJob(jobId, jobData); - } else { - logger.error('[Worker] Unknown job type', { jobType: (jobData as { type: string }).type }); - process.exit(1); - } - + await dispatchJob(jobId, jobData, triggerRegistry); logger.info('[Worker] Job completed successfully', { jobId }); + await flush(); process.exit(0); } catch (err) { logger.error('[Worker] Job failed', { jobId, error: String(err) }); + captureException(err, { tags: { source: 'worker_job_failure' } }); + await flush(); process.exit(1); } } -main().catch((err) => { +main().catch(async (err) => { console.error('[Worker] Unhandled error:', err); + captureException(err, { tags: { source: 'worker_unhandled' }, level: 'fatal' }); + await flush(); process.exit(1); }); diff --git a/tests/unit/instrument.test.ts b/tests/unit/instrument.test.ts new file mode 100644 index 00000000..039b39ad --- /dev/null +++ b/tests/unit/instrument.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn(), +})); + +import * as Sentry from '@sentry/node'; + +const mockInit = vi.mocked(Sentry.init); + +describe('instrument (Sentry init)', () => { + beforeEach(() => { + vi.resetModules(); + mockInit.mockClear(); + }); + + afterEach(() => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_ENVIRONMENT; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_RELEASE; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_TRACES_SAMPLE_RATE; + }); + + it('does NOT call Sentry.init when SENTRY_DSN is unset', async () => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + await import('../../src/instrument.js'); + expect(mockInit).not.toHaveBeenCalled(); + }); + + it('calls Sentry.init with DSN when SENTRY_DSN is set', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://fake@sentry.io/123' }), + ); + }); + + it('passes environment from SENTRY_ENVIRONMENT', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_ENVIRONMENT = 'staging'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ environment: 'staging' })); + }); + + it('falls back to NODE_ENV for environment', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_ENVIRONMENT; + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ environment: 'test' })); + process.env.NODE_ENV = originalNodeEnv; + }); + + it('passes release from SENTRY_RELEASE', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_RELEASE = 'abc123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ release: 'abc123' })); + }); + + it('defaults tracesSampleRate to 0.1', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_TRACES_SAMPLE_RATE; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0.1 })); + }); + + it('respects SENTRY_TRACES_SAMPLE_RATE=0 (does not fall back to 0.1)', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_TRACES_SAMPLE_RATE = '0'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0 })); + }); + + it('parses SENTRY_TRACES_SAMPLE_RATE=1 correctly', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_TRACES_SAMPLE_RATE = '1'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 1 })); + }); + + it('disables PII collection', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ sendDefaultPii: false })); + }); +}); diff --git a/tests/unit/sentry.test.ts b/tests/unit/sentry.test.ts new file mode 100644 index 00000000..57e8a250 --- /dev/null +++ b/tests/unit/sentry.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock @sentry/node before any import (ESM exports are frozen, vi.spyOn won't work) +vi.mock('@sentry/node', () => ({ + init: vi.fn(), + withScope: vi.fn((cb: (scope: unknown) => void) => cb(mockScope)), + captureException: vi.fn(), + addBreadcrumb: vi.fn(), + setTag: vi.fn(), + flush: vi.fn().mockResolvedValue(true), +})); + +const mockScope = { + setTag: vi.fn(), + setExtra: vi.fn(), + setLevel: vi.fn(), +}; + +import * as Sentry from '@sentry/node'; + +describe('sentry wrappers', () => { + describe('when SENTRY_DSN is NOT set (disabled)', () => { + let sentry: typeof import('../../src/sentry.js'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + sentry = await import('../../src/sentry.js'); + }); + + it('sentryEnabled is false', () => { + expect(sentry.sentryEnabled).toBe(false); + }); + + it('captureException does not call Sentry', () => { + sentry.captureException(new Error('test')); + expect(Sentry.withScope).not.toHaveBeenCalled(); + }); + + it('addBreadcrumb does not call Sentry', () => { + sentry.addBreadcrumb({ message: 'test', category: 'test' }); + expect(Sentry.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('setTag does not call Sentry', () => { + sentry.setTag('key', 'value'); + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + + it('flush resolves without calling Sentry', async () => { + await sentry.flush(); + expect(Sentry.flush).not.toHaveBeenCalled(); + }); + }); + + describe('when SENTRY_DSN IS set (enabled)', () => { + let sentry: typeof import('../../src/sentry.js'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + for (const k of Object.keys(mockScope)) mockScope[k as keyof typeof mockScope].mockClear(); + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + sentry = await import('../../src/sentry.js'); + }); + + afterEach(() => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + }); + + it('sentryEnabled is true', () => { + expect(sentry.sentryEnabled).toBe(true); + }); + + it('captureException delegates to Sentry.withScope', () => { + sentry.captureException(new Error('test')); + expect(Sentry.withScope).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + + it('captureException sets tags, extra, and level on scope', () => { + const error = new Error('test'); + sentry.captureException(error, { + tags: { source: 'test_source', role: 'worker' }, + extra: { jobId: '123' }, + level: 'fatal', + }); + + expect(mockScope.setTag).toHaveBeenCalledWith('source', 'test_source'); + expect(mockScope.setTag).toHaveBeenCalledWith('role', 'worker'); + expect(mockScope.setExtra).toHaveBeenCalledWith('jobId', '123'); + expect(mockScope.setLevel).toHaveBeenCalledWith('fatal'); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + + it('captureException works with no context', () => { + const error = new Error('bare error'); + sentry.captureException(error); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + expect(mockScope.setTag).not.toHaveBeenCalled(); + expect(mockScope.setExtra).not.toHaveBeenCalled(); + expect(mockScope.setLevel).not.toHaveBeenCalled(); + }); + + it('addBreadcrumb delegates to Sentry', () => { + const breadcrumb = { message: 'test', category: 'http' }; + sentry.addBreadcrumb(breadcrumb); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb); + }); + + it('setTag delegates to Sentry', () => { + sentry.setTag('role', 'router'); + expect(Sentry.setTag).toHaveBeenCalledWith('role', 'router'); + }); + + it('flush delegates to Sentry.flush with timeout', async () => { + await sentry.flush(5000); + expect(Sentry.flush).toHaveBeenCalledWith(5000); + }); + + it('flush uses default 2000ms timeout', async () => { + await sentry.flush(); + expect(Sentry.flush).toHaveBeenCalledWith(2000); + }); + }); +}); diff --git a/tests/unit/server/webhookHandlers.test.ts b/tests/unit/server/webhookHandlers.test.ts index 54138fb0..7a117fb3 100644 --- a/tests/unit/server/webhookHandlers.test.ts +++ b/tests/unit/server/webhookHandlers.test.ts @@ -29,9 +29,14 @@ vi.mock('../../../src/utils/webhookLogger.js', () => ({ logWebhookCall: vi.fn(), })); +vi.mock('../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + import { findProjectByRepo } from '../../../src/config/provider.js'; import { resolvePersonaIdentities } from '../../../src/github/personas.js'; import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import { captureException } from '../../../src/sentry.js'; import { buildGitHubReactionSender, buildJiraReactionSender, @@ -44,6 +49,7 @@ import { import { canAcceptWebhook, isCurrentlyProcessing } from '../../../src/utils/index.js'; import { logWebhookCall } from '../../../src/utils/webhookLogger.js'; +const mockCaptureException = vi.mocked(captureException); const mockLogWebhookCall = vi.mocked(logWebhookCall); const mockIsCurrentlyProcessing = vi.mocked(isCurrentlyProcessing); const mockCanAcceptWebhook = vi.mocked(canAcceptWebhook); @@ -311,6 +317,31 @@ describe('createWebhookHandler', () => { ); }); + it('captures processWebhook errors to Sentry in fire-and-forget mode', async () => { + vi.useFakeTimers(); + const processError = new Error('redis connection failed'); + const handler = createWebhookHandler({ + source: 'trello', + fireAndForget: true, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'commentCard' }), + processWebhook: vi.fn().mockRejectedValue(processError), + }); + + const app = buildApp(handler); + const res = await postJson(app, {}); + // Fire-and-forget always returns 200 + expect(res.status).toBe(200); + + // Let setImmediate fire and the rejection be caught + await vi.runAllTimersAsync(); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'redis connection failed' }), + expect.objectContaining({ tags: { source: 'trello_webhook' } }), + ); + vi.useRealTimers(); + }); + it('lets processWebhook errors propagate when fireAndForget=false', async () => { const handler = createWebhookHandler({ source: 'jira', diff --git a/tests/unit/utils/lifecycle.test.ts b/tests/unit/utils/lifecycle.test.ts index da3f7d7c..37e50675 100644 --- a/tests/unit/utils/lifecycle.test.ts +++ b/tests/unit/utils/lifecycle.test.ts @@ -8,6 +8,11 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); +vi.mock('../../../src/sentry.js', () => ({ + flush: vi.fn().mockResolvedValue(undefined), +})); + +import { flush } from '../../../src/sentry.js'; import { clearWatchdog, clearWatchdogCleanup, @@ -17,6 +22,8 @@ import { startWatchdog, } from '../../../src/utils/lifecycle.js'; +const mockFlush = vi.mocked(flush); + describe('lifecycle', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,11 +58,20 @@ describe('lifecycle', () => { }); describe('watchdog', () => { - it('force exits after timeout', () => { + it('force exits after timeout', async () => { + startWatchdog(30000); + + await vi.advanceTimersByTimeAsync(30000); + + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('flushes Sentry before force exit', async () => { startWatchdog(30000); - vi.advanceTimersByTime(30000); + await vi.advanceTimersByTimeAsync(30000); + expect(mockFlush).toHaveBeenCalledWith(3000); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -68,14 +84,14 @@ describe('lifecycle', () => { expect(process.exit).not.toHaveBeenCalled(); }); - it('clears previous watchdog when starting new one', () => { + it('clears previous watchdog when starting new one', async () => { startWatchdog(5000); startWatchdog(10000); - vi.advanceTimersByTime(5000); + await vi.advanceTimersByTimeAsync(5000); expect(process.exit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(5000); + await vi.advanceTimersByTimeAsync(5000); expect(process.exit).toHaveBeenCalledWith(1); }); }); From 47a562ce2ca1de2319befe20e76e09781c83d46e Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 23 Feb 2026 14:19:14 +0100 Subject: [PATCH 13/14] fix(dashboard): fix auth routing - reactive state, beforeLoad guard, post-login nav (#503) Co-authored-by: Cascade Bot --- web/src/routes/__root.tsx | 41 ++++++++++++++++++++++++--------------- web/src/routes/login.tsx | 4 ++-- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 930c107d..b9f2b1b7 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,22 +1,15 @@ import { Header } from '@/components/layout/header.js'; import { Sidebar } from '@/components/layout/sidebar.js'; import { OrgProvider } from '@/lib/org-context.js'; +import { queryClient } from '@/lib/query-client.js'; import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; -import { Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'; -import { useEffect } from 'react'; +import { Outlet, createRootRoute, redirect, useRouterState } from '@tanstack/react-router'; function RootLayout() { - const navigate = useNavigate(); - const meQuery = useQuery(trpc.auth.me.queryOptions()); - - const isLoginPage = window.location.pathname === '/login'; - - useEffect(() => { - if (meQuery.isError && !isLoginPage) { - navigate({ to: '/login' }); - } - }, [meQuery.isError, isLoginPage, navigate]); + const routerState = useRouterState(); + const isLoginPage = routerState.location.pathname === '/login'; + const meQuery = useQuery({ ...trpc.auth.me.queryOptions(), retry: false }); if (isLoginPage) { return ; @@ -30,10 +23,6 @@ function RootLayout() { ); } - if (meQuery.isError) { - return null; - } - return (
@@ -49,6 +38,26 @@ function RootLayout() { ); } +function PendingComponent() { + return ( +
+
Loading...
+
+ ); +} + export const rootRoute = createRootRoute({ component: RootLayout, + pendingComponent: PendingComponent, + beforeLoad: async ({ location }) => { + if (location.pathname === '/login') return; + try { + await queryClient.ensureQueryData({ + ...trpc.auth.me.queryOptions(), + retry: false, + }); + } catch { + throw redirect({ to: '/login' }); + } + }, }); diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx index 261ff7cd..45ee9d5b 100644 --- a/web/src/routes/login.tsx +++ b/web/src/routes/login.tsx @@ -32,8 +32,8 @@ function LoginPage() { return; } - await queryClient.refetchQueries({ queryKey: trpc.auth.me.queryOptions().queryKey }); - navigate({ to: '/' }); + await queryClient.invalidateQueries({ queryKey: trpc.auth.me.queryOptions().queryKey }); + navigate({ to: '/', replace: true }); } catch { setError('Network error'); } finally { From 0763849aaa23aea7da14d2d80766965f02732c7f Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Feb 2026 15:41:31 +0100 Subject: [PATCH 14/14] refactor: code review cleanups for trigger system and router (#504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 35+ raw console.log/warn/error calls with structured logger across router handlers (github, trello, jira, index, queue) - Create shared JiraWebhookPayload type and STATUS_TO_AGENT constant in src/triggers/jira/types.ts to eliminate duplication across 3 files - Fix Trello card-moved and label-added triggers to return null instead of throwing on missing card ID (consistency with other handlers) - Fix fragile isAgentLogFilename regex that couldn't handle multi-hyphen agent names like respond-to-review - Extract shared withPMCredentials helper to src/pm/context.ts, eliminating duplication between router and worker - Remove dead fallback job queueing for unresolvable GitHub projects - Rename queueJiraJob → processJiraWebhookEvent for naming consistency - Add Sentry captureException for persistent ack reaction failures - Add ackCommentId type-clarifying comments to job interfaces - Refactor processGitHubWebhook to fix cognitive complexity lint warning (extract tryEnqueueIfBusy and resolveTriggerResult helpers) - Add test coverage for unknown agent types, empty trigger config objects, multi-hyphen filenames, and missing card ID edge cases Co-authored-by: Claude Opus 4.6 --- src/pm/context.ts | 20 ++ src/pm/webhook-handler.ts | 56 ++++- src/router/github.ts | 189 +++++++++------ src/router/index.ts | 9 +- src/router/jira.ts | 114 +++++---- src/router/queue.ts | 14 +- src/router/trello.ts | 115 +++++---- src/triggers/github/check-suite-failure.ts | 4 - src/triggers/github/check-suite-success.ts | 31 +-- src/triggers/github/pr-comment-mention.ts | 4 - src/triggers/github/pr-merged.ts | 4 - src/triggers/github/pr-opened.ts | 4 - src/triggers/github/pr-ready-to-merge.ts | 4 - src/triggers/github/pr-review-submitted.ts | 4 - src/triggers/github/review-requested.ts | 4 - src/triggers/github/webhook-handler.ts | 152 ++++++++---- src/triggers/jira/comment-mention.ts | 22 +- src/triggers/jira/issue-transitioned.ts | 52 +---- src/triggers/jira/label-added.ts | 51 +--- src/triggers/jira/types.ts | 51 ++++ src/triggers/jira/webhook-handler.ts | 4 +- src/triggers/registry.ts | 14 -- src/triggers/trello/card-moved.ts | 10 +- src/triggers/trello/comment-mention.ts | 4 - src/triggers/trello/label-added.ts | 8 +- src/triggers/trello/webhook-handler.ts | 4 +- src/types/index.ts | 9 +- src/worker-entry.ts | 22 +- tests/unit/config/triggerConfig.test.ts | 30 +++ tests/unit/router/github.test.ts | 182 ++++++++------- tests/unit/router/jira.test.ts | 85 +++++-- tests/unit/router/trello.test.ts | 65 +++++- tests/unit/triggers/card-moved.test.ts | 55 +++-- .../unit/triggers/check-suite-failure.test.ts | 11 - .../unit/triggers/check-suite-success.test.ts | 219 +----------------- .../github-pr-comment-mention.test.ts | 7 - .../triggers/jira-comment-mention.test.ts | 7 - .../triggers/jira-issue-transitioned.test.ts | 49 ---- tests/unit/triggers/jira-label-added.test.ts | 28 --- tests/unit/triggers/label-added.test.ts | 29 +-- tests/unit/triggers/pr-merged.test.ts | 11 - tests/unit/triggers/pr-opened.test.ts | 11 - tests/unit/triggers/pr-ready-to-merge.test.ts | 11 - .../unit/triggers/pr-review-submitted.test.ts | 11 - tests/unit/triggers/registry.test.ts | 167 +++---------- tests/unit/triggers/review-requested.test.ts | 11 - .../triggers/trello-comment-mention.test.ts | 7 - 47 files changed, 894 insertions(+), 1081 deletions(-) create mode 100644 src/triggers/jira/types.ts diff --git a/src/pm/context.ts b/src/pm/context.ts index ba08502c..02d02504 100644 --- a/src/pm/context.ts +++ b/src/pm/context.ts @@ -7,6 +7,7 @@ */ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { PMIntegration } from './integration.js'; import type { PMProvider } from './types.js'; const pmProviderStore = new AsyncLocalStorage(); @@ -28,3 +29,22 @@ export function getPMProvider(): PMProvider { export function getPMProviderOrNull(): PMProvider | null { return pmProviderStore.getStore() ?? null; } + +/** + * Establish PM credential scope for a project. + * + * Uses the integration's withCredentials() for the correct PM type. + * Falls through to running fn() directly if no PM type is configured + * or the integration is unknown. + */ +export async function withPMCredentials( + projectId: string, + pmType: string | undefined, + getIntegration: (type: string) => PMIntegration | null, + fn: () => Promise, +): Promise { + if (!pmType) return fn(); + const integration = getIntegration(pmType); + if (!integration) return fn(); + return integration.withCredentials(projectId, fn); +} diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index ce29950b..6426c03d 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -88,14 +88,20 @@ async function cleanupOrphanAck( } } -async function handleMatchedTrigger( +async function resolveTriggerResult( integration: PMIntegration, registry: TriggerRegistry, payload: unknown, project: ProjectConfig, - config: CascadeConfig, - ackCommentId?: string, -): Promise { + ackCommentId: string | undefined, + preResolvedResult: TriggerResult | undefined, +): Promise { + if (preResolvedResult) { + logger.info(`Using pre-resolved trigger result for ${integration.type} webhook`, { + agentType: preResolvedResult.agentType, + }); + return preResolvedResult; + } const ctx: TriggerContext = { project, source: integration.type as TriggerSource, payload }; const result = await registry.dispatch(ctx); if (!result) { @@ -103,8 +109,28 @@ async function handleMatchedTrigger( if (ackCommentId) { await cleanupOrphanAck(integration, project.id, payload, ackCommentId); } - return; } + return result; +} + +async function handleMatchedTrigger( + integration: PMIntegration, + registry: TriggerRegistry, + payload: unknown, + project: ProjectConfig, + config: CascadeConfig, + ackCommentId?: string, + preResolvedResult?: TriggerResult, +): Promise { + const result = await resolveTriggerResult( + integration, + registry, + payload, + project, + ackCommentId, + preResolvedResult, + ); + if (!result) return; // Pass ack comment ID into agent input for ProgressMonitor pre-seeding if (ackCommentId) { @@ -152,7 +178,8 @@ async function handleMatchedTrigger( * * Validates the payload via the integration's `parseWebhookPayload()`, * looks up the project, establishes credential + PM provider scope, - * dispatches to the trigger registry, and runs the matched agent. + * dispatches to the trigger registry (or uses pre-resolved result), + * and runs the matched agent. * * Used by both Trello and JIRA webhook handlers. */ @@ -161,8 +188,11 @@ export async function processPMWebhook( payload: unknown, registry: TriggerRegistry, ackCommentId?: string, + triggerResult?: TriggerResult, ): Promise { - logger.info(`Processing ${integration.type} webhook`); + logger.info(`Processing ${integration.type} webhook`, { + hasTriggerResult: !!triggerResult, + }); const event = integration.parseWebhookPayload(payload); if (!event) { @@ -201,11 +231,19 @@ export async function processPMWebhook( } const { project, config } = projectConfig; - // Establish credential + PM provider scope for trigger dispatch + // Establish credential + PM provider scope for agent execution const pmProvider = pmRegistry.createProvider(project); await integration.withCredentials(project.id, () => withPMProvider(pmProvider, () => - handleMatchedTrigger(integration, registry, payload, project, config, ackCommentId), + handleMatchedTrigger( + integration, + registry, + payload, + project, + config, + ackCommentId, + triggerResult, + ), ), ); } diff --git a/src/router/github.ts b/src/router/github.ts index 5adb4e62..c7c31ff4 100644 --- a/src/router/github.ts +++ b/src/router/github.ts @@ -1,18 +1,24 @@ /** * 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. + * Runs full trigger dispatch() to determine if a job should be queued. + * Only posts ack comments and queues jobs when dispatch confirms a match. */ +import { getProjectGitHubToken } from '../config/projects.js'; import { findProjectByRepo } from '../config/provider.js'; +import { withGitHubToken } from '../github/client.js'; import { type PersonaIdentities, isCascadeBot, resolvePersonaIdentities, } from '../github/personas.js'; +import { withPMCredentials, withPMProvider } from '../pm/context.js'; +import { pmRegistry } from '../pm/registry.js'; +import { captureException } from '../sentry.js'; import type { TriggerRegistry } from '../triggers/registry.js'; -import type { TriggerContext } from '../types/index.js'; +import type { TriggerContext, TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; import { extractGitHubContext, generateAckMessage } from './ackMessageGenerator.js'; import { postGitHubAck, resolveGitHubTokenForAck } from './acknowledgments.js'; import { loadProjectConfig } from './config.js'; @@ -21,54 +27,18 @@ import { addEyesReactionToPR } from './pre-actions.js'; import { type CascadeJob, type GitHubJob, addJob } from './queue.js'; import { sendAcknowledgeReaction } from './reactions.js'; +// Ensure PM integrations are registered (idempotent — uses the same singleton registry +// that pm/index.ts populates, but we import from sub-modules to avoid pulling in +// the webhook handler's agent-execution transitive deps). +import { JiraIntegration } from '../pm/jira/integration.js'; +import { TrelloIntegration } from '../pm/trello/integration.js'; +if (!pmRegistry.getOrNull('trello')) pmRegistry.register(new TrelloIntegration()); +if (!pmRegistry.getOrNull('jira')) pmRegistry.register(new JiraIntegration()); + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- -/** - * Try to match a trigger and post an ack comment for a GitHub webhook. - * Returns the ack comment ID and message text if posted, undefined otherwise. - */ -export async function tryPostGitHubAck( - eventType: string, - repoFullName: string, - payload: unknown, - triggerRegistry: TriggerRegistry, -): Promise<{ commentId: number; message: string } | undefined> { - 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 context = extractGitHubContext(payload, eventType); - const message = await generateAckMessage(match.agentType, context, fullProject.id); - - 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 != null ? { commentId, message } : undefined; -} - export async function isSelfAuthoredGitHubComment( payload: unknown, repoFullName: string, @@ -94,7 +64,7 @@ export function fireGitHubAckReaction(repoFullName: string, payload: unknown): v try { const project = await findProjectByRepo(repoFullName); if (!project) { - console.warn('[Router] No project found for repo, skipping GitHub reaction', { + logger.warn('No project found for repo, skipping GitHub reaction', { repoFullName, }); return; @@ -102,7 +72,11 @@ export function fireGitHubAckReaction(repoFullName: string, payload: unknown): v const personaIdentities = await resolvePersonaIdentities(project.id); await sendAcknowledgeReaction('github', repoFullName, payload, personaIdentities, project); } catch (err) { - console.warn('[Router] GitHub reaction error:', String(err)); + logger.warn('GitHub reaction error', { error: String(err), repoFullName }); + captureException(err, { + tags: { source: 'github_ack_reaction' }, + extra: { repoFullName }, + }); } })(); } @@ -119,11 +93,38 @@ export function firePreActions(job: GitHubJob, p: Record): void 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)), + logger.warn('Pre-action error (eyes reaction)', { error: String(err) }), ); } } +async function tryPostAck( + agentType: string, + payload: unknown, + eventType: string, + repoFullName: string, + projectId: string, +): Promise<{ ackCommentId?: number; ackMessage?: string }> { + try { + const context = extractGitHubContext(payload, eventType); + const message = await generateAckMessage(agentType, context, projectId); + const resolved = await resolveGitHubTokenForAck(repoFullName); + if (resolved) { + const tempJob = { eventType, repoFullName, payload } as GitHubJob; + const prNumber = extractPRNumber(tempJob); + if (prNumber) { + const commentId = await postGitHubAck(repoFullName, prNumber, message, resolved.token); + if (commentId != null) { + return { ackCommentId: commentId, ackMessage: message }; + } + } + } + } catch (err) { + logger.warn('GitHub ack comment failed (non-fatal)', { error: String(err) }); + } + return {}; +} + export async function processGitHubWebhookEvent( eventType: string, repoFullName: string, @@ -134,30 +135,84 @@ export async function processGitHubWebhookEvent( eventType === 'issue_comment' || eventType === 'pull_request_review_comment'; if (isCommentEvent && (await isSelfAuthoredGitHubComment(payload, repoFullName))) { - console.log('[Router] Ignoring self-authored GitHub comment'); + logger.info('Ignoring self-authored GitHub comment', { repoFullName }); 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; - let ackMessage: string | undefined; + // Resolve project and credentials for authoritative dispatch + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.repo === repoFullName); + if (!fullProject) { + logger.info('No project for GitHub repo, skipping dispatch', { repoFullName }); + return; + } + + let personaIdentities: PersonaIdentities | undefined; try { - const ackResult = await tryPostGitHubAck(eventType, repoFullName, payload, triggerRegistry); - if (ackResult) { - ackCommentId = ackResult.commentId; - ackMessage = ackResult.message; - } + personaIdentities = await resolvePersonaIdentities(fullProject.id); + } catch { + // Persona resolution may fail — proceed without + } + + // Run authoritative trigger dispatch with all credential scopes + let result: TriggerResult | null = null; + try { + const githubToken = await getProjectGitHubToken(fullProject); + const pmProvider = pmRegistry.createProvider(fullProject); + + const ctx: TriggerContext = { + project: fullProject, + source: 'github', + payload, + personaIdentities, + }; + + result = await withPMCredentials( + fullProject.id, + fullProject.pm?.type, + (t) => pmRegistry.getOrNull(t), + () => + withPMProvider(pmProvider, () => + withGitHubToken(githubToken, () => triggerRegistry.dispatch(ctx)), + ), + ); } catch (err) { - console.warn('[Router] GitHub ack comment failed (non-fatal):', String(err)); + logger.warn('GitHub trigger dispatch failed (non-fatal)', { error: String(err), repoFullName }); + } + + if (!result) { + logger.info('No trigger matched for GitHub event', { eventType, repoFullName }); + return; } + logger.info('GitHub trigger matched', { + agentType: result.agentType || '(no agent)', + prNumber: result.prNumber, + repoFullName, + }); + + // For triggers with no agent (pr-merged, pr-ready-to-merge), dispatch already + // performed the PM operations. No job queuing needed. + if (!result.agentType) { + logger.info('Trigger completed without agent (PM operation done)'); + return; + } + + // Post ack comment — we KNOW the trigger matched + const { ackCommentId, ackMessage } = await tryPostAck( + result.agentType, + payload, + eventType, + repoFullName, + fullProject.id, + ); + + // Queue job with confirmed trigger result const job: CascadeJob = { type: 'github', source: 'github', @@ -167,6 +222,7 @@ export async function processGitHubWebhookEvent( receivedAt: new Date().toISOString(), ackCommentId, ackMessage, + triggerResult: result, }; // Fire pre-actions (non-blocking) before queueing @@ -175,9 +231,9 @@ export async function processGitHubWebhookEvent( try { const jobId = await addJob(job); - console.log('[Router] GitHub job queued:', { jobId, eventType, ackCommentId }); + logger.info('GitHub job queued', { jobId, eventType, ackCommentId }); } catch (err) { - console.error('[Router] Failed to queue GitHub job:', err); + logger.error('Failed to queue GitHub job', { error: String(err), eventType, repoFullName }); } } @@ -195,7 +251,8 @@ const PROCESSABLE_EVENTS = [ /** * Handle a POST /github/webhook request. - * Parses the payload, filters irrelevant events, and queues a job. + * Parses the payload, filters irrelevant events, dispatches triggers, + * and queues a job only when a trigger confirms a match. */ export async function handleGitHubWebhook( eventType: string, @@ -211,7 +268,7 @@ export async function handleGitHubWebhook( if (shouldProcess) { await processGitHubWebhookEvent(eventType, repoFullName, payload, triggerRegistry); } else { - console.log('[Router] Ignoring GitHub event:', eventType); + logger.debug('Ignoring GitHub event', { eventType }); } return { shouldProcess, repoFullName }; diff --git a/src/router/index.ts b/src/router/index.ts index d24edd06..65b687ba 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -9,6 +9,7 @@ import { } from '../server/webhookHandlers.js'; import { registerBuiltInTriggers } from '../triggers/builtins.js'; import { createTriggerRegistry } from '../triggers/registry.js'; +import { logger } from '../utils/logging.js'; import { handleGitHubWebhook } from './github.js'; import { handleJiraWebhook } from './jira.js'; import { getQueueStats } from './queue.js'; @@ -22,7 +23,7 @@ import { setTag('role', 'router'); -// Create trigger registry once at router startup for matchTrigger() calls +// Create trigger registry once at router startup for dispatch() calls const triggerRegistry = createTriggerRegistry(); registerBuiltInTriggers(triggerRegistry); @@ -123,7 +124,7 @@ app.post( // Graceful shutdown async function shutdown(signal: string): Promise { - console.log(`[Router] Received ${signal}, shutting down...`); + logger.info('Received shutdown signal', { signal }); await stopWorkerProcessor(); await flush(3000); process.exit(0); @@ -147,12 +148,12 @@ process.on('unhandledRejection', (reason) => { async function startRouter(): Promise { const port = Number(process.env.PORT) || 3000; startWorkerProcessor(); - console.log(`[Router] Starting on port ${port}`); + logger.info('Starting router', { port }); serve({ fetch: app.fetch, port }); } startRouter().catch(async (err) => { - console.error('[Router] Failed to start:', err); + logger.error('Failed to start router', { error: String(err) }); captureException(err, { tags: { source: 'router_startup' }, level: 'fatal' }); await flush(3000); process.exit(1); diff --git a/src/router/jira.ts b/src/router/jira.ts index db596499..7413023c 100644 --- a/src/router/jira.ts +++ b/src/router/jira.ts @@ -1,15 +1,18 @@ /** * 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. + * Runs full trigger dispatch() to determine if a job should be queued. + * Only posts ack comments and queues jobs when dispatch confirms a match. */ +import { withJiraCredentials } from '../jira/client.js'; import type { TriggerRegistry } from '../triggers/registry.js'; -import type { ProjectConfig, TriggerContext } from '../types/index.js'; +import type { ProjectConfig, TriggerContext, TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; import { extractJiraContext, generateAckMessage } from './ackMessageGenerator.js'; import { postJiraAck, resolveJiraBotAccountId } from './acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from './config.js'; +import { resolveJiraCredentials } from './platformClients.js'; import { type CascadeJob, addJob } from './queue.js'; import { sendAcknowledgeReaction } from './reactions.js'; @@ -17,31 +20,6 @@ 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 context = extractJiraContext(payload); - const message = await generateAckMessage(match.agentType, context, projectId); - - const commentId = await postJiraAck(projectId, issueKey, message); - return commentId ?? undefined; -} - export async function isSelfAuthoredJiraComment( webhookEvent: string, payload: unknown, @@ -61,7 +39,10 @@ export async function isSelfAuthoredJiraComment( } } -export async function queueJiraJob( +/** + * Run authoritative dispatch and, if matched, post ack + queue job. + */ +export async function processJiraWebhookEvent( project: RouterProjectConfig, issueKey: string, webhookEvent: string, @@ -69,29 +50,64 @@ export async function queueJiraJob( 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), + logger.error('JIRA reaction error', { error: String(err) }), ); } - // Try to post an ack comment via trigger matching (non-blocking best-effort) - let ackCommentId: string | undefined; + // Run authoritative trigger dispatch with credentials in scope + const fullProject = fullProjects.find((fp) => fp.id === project.id); + if (!fullProject) { + logger.info('No full project config for JIRA webhook, skipping', { projectId: project.id }); + return; + } + + let result: TriggerResult | null = null; try { - ackCommentId = await tryPostJiraAck( - project.id, - issueKey, - payload, - fullProjects, - triggerRegistry, - ); + const jiraCreds = await resolveJiraCredentials(project.id); + if (!jiraCreds) { + logger.warn('Missing JIRA credentials, cannot dispatch triggers', { projectId: project.id }); + } else { + const ctx: TriggerContext = { project: fullProject, source: 'jira', payload }; + result = await withJiraCredentials( + { email: jiraCreds.email, apiToken: jiraCreds.apiToken, baseUrl: jiraCreds.baseUrl }, + () => triggerRegistry.dispatch(ctx), + ); + } } catch (err) { - console.warn('[Router] JIRA ack comment failed (non-fatal):', String(err)); + logger.warn('JIRA trigger dispatch failed (non-fatal)', { + error: String(err), + projectId: project.id, + }); + } + + if (!result) { + logger.info('No trigger matched for JIRA event', { webhookEvent, issueKey }); + return; + } + + logger.info('JIRA trigger matched', { + agentType: result.agentType, + issueKey, + projectId: project.id, + }); + + // Post ack comment — we KNOW the trigger matched + let ackCommentId: string | undefined; + if (result.agentType) { + try { + const context = extractJiraContext(payload); + const message = await generateAckMessage(result.agentType, context, project.id); + const commentId = await postJiraAck(project.id, issueKey, message); + ackCommentId = commentId ?? undefined; + } catch (err) { + logger.warn('JIRA ack comment failed (non-fatal)', { error: String(err), issueKey }); + } } + // Queue job with confirmed trigger result const job: CascadeJob = { type: 'jira', source: 'jira', @@ -101,13 +117,14 @@ export async function queueJiraJob( webhookEvent, receivedAt: new Date().toISOString(), ackCommentId, + triggerResult: result, }; try { const jobId = await addJob(job); - console.log('[Router] JIRA job queued:', { jobId, webhookEvent, ackCommentId }); + logger.info('JIRA job queued', { jobId, webhookEvent, ackCommentId }); } catch (err) { - console.error('[Router] Failed to queue JIRA job:', err); + logger.error('Failed to queue JIRA job', { error: String(err), webhookEvent, issueKey }); } } @@ -124,7 +141,8 @@ const PROCESSABLE_EVENTS = [ /** * Handle a POST /jira/webhook request. - * Parses the payload, filters irrelevant events, and queues a job. + * Parses the payload, filters irrelevant events, dispatches triggers, + * and queues a job only when a trigger confirms a match. */ export async function handleJiraWebhook( payload: unknown, @@ -148,9 +166,9 @@ export async function handleJiraWebhook( if (shouldProcess && project) { if (await isSelfAuthoredJiraComment(webhookEvent, payload, project.id)) { - console.log('[Router] Ignoring self-authored JIRA comment'); + logger.info('Ignoring self-authored JIRA comment', { webhookEvent }); } else { - await queueJiraJob( + await processJiraWebhookEvent( project, issueKey, webhookEvent, @@ -160,7 +178,7 @@ export async function handleJiraWebhook( ); } } else { - console.log(`[Router] Ignoring JIRA: ${webhookEvent}`); + logger.debug('Ignoring JIRA event', { webhookEvent }); } return { shouldProcess, project, webhookEvent }; diff --git a/src/router/queue.ts b/src/router/queue.ts index cb298542..92dbc92a 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -1,5 +1,7 @@ import { type ConnectionOptions, Queue } from 'bullmq'; import { captureException } from '../sentry.js'; +import type { TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; import { routerConfig } from './config.js'; // Parse Redis URL to connection options @@ -15,6 +17,9 @@ function parseRedisUrl(url: string): ConnectionOptions { const connection = parseRedisUrl(routerConfig.redisUrl); // Job types +// Note: ackCommentId is `string` for Trello/JIRA (string IDs from their APIs) +// and `number` for GitHub (numeric IDs from GitHub API). Downstream consumers +// (ProgressMonitor) normalize to string via the adapter layer. export interface TrelloJob { type: 'trello'; source: 'trello'; @@ -24,6 +29,7 @@ export interface TrelloJob { actionType: string; receivedAt: string; ackCommentId?: string; + triggerResult?: TriggerResult; } export interface GitHubJob { @@ -35,6 +41,7 @@ export interface GitHubJob { receivedAt: string; ackCommentId?: number; ackMessage?: string; + triggerResult?: TriggerResult; } export interface JiraJob { @@ -46,6 +53,7 @@ export interface JiraJob { webhookEvent: string; receivedAt: string; ackCommentId?: string; + triggerResult?: TriggerResult; } export type CascadeJob = TrelloJob | GitHubJob | JiraJob; @@ -67,17 +75,17 @@ export const jobQueue = new Queue('cascade-jobs', { // Queue event logging jobQueue.on('error', (err) => { - console.error('[Queue] Error:', err); + logger.error('Queue error', { error: String(err) }); captureException(err, { tags: { source: 'job_queue' } }); }); -console.log('[Queue] Initialized with Redis at', routerConfig.redisUrl); +logger.info('Queue initialized', { redisUrl: routerConfig.redisUrl }); // Helper to add a job export async function addJob(job: CascadeJob): Promise { const jobId = `${job.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const result = await jobQueue.add(job.type, job, { jobId }); - console.log('[Queue] Job added:', { id: result.id, type: job.type }); + logger.info('Job added to queue', { id: result.id, type: job.type }); return result.id ?? jobId; } diff --git a/src/router/trello.ts b/src/router/trello.ts index 7e8f0aa9..b23f2add 100644 --- a/src/router/trello.ts +++ b/src/router/trello.ts @@ -1,15 +1,18 @@ /** * 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. + * Runs full trigger dispatch() to determine if a job should be queued. + * Only posts ack comments and queues jobs when dispatch confirms a match. */ +import { withTrelloCredentials } from '../trello/client.js'; import type { TriggerRegistry } from '../triggers/registry.js'; -import type { TriggerContext } from '../types/index.js'; +import type { TriggerContext, TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; import { extractTrelloContext, generateAckMessage } from './ackMessageGenerator.js'; import { postTrelloAck, resolveTrelloBotMemberId } from './acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from './config.js'; +import { resolveTrelloCredentials } from './platformClients.js'; import { type CascadeJob, addJob } from './queue.js'; import { sendAcknowledgeReaction } from './reactions.js'; @@ -20,9 +23,10 @@ import { sendAcknowledgeReaction } from './reactions.js'; /** * 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 + * The timestamp follows ISO 8601 format with colons replaced by hyphens: YYYY-MM-DDTHH-MM-SS-mmmZ */ export function isAgentLogFilename(filename: string): boolean { - return /^[a-z]+(?:-timeout)?-[\d-TZ]+\.zip$/i.test(filename); + return /^.+-\d{4}-\d{2}-\d{2}T[\d-]+Z\.zip$/.test(filename); } export function isCardInTriggerList( @@ -42,7 +46,7 @@ export function isCardInTriggerList( 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}`); + logger.info('Card moved to trigger list', { listId }); return true; } } @@ -52,7 +56,7 @@ export function isCardInTriggerList( 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}`); + logger.info('Card created in trigger list', { listId }); return true; } } @@ -72,7 +76,7 @@ export function isReadyToProcessLabelAdded( const labelId = label.id as string; if (labelId === project.trello.labels.readyToProcess) { - console.log('[Router] Ready-to-process label added'); + logger.info('Ready-to-process label added', { labelId }); return true; } return false; @@ -90,7 +94,7 @@ export function isAgentLogAttachmentUploaded( const name = attachment.name as string | undefined; if (name && isAgentLogFilename(name) && !name.startsWith('debug-')) { - console.log(`[Router] Agent log attachment uploaded: ${name}`); + logger.info('Agent log attachment uploaded', { name }); return true; } return false; @@ -140,31 +144,6 @@ export async function parseTrelloWebhook(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 context = extractTrelloContext(payload); - const message = await generateAckMessage(match.agentType, context, projectId); - - const commentId = await postTrelloAck(projectId, cardId, message); - return commentId ?? undefined; -} - export async function isSelfAuthoredTrelloComment( payload: unknown, projectId: string, @@ -180,6 +159,9 @@ export async function isSelfAuthoredTrelloComment( } } +/** + * Run authoritative dispatch and, if matched, post ack + queue job. + */ export async function processTrelloWebhookEvent( project: RouterProjectConfig, cardId: string, @@ -188,27 +170,68 @@ export async function processTrelloWebhookEvent( triggerRegistry: TriggerRegistry, ): Promise { if (actionType === 'commentCard' && (await isSelfAuthoredTrelloComment(payload, project.id))) { - console.log('[Router] Ignoring self-authored Trello comment'); + logger.info('Ignoring self-authored Trello comment', { projectId: project.id }); 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), + logger.error('Trello reaction error', { error: String(err) }), ); } - // Try to post an ack comment via trigger matching (non-blocking best-effort) - let ackCommentId: string | undefined; + // Run authoritative trigger dispatch with credentials in scope + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (!fullProject) { + logger.info('No full project config for Trello webhook, skipping', { projectId: project.id }); + return; + } + + let result: TriggerResult | null = null; try { - ackCommentId = await tryPostTrelloAck(project.id, cardId, payload, triggerRegistry); + const trelloCreds = await resolveTrelloCredentials(project.id); + if (!trelloCreds) { + logger.warn('Missing Trello credentials, cannot dispatch triggers', { + projectId: project.id, + }); + } else { + const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; + result = await withTrelloCredentials(trelloCreds, () => triggerRegistry.dispatch(ctx)); + } } catch (err) { - console.warn('[Router] Trello ack comment failed (non-fatal):', String(err)); + logger.warn('Trello trigger dispatch failed (non-fatal)', { + error: String(err), + projectId: project.id, + }); + } + + if (!result) { + logger.info('No trigger matched for Trello event', { actionType, cardId }); + return; + } + + logger.info('Trello trigger matched', { + agentType: result.agentType, + cardId, + projectId: project.id, + }); + + // Post ack comment — we KNOW the trigger matched + let ackCommentId: string | undefined; + if (result.agentType) { + try { + const context = extractTrelloContext(payload); + const message = await generateAckMessage(result.agentType, context, project.id); + const commentId = await postTrelloAck(project.id, cardId, message); + ackCommentId = commentId ?? undefined; + } catch (err) { + logger.warn('Trello ack comment failed (non-fatal)', { error: String(err), cardId }); + } } + // Queue job with confirmed trigger result const job: CascadeJob = { type: 'trello', source: 'trello', @@ -218,13 +241,14 @@ export async function processTrelloWebhookEvent( actionType: actionType || 'unknown', receivedAt: new Date().toISOString(), ackCommentId, + triggerResult: result, }; try { const jobId = await addJob(job); - console.log('[Router] Trello job queued:', { jobId, actionType, ackCommentId }); + logger.info('Trello job queued', { jobId, actionType, ackCommentId }); } catch (err) { - console.error('[Router] Failed to queue Trello job:', err); + logger.error('Failed to queue Trello job', { error: String(err), actionType, cardId }); // Still return to caller — Trello gets 200 to avoid retries } } @@ -235,7 +259,8 @@ export async function processTrelloWebhookEvent( /** * Handle a POST /trello/webhook request. - * Parses the payload, filters irrelevant events, and queues a job. + * Parses the payload, filters irrelevant events, dispatches triggers, + * and queues a job only when a trigger confirms a match. */ export async function handleTrelloWebhook( payload: unknown, @@ -257,7 +282,7 @@ export async function handleTrelloWebhook( triggerRegistry, ); } else { - console.log(`[Router] Ignoring Trello: ${actionType || 'unknown'}`); + logger.debug('Ignoring Trello event', { actionType: actionType || 'unknown' }); } return { shouldProcess, project, actionType, cardId }; diff --git a/src/triggers/github/check-suite-failure.ts b/src/triggers/github/check-suite-failure.ts index 49725f72..89a1dceb 100644 --- a/src/triggers/github/check-suite-failure.ts +++ b/src/triggers/github/check-suite-failure.ts @@ -41,10 +41,6 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { return true; } - resolveAgentType(): string { - return 'respond-to-ci'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as GitHubCheckSuitePayload; const { owner, repo } = parseRepoFullName(payload.repository.full_name); diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index fb989719..0f0c410a 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -12,8 +12,10 @@ const RETRY_DELAY_MS = 10_000; /** * Wait for all check suites to complete, retrying when some are still in-progress. * Returns immediately if all checks have completed (whether passing or failing). + * + * Called by the worker before starting the review agent (not in the trigger handler). */ -async function waitForChecks( +export async function waitForChecks( owner: string, repo: string, headSha: string, @@ -84,10 +86,6 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { return true; } - resolveAgentType(): string { - return 'review'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as GitHubCheckSuitePayload; const { owner, repo } = parseRepoFullName(payload.repository.full_name); @@ -171,29 +169,15 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { }); } - // Verify all checks are actually passing (double-check) - // Uses the implementer token already in scope (set by webhook-handler), - // which has actions:read permission. The reviewer's fine-grained PAT may not. - // + // The trigger decision is made — the review agent should run. + // Actual check polling (waitForChecks) is deferred to the worker via the flag. // GitHub fires a check_suite webhook per individual suite completion. // When multiple suites exist, the first webhook arrives before other suites finish. - // waitForChecks retries when checks are still in-progress, but bails on genuine failures. - const checkStatus = await waitForChecks(owner, repo, headSha, prNumber); - - if (!checkStatus.allPassing) { - logger.info('Not all checks passing, skipping review trigger', { - prNumber, - totalChecks: checkStatus.totalCount, - failing: checkStatus.checkRuns.filter((c) => c.conclusion !== 'success').map((c) => c.name), - }); - return null; - } - - logger.info('All CI checks passed - triggering review', { + // The worker will poll until all checks pass before starting the agent. + logger.info('Check-suite success trigger matched — deferring check polling to worker', { prNumber, workItemId, headSha, - totalChecks: checkStatus.totalCount, }); return { @@ -208,6 +192,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { }, prNumber, workItemId, + waitForChecks: true, }; } } diff --git a/src/triggers/github/pr-comment-mention.ts b/src/triggers/github/pr-comment-mention.ts index cb20db7b..b6d0aa7e 100644 --- a/src/triggers/github/pr-comment-mention.ts +++ b/src/triggers/github/pr-comment-mention.ts @@ -39,10 +39,6 @@ export class PRCommentMentionTrigger implements TriggerHandler { return false; } - resolveAgentType(): string { - return 'respond-to-pr-comment'; - } - async handle(ctx: TriggerContext): Promise { // Require persona identities for @mention detection if (!ctx.personaIdentities) { diff --git a/src/triggers/github/pr-merged.ts b/src/triggers/github/pr-merged.ts index 63ed247d..2473fee1 100644 --- a/src/triggers/github/pr-merged.ts +++ b/src/triggers/github/pr-merged.ts @@ -24,10 +24,6 @@ export class PRMergedTrigger implements TriggerHandler { return ctx.payload.action === 'closed'; } - resolveAgentType(): string | null { - return null; // No agent — performs card move directly - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as GitHubPullRequestPayload; const { owner, repo } = parseRepoFullName(payload.repository.full_name); diff --git a/src/triggers/github/pr-opened.ts b/src/triggers/github/pr-opened.ts index fe315449..daba972d 100644 --- a/src/triggers/github/pr-opened.ts +++ b/src/triggers/github/pr-opened.ts @@ -30,10 +30,6 @@ export class PROpenedTrigger implements TriggerHandler { return true; } - resolveAgentType(): string { - return 'respond-to-review'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as { pull_request: { diff --git a/src/triggers/github/pr-ready-to-merge.ts b/src/triggers/github/pr-ready-to-merge.ts index 78bd5b84..f70c31c8 100644 --- a/src/triggers/github/pr-ready-to-merge.ts +++ b/src/triggers/github/pr-ready-to-merge.ts @@ -46,10 +46,6 @@ export class PRReadyToMergeTrigger implements TriggerHandler { return false; } - resolveAgentType(): string | null { - return null; // No agent — performs card move directly - } - async handle(ctx: TriggerContext): Promise { let prNumber: number; let headSha: string; diff --git a/src/triggers/github/pr-review-submitted.ts b/src/triggers/github/pr-review-submitted.ts index 0ad21712..7a15662e 100644 --- a/src/triggers/github/pr-review-submitted.ts +++ b/src/triggers/github/pr-review-submitted.ts @@ -27,10 +27,6 @@ export class PRReviewSubmittedTrigger implements TriggerHandler { return true; } - resolveAgentType(): string { - return 'respond-to-review'; - } - async handle(ctx: TriggerContext): Promise { // Type assertion since we validated in matches() const reviewPayload = ctx.payload as { diff --git a/src/triggers/github/review-requested.ts b/src/triggers/github/review-requested.ts index af7fc097..2d1eb0a1 100644 --- a/src/triggers/github/review-requested.ts +++ b/src/triggers/github/review-requested.ts @@ -40,10 +40,6 @@ export class ReviewRequestedTrigger implements TriggerHandler { return true; } - resolveAgentType(): string { - return 'review'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as GitHubPullRequestPayload; const prNumber = payload.pull_request.number; diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 1b0b1e08..e818fa6c 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -3,6 +3,7 @@ import { loadProjectConfigByRepo } from '../../config/provider.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas.js'; +import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import { extractGitHubContext, generateAckMessage } from '../../router/ackMessageGenerator.js'; import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; @@ -90,19 +91,6 @@ async function postAcknowledgmentComment( } } -/** - * Establish PM credential scope for the project. - * Uses the integration's withCredentials() for the correct PM type. - * Falls through to running fn() directly if no PM type is configured. - */ -async function withPMCredentials(project: ProjectConfig, fn: () => Promise): Promise { - const pmType = project.pm?.type; - if (!pmType) return fn(); - const integration = pmRegistry.getOrNull(pmType); - if (!integration) return fn(); - return integration.withCredentials(project.id, fn); -} - async function executeGitHubAgent( result: TriggerResult, project: ProjectConfig, @@ -122,12 +110,16 @@ async function executeGitHubAgent( try { const pmProvider = createPMProvider(project); - await withPMCredentials(project, () => - withPMProvider(pmProvider, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, executionConfig), + await withPMCredentials( + project.id, + project.pm?.type, + (t) => pmRegistry.getOrNull(t), + () => + withPMProvider(pmProvider, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, executionConfig), + ), ), - ), ); } finally { restoreLlmEnv(); @@ -196,14 +188,98 @@ function processNextQueuedGitHubWebhook(registry: TriggerRegistry): void { ); } +/** + * Poll until all CI checks pass before starting the agent. + * Returns false if checks don't pass after polling (agent should be skipped). + */ +async function pollWaitForChecks( + result: TriggerResult, + repoFullName: string, + githubToken: string, +): Promise { + const { waitForChecks } = await import('./check-suite-success.js'); + const { owner, repo } = parseRepoFullName(repoFullName); + const headSha = result.agentInput.headSha as string; + const prNumber = result.prNumber ?? 0; + + logger.info('Waiting for all checks to pass before starting agent', { prNumber, headSha }); + + const checkStatus = await withGitHubToken(githubToken, () => + waitForChecks(owner, repo, headSha, prNumber), + ); + + if (!checkStatus.allPassing) { + logger.info('Not all checks passing after polling, skipping agent', { + prNumber, + headSha, + failedChecks: checkStatus.checkRuns + .filter((c) => c.conclusion !== 'success') + .map((c) => c.name), + }); + return false; + } + + logger.info('All checks passing, proceeding with agent', { prNumber }); + return true; +} + +/** Try to enqueue the webhook if another job is already processing. Returns true if enqueued (caller should return). */ +function tryEnqueueIfBusy( + payload: unknown, + eventType: string, + ackCommentId?: number, + ackMessage?: string, +): boolean { + if (!isCurrentlyProcessing()) return false; + + const queued = enqueueWebhook(payload, eventType, ackCommentId, ackMessage); + if (queued) { + logger.info('Currently processing, GitHub webhook queued', { + queueLength: getQueueLength(), + eventType, + }); + } else { + logger.warn('Queue full, GitHub webhook rejected', { queueLength: getQueueLength() }); + } + return true; +} + +/** Resolve trigger result — use pre-resolved from router or dispatch via registry. */ +async function resolveTriggerResult( + existing: TriggerResult | undefined, + project: ProjectConfig, + payload: unknown, + personaIdentities: Awaited>, + githubToken: string, + registry: TriggerRegistry, +): Promise { + if (existing) { + logger.info('Using pre-resolved trigger result for GitHub webhook', { + agentType: existing.agentType, + }); + return existing; + } + + const ctx: TriggerContext = { project, source: 'github', payload, personaIdentities }; + const pmProvider = createPMProvider(project); + return withPMCredentials( + project.id, + project.pm?.type, + (t) => pmRegistry.getOrNull(t), + () => + withPMProvider(pmProvider, () => withGitHubToken(githubToken, () => registry.dispatch(ctx))), + ); +} + export async function processGitHubWebhook( payload: unknown, eventType: string, registry: TriggerRegistry, ackCommentId?: number, ackMessage?: string, + triggerResult?: TriggerResult, ): Promise { - logger.info('Processing GitHub webhook', { eventType }); + logger.info('Processing GitHub webhook', { eventType, hasTriggerResult: !!triggerResult }); const p = payload as Record; const repository = p.repository as Record | undefined; @@ -214,18 +290,7 @@ export async function processGitHubWebhook( return; } - if (isCurrentlyProcessing()) { - const queued = enqueueWebhook(payload, eventType, ackCommentId, ackMessage); - if (queued) { - logger.info('Currently processing, GitHub webhook queued', { - queueLength: getQueueLength(), - eventType, - }); - } else { - logger.warn('Queue full, GitHub webhook rejected', { queueLength: getQueueLength() }); - } - return; - } + if (tryEnqueueIfBusy(payload, eventType, ackCommentId, ackMessage)) return; const projectConfig = await loadProjectConfigByRepo(repoFullName); if (!projectConfig) { @@ -234,25 +299,20 @@ export async function processGitHubWebhook( } const { project, config } = projectConfig; - // Resolve persona identities and use implementer token for webhook processing const personaIdentities = await resolvePersonaIdentities(project.id); const githubToken = await getPersonaToken(project.id, 'implementation'); - const pmProvider = createPMProvider(project); - // Establish PM credential + provider scope for trigger dispatch - const ctx: TriggerContext = { project, source: 'github', payload, personaIdentities }; - const result = await withPMCredentials(project, () => - withPMProvider(pmProvider, () => withGitHubToken(githubToken, () => registry.dispatch(ctx))), + const result = await resolveTriggerResult( + triggerResult, + project, + payload, + personaIdentities, + githubToken, + registry, ); if (!result) { logger.info('No trigger matched for GitHub webhook', { eventType, repoFullName }); - // Clean up orphan ack if router posted one but no trigger matched - if (ackCommentId) { - logger.info('Cleaning up orphan ack comment', { ackCommentId, repoFullName }); - const { deleteGitHubAck } = await import('../../router/acknowledgments.js'); - await deleteGitHubAck(repoFullName, ackCommentId, githubToken).catch(() => {}); - } return; } @@ -264,6 +324,12 @@ export async function processGitHubWebhook( result.agentInput.ackMessage = ackMessage; } + // Poll until all CI checks pass before starting agent (deferred from trigger) + if (result.waitForChecks) { + const checksOk = await pollWaitForChecks(result, repoFullName, githubToken); + if (!checksOk) return; + } + logger.info('GitHub trigger matched', { agentType: result.agentType || '(no agent)', prNumber: result.prNumber, diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts index a4982984..0ca534b4 100644 --- a/src/triggers/jira/comment-mention.ts +++ b/src/triggers/jira/comment-mention.ts @@ -10,23 +10,7 @@ import { jiraClient } from '../../jira/client.js'; import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; - -interface JiraWebhookPayload { - webhookEvent: string; - issue?: { - id?: string; - key: string; - fields?: { - project?: { key?: string }; - status?: { name?: string }; - }; - }; - comment?: { - id?: string; - body?: unknown; - author?: { displayName?: string; accountId?: string }; - }; -} +import type { JiraWebhookPayload } from './types.js'; // Cache authenticated user info to avoid repeated API calls let cachedUserInfo: { accountId: string; displayName: string } | null = null; @@ -120,10 +104,6 @@ export class JiraCommentMentionTrigger implements TriggerHandler { return payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated'; } - resolveAgentType(): string { - return 'respond-to-planning-comment'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as JiraWebhookPayload; const issueKey = payload.issue?.key; diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts index 7d5fea27..0b8d600e 100644 --- a/src/triggers/jira/issue-transitioned.ts +++ b/src/triggers/jira/issue-transitioned.ts @@ -12,40 +12,7 @@ import { import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; - -interface JiraWebhookPayload { - webhookEvent: string; - issue?: { - key: string; - fields?: { - project?: { key?: string }; - status?: { name?: string }; - summary?: string; - }; - }; - changelog?: { - items?: Array<{ - field?: string; - fromString?: string; - toString?: string; - }>; - }; -} - -/** - * Maps a JIRA status name to the CASCADE agent type based on project config. - * - * project.jira.statuses maps CASCADE status names to JIRA status names, e.g.: - * { briefing: "Briefing", planning: "Planning", todo: "To Do" } - * - * We invert this mapping: if the issue transitioned to "Briefing", we fire - * the briefing agent. - */ -const STATUS_TO_AGENT: Record = { - briefing: 'briefing', - planning: 'planning', - todo: 'implementation', -}; +import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; export class JiraIssueTransitionedTrigger implements TriggerHandler { name = 'jira-issue-transitioned'; @@ -67,23 +34,6 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { return !!statusChange; } - resolveAgentType(ctx: TriggerContext): string | null { - const payload = ctx.payload as JiraWebhookPayload; - const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); - const newStatus = statusChange?.toString; - if (!newStatus) return null; - - const jiraConfig = getJiraConfig(ctx.project); - if (!jiraConfig?.statuses) return null; - - for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { - if (jiraStatus.toLowerCase() === newStatus.toLowerCase()) { - return STATUS_TO_AGENT[cascadeStatus] ?? null; - } - } - return null; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as JiraWebhookPayload; const issueKey = payload.issue?.key; diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 672ac763..a81e2a39 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -18,34 +18,7 @@ import { getJiraConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; - -interface JiraChangelogItem { - field?: string; - fromString?: string; - toString?: string; -} - -interface JiraLabelPayload { - webhookEvent: string; - issue?: { - key: string; - fields?: { - project?: { key?: string }; - status?: { name?: string }; - summary?: string; - }; - }; - changelog?: { - items?: JiraChangelogItem[]; - }; -} - -/** Same status→agent mapping as issue-transitioned.ts */ -const STATUS_TO_AGENT: Record = { - briefing: 'briefing', - planning: 'planning', - todo: 'implementation', -}; +import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; /** * Parse which labels were added from a JIRA label changelog item. @@ -56,7 +29,7 @@ const STATUS_TO_AGENT: Record = { * * Returns the set of labels present in `toString` but not in `fromString`. */ -function parseAddedLabels(item: JiraChangelogItem): Set { +function parseAddedLabels(item: { fromString?: string; toString?: string }): Set { const before = new Set((item.fromString ?? '').split(/\s+/).filter(Boolean)); const after = (item.toString ?? '').split(/\s+/).filter(Boolean); return new Set(after.filter((label) => !before.has(label))); @@ -74,7 +47,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return false; } - const payload = ctx.payload as JiraLabelPayload; + const payload = ctx.payload as JiraWebhookPayload; if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; const items = payload.changelog?.items; @@ -97,24 +70,8 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return addedLabels.has(readyLabel); } - resolveAgentType(ctx: TriggerContext): string | null { - const payload = ctx.payload as JiraLabelPayload; - const currentStatus = payload.issue?.fields?.status?.name; - if (!currentStatus) return null; - - const jiraConfig = getJiraConfig(ctx.project); - if (!jiraConfig?.statuses) return null; - - for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { - if (jiraStatus.toLowerCase() === currentStatus.toLowerCase()) { - return STATUS_TO_AGENT[cascadeStatus] ?? null; - } - } - return null; - } - async handle(ctx: TriggerContext): Promise { - const payload = ctx.payload as JiraLabelPayload; + const payload = ctx.payload as JiraWebhookPayload; const issueKey = payload.issue?.key; if (!issueKey) { diff --git a/src/triggers/jira/types.ts b/src/triggers/jira/types.ts new file mode 100644 index 00000000..730396ec --- /dev/null +++ b/src/triggers/jira/types.ts @@ -0,0 +1,51 @@ +/** + * Shared JIRA webhook types and constants used across JIRA trigger handlers. + */ + +// --------------------------------------------------------------------------- +// Webhook Payload +// --------------------------------------------------------------------------- + +export interface JiraWebhookPayload { + webhookEvent: string; + issue?: { + id?: string; + key: string; + fields?: { + project?: { key?: string }; + status?: { name?: string }; + summary?: string; + }; + }; + changelog?: { + items?: Array<{ + field?: string; + fromString?: string; + toString?: string; + }>; + }; + comment?: { + id?: string; + body?: unknown; + author?: { displayName?: string; accountId?: string }; + }; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Maps CASCADE status keys to agent types. + * + * Project config maps CASCADE status names to JIRA status names, e.g.: + * { briefing: "Briefing", planning: "Planning", todo: "To Do" } + * + * We invert that mapping at runtime: if the issue transitioned to "Briefing", + * we look up `briefing` → `briefing` agent. + */ +export const STATUS_TO_AGENT: Record = { + briefing: 'briefing', + planning: 'planning', + todo: 'implementation', +}; diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts index 9f034b88..d641e297 100644 --- a/src/triggers/jira/webhook-handler.ts +++ b/src/triggers/jira/webhook-handler.ts @@ -7,13 +7,15 @@ import { pmRegistry } from '../../pm/index.js'; import { processPMWebhook } from '../../pm/webhook-handler.js'; +import type { TriggerResult } from '../../types/index.js'; import type { TriggerRegistry } from '../registry.js'; export async function processJiraWebhook( payload: unknown, registry: TriggerRegistry, ackCommentId?: string, + triggerResult?: TriggerResult, ): Promise { const integration = pmRegistry.get('jira'); - await processPMWebhook(integration, payload, registry, ackCommentId); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult); } diff --git a/src/triggers/registry.ts b/src/triggers/registry.ts index 88a5649b..dc2aa5f3 100644 --- a/src/triggers/registry.ts +++ b/src/triggers/registry.ts @@ -41,20 +41,6 @@ export class TriggerRegistry { return null; } - /** - * Find the first handler that matches the context and can resolve an agent type. - * Pure logic — no API calls, no side effects. Safe to call in the router. - */ - matchTrigger(ctx: TriggerContext): { name: string; agentType: string } | null { - for (const handler of this.handlers) { - if (handler.matches(ctx)) { - const agentType = handler.resolveAgentType(ctx); - if (agentType) return { name: handler.name, agentType }; - } - } - return null; - } - getHandlers(): TriggerHandler[] { return [...this.handlers]; } diff --git a/src/triggers/trello/card-moved.ts b/src/triggers/trello/card-moved.ts index 3f90e79e..d6a5b009 100644 --- a/src/triggers/trello/card-moved.ts +++ b/src/triggers/trello/card-moved.ts @@ -1,5 +1,6 @@ import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; import { getTrelloConfig } from '../../pm/config.js'; +import { logger } from '../../utils/logging.js'; import type { TrelloWebhookPayload, TriggerContext, @@ -51,16 +52,13 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { return isMove || isCreate; }, - resolveAgentType(): string { - return config.agentType; - }, - - async handle(ctx: TriggerContext): Promise { + async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as TrelloWebhookPayload; const cardId = payload.action.data.card?.id; if (!cardId) { - throw new Error('No card ID in payload'); + logger.warn('No card ID in Trello card-moved payload', { trigger: config.name }); + return null; } return { diff --git a/src/triggers/trello/comment-mention.ts b/src/triggers/trello/comment-mention.ts index 5ecfc741..f49adfe7 100644 --- a/src/triggers/trello/comment-mention.ts +++ b/src/triggers/trello/comment-mention.ts @@ -45,10 +45,6 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { return ctx.payload.action.type === 'commentCard'; } - resolveAgentType(): string { - return 'respond-to-planning-comment'; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as TrelloWebhookPayload; const cardId = payload.action.data.card?.id; diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index 27e4afbf..f9465ac4 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -36,17 +36,13 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { ); } - resolveAgentType(): string | null { - // Cannot determine agent type without fetching the card to get its current list ID - return null; - } - async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as TrelloWebhookPayload; const cardId = payload.action.data.card?.id; if (!cardId) { - throw new Error('No card ID in payload'); + logger.warn('No card ID in Trello label-added payload'); + return null; } // Fetch card to get current list ID (webhook payload doesn't include it for addLabelToCard) diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 9b3f2b36..1e64b18a 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -7,13 +7,15 @@ import { pmRegistry } from '../../pm/index.js'; import { processPMWebhook } from '../../pm/webhook-handler.js'; +import type { TriggerResult } from '../../types/index.js'; import type { TriggerRegistry } from '../registry.js'; export async function processTrelloWebhook( payload: unknown, registry: TriggerRegistry, ackCommentId?: string, + triggerResult?: TriggerResult, ): Promise { const integration = pmRegistry.get('trello'); - await processPMWebhook(integration, payload, registry, ackCommentId); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult); } diff --git a/src/types/index.ts b/src/types/index.ts index 59ef22e8..ddb58c6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,18 +74,13 @@ export interface TriggerResult { agentInput: AgentInput; workItemId?: string; prNumber?: number; + /** When true, the worker must poll for all CI checks to pass before starting the agent. */ + waitForChecks?: boolean; } export interface TriggerHandler { name: string; description: string; matches: (ctx: TriggerContext) => boolean; - /** - * Resolve the agent type this trigger would run, without side effects. - * Returns null when agent type cannot be determined without API calls - * (e.g. Trello label-added needs card lookup) or when the trigger - * doesn't run an agent (e.g. PR merged). - */ - resolveAgentType: (ctx: TriggerContext) => string | null; handle: (ctx: TriggerContext) => Promise; } diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 17da73ff..cfa1e1f2 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -25,6 +25,7 @@ import { registerBuiltInTriggers, } from './triggers/index.js'; import { processTrelloWebhook } from './triggers/trello/webhook-handler.js'; +import type { TriggerResult } from './types/index.js'; import { scrubSensitiveEnv } from './utils/envScrub.js'; import { logger, setLogLevel } from './utils/index.js'; @@ -37,6 +38,7 @@ interface TrelloJobData { actionType: string; receivedAt: string; ackCommentId?: string; + triggerResult?: TriggerResult; } interface GitHubJobData { @@ -48,6 +50,7 @@ interface GitHubJobData { receivedAt: string; ackCommentId?: number; ackMessage?: string; + triggerResult?: TriggerResult; } interface JiraJobData { @@ -59,6 +62,7 @@ interface JiraJobData { webhookEvent: string; receivedAt: string; ackCommentId?: string; + triggerResult?: TriggerResult; } interface ManualRunJobData { @@ -147,8 +151,14 @@ async function dispatchJob( cardId: jobData.cardId, actionType: jobData.actionType, ackCommentId: jobData.ackCommentId, + hasTriggerResult: !!jobData.triggerResult, }); - await processTrelloWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + await processTrelloWebhook( + jobData.payload, + triggerRegistry, + jobData.ackCommentId, + jobData.triggerResult, + ); break; case 'github': logger.info('[Worker] Processing GitHub job', { @@ -156,6 +166,7 @@ async function dispatchJob( eventType: jobData.eventType, repoFullName: jobData.repoFullName, ackCommentId: jobData.ackCommentId, + hasTriggerResult: !!jobData.triggerResult, }); await processGitHubWebhook( jobData.payload, @@ -163,6 +174,7 @@ async function dispatchJob( triggerRegistry, jobData.ackCommentId, jobData.ackMessage, + jobData.triggerResult, ); break; case 'jira': @@ -171,8 +183,14 @@ async function dispatchJob( issueKey: jobData.issueKey, webhookEvent: jobData.webhookEvent, ackCommentId: jobData.ackCommentId, + hasTriggerResult: !!jobData.triggerResult, }); - await processJiraWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + await processJiraWebhook( + jobData.payload, + triggerRegistry, + jobData.ackCommentId, + jobData.triggerResult, + ); break; case 'manual-run': case 'retry-run': diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 78993637..f1f1cb76 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -261,6 +261,21 @@ describe('resolveReadyToProcessEnabled', () => { }; expect(resolveReadyToProcessEnabled(config, 'unknown-agent')).toBe(true); }); + + it('defaults to true for known non-toggle agents like respond-to-review', () => { + const config = { + readyToProcessLabel: { briefing: false, planning: false, implementation: false }, + }; + expect(resolveReadyToProcessEnabled(config, 'respond-to-review')).toBe(true); + expect(resolveReadyToProcessEnabled(config, 'debug')).toBe(true); + }); + + it('defaults all agents to true when nested object is empty (Zod fills defaults)', () => { + const parsed = TrelloTriggerConfigSchema.parse({ readyToProcessLabel: {} }); + expect(resolveReadyToProcessEnabled(parsed, 'briefing')).toBe(true); + expect(resolveReadyToProcessEnabled(parsed, 'planning')).toBe(true); + expect(resolveReadyToProcessEnabled(parsed, 'implementation')).toBe(true); + }); }); describe('resolveIssueTransitionedEnabled', () => { @@ -303,6 +318,21 @@ describe('resolveIssueTransitionedEnabled', () => { }; expect(resolveIssueTransitionedEnabled(config, 'unknown-agent')).toBe(true); }); + + it('defaults to true for known non-toggle agents like respond-to-review', () => { + const config = { + issueTransitioned: { briefing: false, planning: false, implementation: false }, + }; + expect(resolveIssueTransitionedEnabled(config, 'respond-to-review')).toBe(true); + expect(resolveIssueTransitionedEnabled(config, 'debug')).toBe(true); + }); + + it('defaults all agents to true when nested object is empty (Zod fills defaults)', () => { + const parsed = JiraTriggerConfigSchema.parse({ issueTransitioned: {} }); + expect(resolveIssueTransitionedEnabled(parsed, 'briefing')).toBe(true); + expect(resolveIssueTransitionedEnabled(parsed, 'planning')).toBe(true); + expect(resolveIssueTransitionedEnabled(parsed, 'implementation')).toBe(true); + }); }); describe('resolveReviewTriggerConfig', () => { diff --git a/tests/unit/router/github.test.ts b/tests/unit/router/github.test.ts index f81012da..615923d8 100644 --- a/tests/unit/router/github.test.ts +++ b/tests/unit/router/github.test.ts @@ -1,5 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // Mock heavy imports vi.mock('../../../src/router/queue.js', () => ({ addJob: vi.fn(), @@ -24,6 +33,9 @@ vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ extractGitHubContext: vi.fn().mockReturnValue('PR: Test PR'), generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); +vi.mock('../../../src/config/projects.js', () => ({ + getProjectGitHubToken: vi.fn().mockResolvedValue('ghp_mock'), +})); vi.mock('../../../src/config/provider.js', () => ({ findProjectByRepo: vi.fn(), })); @@ -31,6 +43,31 @@ vi.mock('../../../src/github/personas.js', () => ({ resolvePersonaIdentities: vi.fn(), isCascadeBot: vi.fn(), })); +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn().mockImplementation((_t: unknown, fn: () => unknown) => fn()), +})); +vi.mock('../../../src/pm/context.js', () => ({ + withPMProvider: vi.fn().mockImplementation((_p: unknown, fn: () => unknown) => fn()), + withPMCredentials: vi + .fn() + .mockImplementation((_id: unknown, _type: unknown, _get: unknown, fn: () => unknown) => fn()), +})); +vi.mock('../../../src/pm/registry.js', () => ({ + pmRegistry: { + getOrNull: vi.fn().mockReturnValue(null), + createProvider: vi.fn().mockReturnValue({}), + register: vi.fn(), + }, +})); +vi.mock('../../../src/pm/jira/integration.js', () => ({ + JiraIntegration: vi.fn(), +})); +vi.mock('../../../src/pm/trello/integration.js', () => ({ + TrelloIntegration: vi.fn(), +})); +vi.mock('../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); import { findProjectByRepo } from '../../../src/config/provider.js'; import { isCascadeBot, resolvePersonaIdentities } from '../../../src/github/personas.js'; @@ -42,7 +79,6 @@ import { handleGitHubWebhook, isSelfAuthoredGitHubComment, processGitHubWebhookEvent, - tryPostGitHubAck, } from '../../../src/router/github.js'; import { extractPRNumber } from '../../../src/router/notifications.js'; import { addEyesReactionToPR } from '../../../src/router/pre-actions.js'; @@ -52,7 +88,7 @@ import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; import type { TriggerRegistry } from '../../../src/triggers/registry.js'; const mockTriggerRegistry = { - matchTrigger: vi.fn(), + dispatch: vi.fn().mockResolvedValue(null), } as unknown as TriggerRegistry; beforeEach(() => { @@ -148,8 +184,34 @@ describe('handleGitHubWebhook', () => { expect(addJob).not.toHaveBeenCalled(); }); - it('processes pull_request events', async () => { - vi.mocked(findProjectByRepo).mockResolvedValue(null); + it('does not queue job when no project found', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [], + } as never); + + const result = await handleGitHubWebhook( + 'pull_request', + { repository: { full_name: 'owner/repo' }, action: 'opened' }, + mockTriggerRegistry, + ); + + expect(result.shouldProcess).toBe(true); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('queues job when dispatch returns a trigger result', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: { prNumber: 1 }, + prNumber: 1, + }); + vi.mocked(resolveGitHubTokenForAck).mockResolvedValue(null); vi.mocked(addJob).mockResolvedValue('job-1'); const result = await handleGitHubWebhook( @@ -165,6 +227,7 @@ describe('handleGitHubWebhook', () => { type: 'github', eventType: 'pull_request', repoFullName: 'owner/repo', + triggerResult: expect.objectContaining({ agentType: 'implementation' }), }), ); }); @@ -187,9 +250,13 @@ describe('handleGitHubWebhook', () => { 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); + it('does not queue when dispatch returns no match', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue(null); await handleGitHubWebhook( 'check_suite', @@ -201,9 +268,7 @@ describe('handleGitHubWebhook', () => { mockTriggerRegistry, ); - expect(addJob).toHaveBeenCalledWith( - expect.objectContaining({ type: 'github', eventType: 'check_suite' }), - ); + expect(addJob).not.toHaveBeenCalled(); }); }); @@ -211,8 +276,11 @@ 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); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'p1', repo: 'owner/repo' }], + } as never); await processGitHubWebhookEvent('issue_comment', 'owner/repo', {}, mockTriggerRegistry); @@ -222,9 +290,12 @@ describe('processGitHubWebhookEvent', () => { }); it('does not send ack reaction for non-comment events', async () => { - vi.mocked(addJob).mockResolvedValue('job-1'); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], + } as never); + vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); vi.mocked(resolveGitHubTokenForAck).mockResolvedValue(null); - vi.mocked(extractPRNumber).mockReturnValue(null); await processGitHubWebhookEvent('pull_request', 'owner/repo', {}, mockTriggerRegistry); @@ -232,14 +303,15 @@ describe('processGitHubWebhookEvent', () => { }); it('stores ackCommentId and ackMessage on the job when ack succeeds', async () => { - // Setup: make tryPostGitHubAck succeed by mocking its dependencies vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], } as never); vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); - (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ agentType: 'review', + agentInput: { prNumber: 42 }, + prNumber: 42, }); vi.mocked(generateAckMessage).mockResolvedValue('Looking into the PR now...'); vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ @@ -262,96 +334,48 @@ describe('processGitHubWebhookEvent', () => { type: 'github', ackCommentId: 12345, ackMessage: 'Looking into the PR now...', + triggerResult: expect.objectContaining({ agentType: 'review' }), }), ); }); - it('leaves ackMessage undefined when ack fails', async () => { - vi.mocked(resolveGitHubTokenForAck).mockResolvedValue(null); - vi.mocked(addJob).mockResolvedValue('job-1'); - - await processGitHubWebhookEvent( - 'pull_request', - 'owner/repo', - { repository: { full_name: 'owner/repo' } }, - mockTriggerRegistry, - ); - - expect(addJob).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'github', - ackCommentId: undefined, - ackMessage: undefined, - }), - ); - }); -}); - -describe('tryPostGitHubAck', () => { - it('returns commentId and message on success', async () => { + it('does not queue job when dispatch returns null', async () => { vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], } as never); vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); - (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ - agentType: 'review', - }); - vi.mocked(generateAckMessage).mockResolvedValue('Checking it out...'); - vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ - token: 'ghp_test', - project: { id: 'proj-1' }, - } as never); - vi.mocked(extractPRNumber).mockReturnValue(5); - vi.mocked(postGitHubAck).mockResolvedValue(777); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue(null); - const result = await tryPostGitHubAck( - 'pull_request_review', + await processGitHubWebhookEvent( + 'pull_request', 'owner/repo', - {}, + { repository: { full_name: 'owner/repo' } }, mockTriggerRegistry, ); - expect(result).toEqual({ commentId: 777, message: 'Checking it out...' }); - }); - - it('returns undefined when no trigger matches', async () => { - vi.mocked(loadProjectConfig).mockResolvedValue({ - projects: [], - fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], - } as never); - vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); - (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue(null); - - const result = await tryPostGitHubAck('pull_request', 'owner/repo', {}, mockTriggerRegistry); - - expect(result).toBeUndefined(); + expect(addJob).not.toHaveBeenCalled(); }); - it('returns undefined when postGitHubAck returns null', async () => { + it('does not queue job for no-agent triggers', async () => { vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [{ id: 'proj-1', repo: 'owner/repo' }], } as never); vi.mocked(resolvePersonaIdentities).mockResolvedValue({} as never); - (mockTriggerRegistry.matchTrigger as ReturnType).mockReturnValue({ - agentType: 'review', + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: null, + agentInput: {}, + prNumber: 42, }); - vi.mocked(generateAckMessage).mockResolvedValue('Msg'); - vi.mocked(resolveGitHubTokenForAck).mockResolvedValue({ - token: 'ghp_test', - project: { id: 'proj-1' }, - } as never); - vi.mocked(extractPRNumber).mockReturnValue(5); - vi.mocked(postGitHubAck).mockResolvedValue(null); - const result = await tryPostGitHubAck( - 'pull_request_review', + await processGitHubWebhookEvent( + 'pull_request', 'owner/repo', - {}, + { repository: { full_name: 'owner/repo' } }, mockTriggerRegistry, ); - expect(result).toBeUndefined(); + expect(addJob).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/router/jira.test.ts b/tests/unit/router/jira.test.ts index 6e0c6248..80ef8fb0 100644 --- a/tests/unit/router/jira.test.ts +++ b/tests/unit/router/jira.test.ts @@ -1,5 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // Mock heavy imports vi.mock('../../../src/router/config.js', () => ({ loadProjectConfig: vi.fn(), @@ -18,6 +27,14 @@ vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ extractJiraContext: vi.fn().mockReturnValue('Issue: Test issue'), generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); +vi.mock('../../../src/router/platformClients.js', () => ({ + resolveJiraCredentials: vi + .fn() + .mockResolvedValue({ email: 'e@x.com', apiToken: 'tok', baseUrl: 'https://x.atlassian.net' }), +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn().mockImplementation((_c: unknown, fn: () => unknown) => fn()), +})); import { resolveJiraBotAccountId } from '../../../src/router/acknowledgments.js'; import { loadProjectConfig } from '../../../src/router/config.js'; @@ -25,7 +42,7 @@ import type { RouterProjectConfig } from '../../../src/router/config.js'; import { handleJiraWebhook, isSelfAuthoredJiraComment, - queueJiraJob, + processJiraWebhookEvent, } from '../../../src/router/jira.js'; import { addJob } from '../../../src/router/queue.js'; import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; @@ -42,7 +59,7 @@ const mockProject: RouterProjectConfig = { }; const mockTriggerRegistry = { - matchTrigger: vi.fn(), + dispatch: vi.fn().mockResolvedValue(null), } as unknown as TriggerRegistry; beforeEach(() => { @@ -91,15 +108,22 @@ describe('isSelfAuthoredJiraComment', () => { }); }); -describe('queueJiraJob', () => { - it('queues a jira job', async () => { +describe('processJiraWebhookEvent', () => { + const fullProject = { id: 'p1', repo: 'owner/repo' }; + + it('queues a jira job when dispatch matches', async () => { vi.mocked(addJob).mockResolvedValue('job-1'); - await queueJiraJob( + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: { issueKey: 'MYPROJ-123' }, + }); + + await processJiraWebhookEvent( mockProject, 'MYPROJ-123', 'jira:issue_updated', { issue: { key: 'MYPROJ-123' } }, - [], + [fullProject] as never, mockTriggerRegistry, ); expect(addJob).toHaveBeenCalledWith( @@ -108,20 +132,39 @@ describe('queueJiraJob', () => { projectId: 'p1', issueKey: 'MYPROJ-123', webhookEvent: 'jira:issue_updated', + triggerResult: expect.objectContaining({ agentType: 'implementation' }), }), ); }); + it('does not queue when dispatch returns null', async () => { + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue(null); + + await processJiraWebhookEvent( + mockProject, + 'MYPROJ-123', + 'jira:issue_updated', + { issue: { key: 'MYPROJ-123' } }, + [fullProject] as never, + mockTriggerRegistry, + ); + expect(addJob).not.toHaveBeenCalled(); + }); + it('sends ack reaction for comment events', async () => { vi.mocked(addJob).mockResolvedValue('job-1'); vi.mocked(sendAcknowledgeReaction).mockResolvedValue(undefined); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: {}, + }); - await queueJiraJob( + await processJiraWebhookEvent( mockProject, 'MYPROJ-123', 'comment_created', { comment: {} }, - [], + [fullProject] as never, mockTriggerRegistry, ); @@ -132,12 +175,17 @@ describe('queueJiraJob', () => { it('does not send reaction for non-comment events', async () => { vi.mocked(addJob).mockResolvedValue('job-1'); - await queueJiraJob( + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: {}, + }); + + await processJiraWebhookEvent( mockProject, 'MYPROJ-123', 'jira:issue_updated', {}, - [], + [fullProject] as never, mockTriggerRegistry, ); expect(sendAcknowledgeReaction).not.toHaveBeenCalled(); @@ -177,12 +225,16 @@ describe('handleJiraWebhook', () => { expect(addJob).not.toHaveBeenCalled(); }); - it('processes jira:issue_updated events for known projects', async () => { + it('processes jira:issue_updated events for known projects when dispatch matches', async () => { vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], - fullProjects: [], - }); + fullProjects: [{ id: 'p1' }], + } as never); vi.mocked(addJob).mockResolvedValue('job-1'); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: {}, + }); const result = await handleJiraWebhook( { @@ -194,7 +246,12 @@ describe('handleJiraWebhook', () => { expect(result.shouldProcess).toBe(true); expect(addJob).toHaveBeenCalledWith( - expect.objectContaining({ type: 'jira', projectId: 'p1', issueKey: 'MYPROJ-1' }), + expect.objectContaining({ + type: 'jira', + projectId: 'p1', + issueKey: 'MYPROJ-1', + triggerResult: expect.objectContaining({ agentType: 'implementation' }), + }), ); }); diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index bde90102..b38caba7 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -1,5 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // Mock heavy imports vi.mock('../../../src/router/config.js', () => ({ loadProjectConfig: vi.fn(), @@ -18,6 +27,12 @@ vi.mock('../../../src/router/ackMessageGenerator.js', () => ({ extractTrelloContext: vi.fn().mockReturnValue('Card: Test card'), generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); +vi.mock('../../../src/router/platformClients.js', () => ({ + resolveTrelloCredentials: vi.fn().mockResolvedValue({ apiKey: 'key', token: 'tok' }), +})); +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn().mockImplementation((_creds: unknown, fn: () => unknown) => fn()), +})); import { postTrelloAck, resolveTrelloBotMemberId } from '../../../src/router/acknowledgments.js'; import { loadProjectConfig } from '../../../src/router/config.js'; @@ -53,7 +68,7 @@ const mockProject: RouterProjectConfig = { }; const mockTriggerRegistry = { - matchTrigger: vi.fn(), + dispatch: vi.fn().mockResolvedValue(null), } as unknown as TriggerRegistry; beforeEach(() => { @@ -66,10 +81,19 @@ describe('isAgentLogFilename', () => { expect(isAgentLogFilename('briefing-timeout-2026-01-02T12-34-56-789Z.zip')).toBe(true); }); + it('matches multi-hyphen agent names (e.g. respond-to-review)', () => { + expect(isAgentLogFilename('respond-to-review-2026-01-02T16-30-24-339Z.zip')).toBe(true); + expect(isAgentLogFilename('respond-to-pr-comment-2026-01-02T16-30-24-339Z.zip')).toBe(true); + }); + it('does not match non-zip filenames', () => { expect(isAgentLogFilename('screenshot.png')).toBe(false); }); + it('does not match filenames without a timestamp', () => { + expect(isAgentLogFilename('implementation.zip')).toBe(false); + }); + it('matches debug-prefixed filenames (caller filters separately)', () => { expect(isAgentLogFilename('debug-2026-01-02T16-30-24-339Z.zip')).toBe(true); }); @@ -249,9 +273,36 @@ describe('processTrelloWebhookEvent', () => { expect(addJob).not.toHaveBeenCalled(); }); - it('queues a job for non-self-authored comment', async () => { + it('does not queue a job when dispatch returns null', async () => { vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1' }], + } as never); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue(null); + + await processTrelloWebhookEvent( + mockProject, + 'card1', + 'commentCard', + { action: { idMemberCreator: 'user-id' } }, + mockTriggerRegistry, + ); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('queues a job when dispatch returns a result', async () => { + vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1' }], + } as never); vi.mocked(addJob).mockResolvedValue('job-1'); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: { cardId: 'card1' }, + }); + await processTrelloWebhookEvent( mockProject, 'card1', @@ -265,14 +316,24 @@ describe('processTrelloWebhookEvent', () => { projectId: 'p1', cardId: 'card1', actionType: 'commentCard', + triggerResult: expect.objectContaining({ agentType: 'implementation' }), }), ); }); it('sends ack reaction for comment actions', async () => { vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1' }], + } as never); vi.mocked(addJob).mockResolvedValue('job-1'); vi.mocked(sendAcknowledgeReaction).mockResolvedValue(undefined); + (mockTriggerRegistry.dispatch as ReturnType).mockResolvedValue({ + agentType: 'implementation', + agentInput: { cardId: 'card1' }, + }); + await processTrelloWebhookEvent( mockProject, 'card1', diff --git a/tests/unit/triggers/card-moved.test.ts b/tests/unit/triggers/card-moved.test.ts index 8af247b4..58297b7f 100644 --- a/tests/unit/triggers/card-moved.test.ts +++ b/tests/unit/triggers/card-moved.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // Mocks required for PM integration registration (pm/index.js side-effect) vi.mock('../../../src/config/provider.js', () => ({ getIntegrationCredential: vi.fn(), @@ -157,16 +166,6 @@ describe('CardMovedToBriefingTrigger', () => { expect(trigger.matches(ctx)).toBe(false); }); - it('resolveAgentType returns briefing', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'trello', - payload: {}, - }; - - expect(trigger.resolveAgentType(ctx)).toBe('briefing'); - }); - it('handles and returns briefing agent', async () => { const ctx: TriggerContext = { project: mockProject, @@ -189,9 +188,9 @@ describe('CardMovedToBriefingTrigger', () => { const result = await trigger.handle(ctx); - expect(result.agentType).toBe('briefing'); - expect(result.workItemId).toBe('card123'); - expect(result.agentInput.cardId).toBe('card123'); + expect(result?.agentType).toBe('briefing'); + expect(result?.workItemId).toBe('card123'); + expect(result?.agentInput.cardId).toBe('card123'); }); }); @@ -238,17 +237,33 @@ describe('CardMovedToTodoTrigger', () => { expect(trigger.matches(ctx)).toBe(true); }); - it('resolveAgentType returns implementation', () => { + it('handles and returns implementation agent', async () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', - payload: {}, + payload: { + model: { id: 'board123', name: 'Board' }, + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'updateCard', + date: '2024-01-01', + data: { + card: { id: 'card456', name: 'Test Card', idShort: 1, shortLink: 'abc' }, + listBefore: { id: 'planning-list-id', name: 'Planning' }, + listAfter: { id: 'todo-list-id', name: 'TODO' }, + }, + }, + }, }; - expect(trigger.resolveAgentType(ctx)).toBe('implementation'); + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('card456'); }); - it('handles and returns implementation agent', async () => { + it('returns null when card ID is missing from payload', async () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -260,7 +275,7 @@ describe('CardMovedToTodoTrigger', () => { type: 'updateCard', date: '2024-01-01', data: { - card: { id: 'card456', name: 'Test Card', idShort: 1, shortLink: 'abc' }, + // No card field listBefore: { id: 'planning-list-id', name: 'Planning' }, listAfter: { id: 'todo-list-id', name: 'TODO' }, }, @@ -269,8 +284,6 @@ describe('CardMovedToTodoTrigger', () => { }; const result = await trigger.handle(ctx); - - expect(result.agentType).toBe('implementation'); - expect(result.workItemId).toBe('card456'); + expect(result).toBeNull(); }); }); diff --git a/tests/unit/triggers/check-suite-failure.test.ts b/tests/unit/triggers/check-suite-failure.test.ts index 90c686ba..e1b15ee7 100644 --- a/tests/unit/triggers/check-suite-failure.test.ts +++ b/tests/unit/triggers/check-suite-failure.test.ts @@ -60,17 +60,6 @@ describe('CheckSuiteFailureTrigger', () => { vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); - describe('resolveAgentType', () => { - it('returns respond-to-ci', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-ci'); - }); - }); - describe('matches', () => { it('matches completed check suite with failure conclusion and PRs', () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index d8c977db..adb31956 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -62,17 +62,6 @@ describe('CheckSuiteSuccessTrigger', () => { vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); - describe('resolveAgentType', () => { - it('returns review', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBe('review'); - }); - }); - describe('matches', () => { it('matches completed check suite with success conclusion and PRs', () => { const ctx: TriggerContext = { @@ -150,7 +139,7 @@ describe('CheckSuiteSuccessTrigger', () => { }); describe('handle', () => { - it('returns review result when PR has Trello URL and all checks pass', async () => { + it('returns review result with waitForChecks flag when PR matches', async () => { vi.mocked(githubClient.getPR).mockResolvedValue({ number: 42, title: 'Test PR', @@ -164,14 +153,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: 'cascade-impl' }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'success' }, - ], - }); const ctx: TriggerContext = { project: mockProject, @@ -183,7 +164,8 @@ describe('CheckSuiteSuccessTrigger', () => { const result = await trigger.handle(ctx); expect(githubClient.getPR).toHaveBeenCalledWith('owner', 'repo', 42); - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalledWith('owner', 'repo', 'sha123'); + // handle() no longer polls checks — it defers to worker via waitForChecks flag + expect(githubClient.getCheckSuiteStatus).not.toHaveBeenCalled(); expect(result).toEqual({ agentType: 'review', agentInput: { @@ -196,6 +178,7 @@ describe('CheckSuiteSuccessTrigger', () => { }, prNumber: 42, workItemId: 'abc123', + waitForChecks: true, }); }); @@ -280,153 +263,6 @@ describe('CheckSuiteSuccessTrigger', () => { expect(githubClient.getCheckSuiteStatus).not.toHaveBeenCalled(); }); - it('returns null immediately when checks have genuine failures (no retry)', async () => { - vi.mocked(githubClient.getPR).mockResolvedValue({ - number: 42, - title: 'Test PR', - body: 'https://trello.com/c/abc123', - state: 'open', - headRef: 'feature/test', - headSha: 'sha123', - baseRef: 'main', - merged: false, - htmlUrl: 'https://github.com/owner/repo/pull/42', - user: { login: 'cascade-impl' }, - }); - vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: false, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'failure' }, - ], - }); - - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: makeCheckSuitePayload(), - personaIdentities: mockPersonaIdentities, - }; - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); - // Should NOT retry — all checks completed, one genuinely failed - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalledTimes(1); - }); - - it('retries when checks are still in-progress, then succeeds', async () => { - vi.useFakeTimers(); - - vi.mocked(githubClient.getPR).mockResolvedValue({ - number: 42, - title: 'Test PR', - body: 'https://trello.com/c/abc123', - state: 'open', - headRef: 'feature/test', - headSha: 'sha123', - baseRef: 'main', - merged: false, - htmlUrl: 'https://github.com/owner/repo/pull/42', - user: { login: 'cascade-impl' }, - }); - vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus) - .mockResolvedValueOnce({ - allPassing: false, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'in_progress', conclusion: null }, - ], - }) - .mockResolvedValueOnce({ - allPassing: true, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'success' }, - ], - }); - - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: makeCheckSuitePayload(), - personaIdentities: mockPersonaIdentities, - }; - - const handlePromise = trigger.handle(ctx); - - // Advance past the retry delay - await vi.advanceTimersByTimeAsync(10_000); - - const result = await handlePromise; - - expect(result).not.toBeNull(); - expect(result?.agentType).toBe('review'); - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); - - it('retries when checks are in-progress but eventually all fail', async () => { - vi.useFakeTimers(); - - vi.mocked(githubClient.getPR).mockResolvedValue({ - number: 42, - title: 'Test PR', - body: 'https://trello.com/c/abc123', - state: 'open', - headRef: 'feature/test', - headSha: 'sha123', - baseRef: 'main', - merged: false, - htmlUrl: 'https://github.com/owner/repo/pull/42', - user: { login: 'cascade-impl' }, - }); - vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus) - .mockResolvedValueOnce({ - allPassing: false, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'in_progress', conclusion: null }, - ], - }) - .mockResolvedValueOnce({ - allPassing: false, - totalCount: 2, - checkRuns: [ - { name: 'lint', status: 'completed', conclusion: 'success' }, - { name: 'test', status: 'completed', conclusion: 'failure' }, - ], - }); - - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: makeCheckSuitePayload(), - personaIdentities: mockPersonaIdentities, - }; - - const handlePromise = trigger.handle(ctx); - - // Advance past the retry delay - await vi.advanceTimersByTimeAsync(10_000); - - const result = await handlePromise; - - expect(result).toBeNull(); - // 1 initial + 1 retry (then stops because all completed) - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); - it('returns null when PR was already reviewed by reviewer persona at current HEAD', async () => { vi.mocked(githubClient.getPR).mockResolvedValue({ number: 42, @@ -487,11 +323,6 @@ describe('CheckSuiteSuccessTrigger', () => { commitId: 'old-sha', }, ]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProject, @@ -504,7 +335,7 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('review'); - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalled(); + expect(result?.waitForChecks).toBe(true); }); it('skips when latest of multiple reviews covers current HEAD', async () => { @@ -591,11 +422,6 @@ describe('CheckSuiteSuccessTrigger', () => { commitId: 'sha123', }, ]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProject, @@ -608,7 +434,7 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('review'); - expect(githubClient.getCheckSuiteStatus).toHaveBeenCalled(); + expect(result?.waitForChecks).toBe(true); }); it('proceeds when PR has reviews from other users only', async () => { @@ -634,11 +460,6 @@ describe('CheckSuiteSuccessTrigger', () => { commitId: 'sha123', }, ]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProject, @@ -651,6 +472,7 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('review'); + expect(result?.waitForChecks).toBe(true); }); it('fires without work item when PR body has no work item reference', async () => { @@ -667,11 +489,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: 'cascade-impl' }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProject, @@ -685,6 +502,7 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result).not.toBeNull(); expect(result?.workItemId).toBeUndefined(); expect(result?.agentInput.cardId).toBeUndefined(); + expect(result?.waitForChecks).toBe(true); }); it('uses DB lookup result over PR body extraction', async () => { @@ -702,11 +520,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: 'cascade-impl' }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProject, @@ -719,6 +532,7 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result).not.toBeNull(); expect(result?.workItemId).toBe('db-work-item'); + expect(result?.waitForChecks).toBe(true); }); }); @@ -794,11 +608,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: 'external-contributor' }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); const ctx: TriggerContext = { project: mockProjectExternalOnly, @@ -854,11 +663,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: authorLogin }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); }; // Implementer PR @@ -900,11 +704,6 @@ describe('CheckSuiteSuccessTrigger', () => { user: { login: 'cascade-impl' }, }); vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); - vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ - allPassing: true, - totalCount: 1, - checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], - }); // mockProject has no github triggers — resolves to legacy defaults (ownPrsOnly=true) const ctx: TriggerContext = { diff --git a/tests/unit/triggers/github-pr-comment-mention.test.ts b/tests/unit/triggers/github-pr-comment-mention.test.ts index d04dd523..3ca5dc66 100644 --- a/tests/unit/triggers/github-pr-comment-mention.test.ts +++ b/tests/unit/triggers/github-pr-comment-mention.test.ts @@ -162,13 +162,6 @@ describe('PRCommentMentionTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns respond-to-pr-comment', () => { - const ctx = buildCtx(); - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-pr-comment'); - }); - }); - describe('matches', () => { it('matches issue_comment.created on a PR', () => { expect(trigger.matches(buildCtx())).toBe(true); diff --git a/tests/unit/triggers/jira-comment-mention.test.ts b/tests/unit/triggers/jira-comment-mention.test.ts index 7af32e37..3356762c 100644 --- a/tests/unit/triggers/jira-comment-mention.test.ts +++ b/tests/unit/triggers/jira-comment-mention.test.ts @@ -124,13 +124,6 @@ describe('JiraCommentMentionTrigger', () => { vi.restoreAllMocks(); }); - describe('resolveAgentType', () => { - it('returns respond-to-planning-comment', () => { - const ctx = buildCtx(); - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-planning-comment'); - }); - }); - describe('matches', () => { it('matches comment_created from jira source', () => { expect(trigger.matches(buildCtx({ webhookEvent: 'comment_created' }))).toBe(true); diff --git a/tests/unit/triggers/jira-issue-transitioned.test.ts b/tests/unit/triggers/jira-issue-transitioned.test.ts index dc962132..c0e95d0a 100644 --- a/tests/unit/triggers/jira-issue-transitioned.test.ts +++ b/tests/unit/triggers/jira-issue-transitioned.test.ts @@ -104,55 +104,6 @@ describe('JiraIssueTransitionedTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns implementation for "To Do" transition', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], - }); - expect(trigger.resolveAgentType(ctx)).toBe('implementation'); - }); - - it('returns briefing for "Briefing" transition', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Briefing' }], - }); - expect(trigger.resolveAgentType(ctx)).toBe('briefing'); - }); - - it('returns planning for "Planning" transition', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Briefing', toString: 'Planning' }], - }); - expect(trigger.resolveAgentType(ctx)).toBe('planning'); - }); - - it('returns null for unmapped status', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'To Do', toString: 'Done' }], - }); - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - - it('returns null when no status change in changelog', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'assignee' }], - }); - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - - it('returns null when JIRA config is missing', () => { - const ctx = buildCtx({ noJiraConfig: true }); - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - - it('is case insensitive', () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'briefing' }], - }); - expect(trigger.resolveAgentType(ctx)).toBe('briefing'); - }); - }); - describe('handle', () => { it('returns implementation agent for "To Do" transition', async () => { const ctx = buildCtx({ diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 31691c8e..0abe529b 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -94,34 +94,6 @@ function buildCtx(overrides: { } describe('JiraReadyToProcessLabelTrigger', () => { - describe('resolveAgentType()', () => { - it('returns briefing when issue is in Briefing status', () => { - expect(trigger.resolveAgentType(buildCtx({ statusName: 'Briefing' }))).toBe('briefing'); - }); - - it('returns planning when issue is in Planning status', () => { - expect(trigger.resolveAgentType(buildCtx({ statusName: 'Planning' }))).toBe('planning'); - }); - - it('returns implementation when issue is in To Do status', () => { - expect(trigger.resolveAgentType(buildCtx({ statusName: 'To Do' }))).toBe('implementation'); - }); - - it('returns null when issue is in unmapped status', () => { - expect(trigger.resolveAgentType(buildCtx({ statusName: 'Done' }))).toBeNull(); - }); - - it('returns null when issue has no status', () => { - const ctx = buildCtx({ statusName: undefined }); - // Override payload to have no status field - const payload = ctx.payload as Record; - const issue = payload.issue as Record; - const fields = issue.fields as Record; - fields.status = undefined; - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - }); - describe('matches()', () => { it('matches when cascade-ready label is added', () => { expect(trigger.matches(buildCtx({}))).toBe(true); diff --git a/tests/unit/triggers/label-added.test.ts b/tests/unit/triggers/label-added.test.ts index 3a1e1578..3c9b83c6 100644 --- a/tests/unit/triggers/label-added.test.ts +++ b/tests/unit/triggers/label-added.test.ts @@ -138,30 +138,6 @@ describe('ReadyToProcessLabelTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns null (requires API call to determine list)', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'trello', - payload: { - model: { id: 'board123', name: 'Board' }, - action: { - id: 'action1', - idMemberCreator: 'member1', - type: 'addLabelToCard', - date: '2024-01-01', - data: { - card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, - label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, - }, - }, - }, - }; - - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - }); - describe('handle', () => { it('returns briefing agent when card is in briefing list', async () => { mockGetCard.mockResolvedValue({ @@ -303,7 +279,7 @@ describe('ReadyToProcessLabelTrigger', () => { expect(result.agentType).toBe('briefing'); }); - it('throws when card ID is missing', async () => { + it('returns null when card ID is missing', async () => { const ctx: TriggerContext = { project: mockProject, source: 'trello', @@ -321,7 +297,8 @@ describe('ReadyToProcessLabelTrigger', () => { }, }; - await expect(trigger.handle(ctx)).rejects.toThrow('No card ID in payload'); + const result = await trigger.handle(ctx); + expect(result).toBeNull(); }); }); }); diff --git a/tests/unit/triggers/pr-merged.test.ts b/tests/unit/triggers/pr-merged.test.ts index 1fa0ff82..6337f232 100644 --- a/tests/unit/triggers/pr-merged.test.ts +++ b/tests/unit/triggers/pr-merged.test.ts @@ -82,17 +82,6 @@ describe('PRMergedTrigger', () => { vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); - describe('resolveAgentType', () => { - it('returns null (no agent)', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - }); - describe('matches', () => { it('matches when PR is closed', () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index b44471cc..eb4e9229 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -169,17 +169,6 @@ describe('PROpenedTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns respond-to-review', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-review'); - }); - }); - describe('handle', () => { it('returns result when PR body has Trello URL', async () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/pr-ready-to-merge.test.ts b/tests/unit/triggers/pr-ready-to-merge.test.ts index 403ed431..dc5bf582 100644 --- a/tests/unit/triggers/pr-ready-to-merge.test.ts +++ b/tests/unit/triggers/pr-ready-to-merge.test.ts @@ -83,17 +83,6 @@ describe('PRReadyToMergeTrigger', () => { vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); }); - describe('resolveAgentType', () => { - it('returns null (no agent)', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBeNull(); - }); - }); - describe('matches', () => { it('matches check_suite completed with success and PRs', () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/pr-review-submitted.test.ts b/tests/unit/triggers/pr-review-submitted.test.ts index c0291091..3b3ab7c7 100644 --- a/tests/unit/triggers/pr-review-submitted.test.ts +++ b/tests/unit/triggers/pr-review-submitted.test.ts @@ -137,17 +137,6 @@ describe('PRReviewSubmittedTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns respond-to-review', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-review'); - }); - }); - describe('handle', () => { it('returns respond-to-review result when reviewer persona posts changes_requested', async () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/registry.test.ts b/tests/unit/triggers/registry.test.ts index 04194812..da43cc05 100644 --- a/tests/unit/triggers/registry.test.ts +++ b/tests/unit/triggers/registry.test.ts @@ -23,7 +23,6 @@ describe('TriggerRegistry', () => { name: 'test-handler', description: 'Test handler', matches: (ctx) => ctx.source === 'trello', - resolveAgentType: () => 'briefing', handle: vi.fn().mockResolvedValue({ agentType: 'briefing', agentInput: { cardId: 'card123' }, @@ -52,7 +51,6 @@ describe('TriggerRegistry', () => { name: 'trello-only', description: 'Only matches trello', matches: (ctx) => ctx.source === 'trello', - resolveAgentType: () => 'briefing', handle: vi.fn(), }; @@ -77,7 +75,6 @@ describe('TriggerRegistry', () => { name: 'to-remove', description: 'Will be removed', matches: () => true, - resolveAgentType: () => 'briefing', handle: vi.fn(), }; @@ -88,113 +85,29 @@ describe('TriggerRegistry', () => { expect(removed).toBe(true); expect(registry.getHandlers()).toHaveLength(0); }); -}); - -describe('TriggerRegistry.matchTrigger', () => { - const mockProject = { - id: 'test', - name: 'Test', - repo: 'owner/repo', - baseBranch: 'main', - branchPrefix: 'feature/', - trello: { - boardId: 'board123', - lists: { todo: 'list1' }, - labels: {}, - }, - }; - - it('returns name and agentType for the first matching handler', () => { - const registry = createTriggerRegistry(); - - const handler: TriggerHandler = { - name: 'card-moved-to-todo', - description: 'Card moved to TODO', - matches: (ctx) => ctx.source === 'trello', - resolveAgentType: () => 'implementation', - handle: vi.fn(), - }; - - registry.register(handler); - - const ctx: TriggerContext = { - project: mockProject, - source: 'trello', - payload: {}, - }; - - const result = registry.matchTrigger(ctx); - - expect(result).toEqual({ name: 'card-moved-to-todo', agentType: 'implementation' }); - }); - it('returns null when no handler matches', () => { + it('calls first matching handler and returns its result', async () => { const registry = createTriggerRegistry(); - const handler: TriggerHandler = { - name: 'trello-only', - description: 'Only matches trello', - matches: (ctx) => ctx.source === 'trello', - resolveAgentType: () => 'briefing', - handle: vi.fn(), - }; - - registry.register(handler); - - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - - expect(registry.matchTrigger(ctx)).toBeNull(); - }); - - it('skips handlers that match but return null from resolveAgentType', () => { - const registry = createTriggerRegistry(); - - const nullHandler: TriggerHandler = { - name: 'label-added', - description: 'Label added (cannot resolve)', - matches: () => true, - resolveAgentType: () => null, - handle: vi.fn(), - }; - - const validHandler: TriggerHandler = { - name: 'card-moved', - description: 'Card moved', + const handler1: TriggerHandler = { + name: 'first', + description: 'First', matches: () => true, - resolveAgentType: () => 'planning', - handle: vi.fn(), - }; - - registry.register(nullHandler); - registry.register(validHandler); - - const ctx: TriggerContext = { - project: mockProject, - source: 'trello', - payload: {}, + handle: vi.fn().mockResolvedValue({ + agentType: 'briefing', + agentInput: {}, + }), }; - const result = registry.matchTrigger(ctx); - - expect(result).toEqual({ name: 'card-moved', agentType: 'planning' }); - }); - - it('returns null when all matching handlers return null from resolveAgentType', () => { - const registry = createTriggerRegistry(); - - const handler: TriggerHandler = { - name: 'label-added', - description: 'Label added (cannot resolve)', + const handler2: TriggerHandler = { + name: 'second', + description: 'Second', matches: () => true, - resolveAgentType: () => null, handle: vi.fn(), }; - registry.register(handler); + registry.register(handler1); + registry.register(handler2); const ctx: TriggerContext = { project: mockProject, @@ -202,51 +115,31 @@ describe('TriggerRegistry.matchTrigger', () => { payload: {}, }; - expect(registry.matchTrigger(ctx)).toBeNull(); - }); - - it('does not call handle() — pure matching only', () => { - const registry = createTriggerRegistry(); - - const handleMock = vi.fn(); - const handler: TriggerHandler = { - name: 'test', - description: 'Test', - matches: () => true, - resolveAgentType: () => 'review', - handle: handleMock, - }; - - registry.register(handler); - - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - - registry.matchTrigger(ctx); + const result = await registry.dispatch(ctx); - expect(handleMock).not.toHaveBeenCalled(); + expect(result?.agentType).toBe('briefing'); + expect(handler1.handle).toHaveBeenCalledWith(ctx); + expect(handler2.handle).not.toHaveBeenCalled(); }); - it('returns first match when multiple handlers match', () => { + it('skips handler when handle() returns null and tries next', async () => { const registry = createTriggerRegistry(); const handler1: TriggerHandler = { - name: 'first', - description: 'First', + name: 'no-match', + description: 'Returns null from handle', matches: () => true, - resolveAgentType: () => 'briefing', - handle: vi.fn(), + handle: vi.fn().mockResolvedValue(null), }; const handler2: TriggerHandler = { - name: 'second', - description: 'Second', + name: 'real-match', + description: 'Returns a result', matches: () => true, - resolveAgentType: () => 'planning', - handle: vi.fn(), + handle: vi.fn().mockResolvedValue({ + agentType: 'planning', + agentInput: {}, + }), }; registry.register(handler1); @@ -258,8 +151,10 @@ describe('TriggerRegistry.matchTrigger', () => { payload: {}, }; - const result = registry.matchTrigger(ctx); + const result = await registry.dispatch(ctx); - expect(result).toEqual({ name: 'first', agentType: 'briefing' }); + expect(result?.agentType).toBe('planning'); + expect(handler1.handle).toHaveBeenCalled(); + expect(handler2.handle).toHaveBeenCalled(); }); }); diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index 2dcbb6e3..6539ca05 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -75,17 +75,6 @@ describe('ReviewRequestedTrigger', () => { sender: { login: 'author' }, }); - describe('resolveAgentType', () => { - it('returns review', () => { - const ctx: TriggerContext = { - project: mockProject, - source: 'github', - payload: {}, - }; - expect(trigger.resolveAgentType(ctx)).toBe('review'); - }); - }); - describe('matches', () => { it('does not match by default (opt-in trigger, disabled without config)', () => { const ctx: TriggerContext = { diff --git a/tests/unit/triggers/trello-comment-mention.test.ts b/tests/unit/triggers/trello-comment-mention.test.ts index b8a8fb66..1261b223 100644 --- a/tests/unit/triggers/trello-comment-mention.test.ts +++ b/tests/unit/triggers/trello-comment-mention.test.ts @@ -132,13 +132,6 @@ describe('TrelloCommentMentionTrigger', () => { }); }); - describe('resolveAgentType', () => { - it('returns respond-to-planning-comment', () => { - const ctx = buildCtx(); - expect(trigger.resolveAgentType(ctx)).toBe('respond-to-planning-comment'); - }); - }); - describe('handle', () => { it('returns respond-to-planning-comment result when @mention is present on PLANNING card', async () => { const result = await trigger.handle(buildCtx());