From 1f2f8c2cb088fea97f2979846e3c4542da8c76d4 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 20:19:47 +0000 Subject: [PATCH 1/8] feat(trello): add required label filter for webhook processing --- src/db/repositories/configMapper.ts | 3 + src/pm/config.ts | 1 + src/router/adapters/trello.ts | 18 +++- src/router/config.ts | 2 + src/router/trello.ts | 20 +++++ tests/unit/router/adapters/trello.test.ts | 86 ++++++++++++++++++- tests/unit/router/trello.test.ts | 69 +++++++++++++++ .../components/projects/pm-wizard-hooks.ts | 1 + .../components/projects/pm-wizard-state.ts | 8 ++ .../projects/pm-wizard-trello-steps.tsx | 34 ++++++++ 10 files changed, 240 insertions(+), 2 deletions(-) diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 472887aa..bbe693e7 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -17,6 +17,7 @@ export interface TrelloIntegrationConfig { lists: Record; labels: Record; customFields?: { cost?: string }; + requiredLabelId?: string; } export interface JiraIntegrationConfig { @@ -98,6 +99,7 @@ export interface ProjectConfigRaw { lists: Record; labels: Record; customFields?: { cost?: string }; + requiredLabelId?: string; }; jira?: { projectKey: string; @@ -171,6 +173,7 @@ function buildTrelloConfig(config: TrelloIntegrationConfig): ProjectConfigRaw['t lists: config.lists, labels: config.labels, customFields: config.customFields, + requiredLabelId: config.requiredLabelId, }; } diff --git a/src/pm/config.ts b/src/pm/config.ts index 00f1c20e..7fdd14a4 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -14,6 +14,7 @@ export interface TrelloConfig { lists: Record; labels: Record; customFields?: { cost?: string }; + requiredLabelId?: string; } /** JIRA-specific configuration (from project_integrations JSONB) */ diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index 88a4b8f7..56ffe712 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -20,6 +20,7 @@ import { resolveTrelloCredentials } from '../platformClients/index.js'; import type { CascadeJob, TrelloJob } from '../queue.js'; import { sendAcknowledgeReaction } from '../reactions.js'; import { + checkCardHasRequiredLabel, isAgentLogAttachmentUploaded, isCardInTriggerList, isReadyToProcessLabelAdded, @@ -125,7 +126,22 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { } const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; - return withTrelloCredentials(trelloCreds, () => triggerRegistry.dispatch(ctx)); + return withTrelloCredentials(trelloCreds, async () => { + if (project.trello?.requiredLabelId && _event.workItemId) { + const hasLabel = await checkCardHasRequiredLabel( + _event.workItemId, + project.trello.requiredLabelId, + ); + if (!hasLabel) { + logger.info('Card lacks required label, skipping dispatch', { + cardId: _event.workItemId, + requiredLabelId: project.trello.requiredLabelId, + }); + return null; + } + } + return triggerRegistry.dispatch(ctx); + }); } async postAck( diff --git a/src/router/config.ts b/src/router/config.ts index 83029e50..c4e56f55 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -11,6 +11,7 @@ export interface RouterProjectConfig { boardId: string; lists: Record; labels: Record; + requiredLabelId?: string; }; jira?: { projectKey: string; @@ -93,6 +94,7 @@ export async function loadProjectConfig(): Promise<{ boardId: trelloConfig.boardId, lists: trelloConfig.lists, labels: trelloConfig.labels, + requiredLabelId: trelloConfig.requiredLabelId, }, }), ...(jiraConfig && { diff --git a/src/router/trello.ts b/src/router/trello.ts index 2cfee02d..b917a19e 100644 --- a/src/router/trello.ts +++ b/src/router/trello.ts @@ -5,6 +5,7 @@ * whether a Trello webhook event is processable and whether it was self-authored. */ +import { trelloClient } from '../trello/client.js'; import { logger } from '../utils/logging.js'; import { resolveTrelloBotMemberId } from './acknowledgments.js'; import type { RouterProjectConfig } from './config.js'; @@ -93,6 +94,25 @@ export function isAgentLogAttachmentUploaded( return false; } +/** + * Check whether a Trello card has the required label. + * + * Returns `true` when: + * - `requiredLabelId` is falsy (no filter configured), OR + * - the card's labels include an entry with `id === requiredLabelId` + * + * Must be called inside a `withTrelloCredentials` scope so the Trello API + * client is configured with the correct credentials. + */ +export async function checkCardHasRequiredLabel( + cardId: string, + requiredLabelId: string | undefined, +): Promise { + if (!requiredLabelId) return true; + const card = await trelloClient.getCard(cardId); + return card.labels.some((l) => l.id === requiredLabelId); +} + export async function isSelfAuthoredTrelloComment( payload: unknown, projectId: string, diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index 57e2945a..a6ab36da 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -42,6 +42,7 @@ vi.mock('../../../../src/router/trello.js', () => ({ isCardInTriggerList: vi.fn().mockReturnValue(false), isReadyToProcessLabelAdded: vi.fn().mockReturnValue(false), isSelfAuthoredTrelloComment: vi.fn().mockResolvedValue(false), + checkCardHasRequiredLabel: vi.fn().mockResolvedValue(true), })); import { postTrelloAck } from '../../../../src/router/acknowledgments.js'; @@ -50,7 +51,11 @@ import type { RouterProjectConfig } from '../../../../src/router/config.js'; import { loadProjectConfig } from '../../../../src/router/config.js'; import { resolveTrelloCredentials } from '../../../../src/router/platformClients/index.js'; import { sendAcknowledgeReaction } from '../../../../src/router/reactions.js'; -import { isCardInTriggerList, isSelfAuthoredTrelloComment } from '../../../../src/router/trello.js'; +import { + checkCardHasRequiredLabel, + isCardInTriggerList, + isSelfAuthoredTrelloComment, +} from '../../../../src/router/trello.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; @@ -312,6 +317,85 @@ describe('TrelloRouterAdapter', () => { expect(result).toBeNull(); expect(mockTriggerRegistry.dispatch).not.toHaveBeenCalled(); }); + + it('dispatches when card has the required label', async () => { + const projectWithLabel: RouterProjectConfig = { + ...mockProject, + trello: { ...mockProject.trello!, requiredLabelId: 'label-required' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectWithLabel], + fullProjects: [{ id: 'p1' } as never], + }); + vi.mocked(checkCardHasRequiredLabel).mockResolvedValueOnce(true); + vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValue({ + agentType: 'implementation', + agentInput: { workItemId: 'card1' }, + } as never); + + const result = await adapter.dispatchWithCredentials( + { + projectIdentifier: 'board1', + eventType: 'updateCard', + workItemId: 'card1', + isCommentEvent: false, + }, + {}, + projectWithLabel, + mockTriggerRegistry, + ); + expect(checkCardHasRequiredLabel).toHaveBeenCalledWith('card1', 'label-required'); + expect(result?.agentType).toBe('implementation'); + }); + + it('returns null and skips dispatch when card lacks required label', async () => { + const projectWithLabel: RouterProjectConfig = { + ...mockProject, + trello: { ...mockProject.trello!, requiredLabelId: 'label-required' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectWithLabel], + fullProjects: [{ id: 'p1' } as never], + }); + vi.mocked(checkCardHasRequiredLabel).mockResolvedValueOnce(false); + vi.mocked(mockTriggerRegistry.dispatch).mockClear(); + + const result = await adapter.dispatchWithCredentials( + { + projectIdentifier: 'board1', + eventType: 'updateCard', + workItemId: 'card1', + isCommentEvent: false, + }, + {}, + projectWithLabel, + mockTriggerRegistry, + ); + expect(checkCardHasRequiredLabel).toHaveBeenCalledWith('card1', 'label-required'); + expect(result).toBeNull(); + expect(mockTriggerRegistry.dispatch).not.toHaveBeenCalled(); + }); + + it('does not call checkCardHasRequiredLabel when no requiredLabelId configured', async () => { + vi.mocked(checkCardHasRequiredLabel).mockClear(); + vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValue({ + agentType: 'implementation', + agentInput: {}, + } as never); + + await adapter.dispatchWithCredentials( + { + projectIdentifier: 'board1', + eventType: 'updateCard', + workItemId: 'card1', + isCommentEvent: false, + }, + {}, + mockProject, // no requiredLabelId + mockTriggerRegistry, + ); + expect(checkCardHasRequiredLabel).not.toHaveBeenCalled(); + }); }); describe('postAck - additional paths', () => { diff --git a/tests/unit/router/trello.test.ts b/tests/unit/router/trello.test.ts index 1812caab..804df865 100644 --- a/tests/unit/router/trello.test.ts +++ b/tests/unit/router/trello.test.ts @@ -13,15 +13,23 @@ vi.mock('../../../src/router/acknowledgments.js', () => ({ resolveTrelloBotMemberId: vi.fn(), })); +vi.mock('../../../src/trello/client.js', () => ({ + trelloClient: { + getCard: vi.fn(), + }, +})); + import { resolveTrelloBotMemberId } from '../../../src/router/acknowledgments.js'; import type { RouterProjectConfig } from '../../../src/router/config.js'; import { + checkCardHasRequiredLabel, isAgentLogAttachmentUploaded, isAgentLogFilename, isCardInTriggerList, isReadyToProcessLabelAdded, isSelfAuthoredTrelloComment, } from '../../../src/router/trello.js'; +import { trelloClient } from '../../../src/trello/client.js'; const mockProject: RouterProjectConfig = { id: 'p1', @@ -165,6 +173,67 @@ describe('isAgentLogAttachmentUploaded', () => { }); }); +describe('checkCardHasRequiredLabel', () => { + it('returns true when no requiredLabelId is set (falsy)', async () => { + const result = await checkCardHasRequiredLabel('card1', undefined); + expect(result).toBe(true); + expect(trelloClient.getCard).not.toHaveBeenCalled(); + }); + + it('returns true when empty string is provided as requiredLabelId', async () => { + const result = await checkCardHasRequiredLabel('card1', ''); + expect(result).toBe(true); + expect(trelloClient.getCard).not.toHaveBeenCalled(); + }); + + it('returns true when card has the required label', async () => { + vi.mocked(trelloClient.getCard).mockResolvedValue({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [{ id: 'label-required', name: 'Required', color: 'red' }], + url: 'https://trello.com/c/card1', + pos: 0, + shortUrl: 'https://trello.com/c/card1', + }); + const result = await checkCardHasRequiredLabel('card1', 'label-required'); + expect(result).toBe(true); + expect(trelloClient.getCard).toHaveBeenCalledWith('card1'); + }); + + it('returns false when card does not have the required label', async () => { + vi.mocked(trelloClient.getCard).mockResolvedValue({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [{ id: 'label-other', name: 'Other', color: 'blue' }], + url: 'https://trello.com/c/card1', + pos: 0, + shortUrl: 'https://trello.com/c/card1', + }); + const result = await checkCardHasRequiredLabel('card1', 'label-required'); + expect(result).toBe(false); + expect(trelloClient.getCard).toHaveBeenCalledWith('card1'); + }); + + it('returns false when card has no labels', async () => { + vi.mocked(trelloClient.getCard).mockResolvedValue({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [], + url: 'https://trello.com/c/card1', + pos: 0, + shortUrl: 'https://trello.com/c/card1', + }); + const result = await checkCardHasRequiredLabel('card1', 'label-required'); + expect(result).toBe(false); + }); +}); + describe('isSelfAuthoredTrelloComment', () => { it('returns true when comment author matches bot ID', async () => { vi.mocked(resolveTrelloBotMemberId).mockResolvedValue('bot-id'); diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 591ea0f8..749be152 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -463,6 +463,7 @@ export function useSaveMutation(projectId: string, state: WizardState) { lists: state.trelloListMappings, labels: state.trelloLabelMappings, ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), + ...(state.trelloRequiredLabelId ? { requiredLabelId: state.trelloRequiredLabelId } : {}), }; } else { config = { diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 8be4e6d3..552d346c 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -55,6 +55,7 @@ export interface WizardState { trelloListMappings: Record; trelloLabelMappings: Record; trelloCostFieldId: string; + trelloRequiredLabelId: string; // JIRA mappings jiraStatusMappings: Record; jiraIssueTypes: Record; @@ -86,6 +87,7 @@ export type WizardAction = | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } | { type: 'SET_TRELLO_LABEL_MAPPING'; key: string; value: string } | { type: 'SET_TRELLO_COST_FIELD'; id: string } + | { type: 'SET_TRELLO_REQUIRED_LABEL'; id: string } | { type: 'SET_JIRA_STATUS_MAPPING'; key: string; value: string } | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } | { type: 'SET_JIRA_LABEL'; key: string; value: string } @@ -129,6 +131,7 @@ export function createInitialState(): WizardState { trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', jiraStatusMappings: {}, jiraIssueTypes: {}, jiraLabels: { ...INITIAL_JIRA_LABELS }, @@ -191,6 +194,7 @@ export const wizardReducer: Reducer = (state, action) trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', }; case 'SET_JIRA_PROJECTS': return { ...state, jiraProjects: action.projects }; @@ -219,6 +223,8 @@ export const wizardReducer: Reducer = (state, action) }; case 'SET_TRELLO_COST_FIELD': return { ...state, trelloCostFieldId: action.id }; + case 'SET_TRELLO_REQUIRED_LABEL': + return { ...state, trelloRequiredLabelId: action.id }; case 'SET_JIRA_STATUS_MAPPING': return { ...state, @@ -303,6 +309,8 @@ export function buildEditState( const cf = initialConfig.customFields as Record | undefined; editState.trelloCostFieldId = cf?.cost ?? ''; + editState.trelloRequiredLabelId = (initialConfig.requiredLabelId as string) ?? ''; + editState.hasStoredCredentials = configuredKeys.has('TRELLO_API_KEY') && configuredKeys.has('TRELLO_TOKEN'); } else if (provider === 'jira') { diff --git a/web/src/components/projects/pm-wizard-trello-steps.tsx b/web/src/components/projects/pm-wizard-trello-steps.tsx index 22aa072b..b494642b 100644 --- a/web/src/components/projects/pm-wizard-trello-steps.tsx +++ b/web/src/components/projects/pm-wizard-trello-steps.tsx @@ -383,6 +383,40 @@ export function TrelloFieldMappingStep({ )} + {/* Required Label (optional) */} +
+ +

+ When set, only cards carrying this label will trigger CASCADE. Leave blank to process all + cards. +

+ {state.trelloBoardDetails ? ( + l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + }))} + value={state.trelloRequiredLabelId} + onChange={(v) => dispatch({ type: 'SET_TRELLO_REQUIRED_LABEL', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_TRELLO_REQUIRED_LABEL', + id: e.target.value, + }) + } + placeholder="Label ID to filter by (optional)" + /> + )} +
+ {/* Cost custom field */}
From 7c953af59adab4f4b9ec845ad3e9f604a1323e23 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 20:24:57 +0000 Subject: [PATCH 2/8] fix(tests): fix pre-existing test failures from GitLab integration commit - Add gitlabOnly: false to webhook CLI test expectations (create/delete) - Add GITHUB_TOKEN_IMPLEMENTER and GITLAB_TOKEN_IMPLEMENTER env var clearing in credential-scoping test beforeEach to prevent cross-test contamination - Add withGitLabToken mock to credential-scoping test Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/cli/credential-scoping.test.ts | 6 ++++++ tests/unit/cli/dashboard/webhooks/webhooks.test.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index f1626e6f..da9da174 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -4,6 +4,10 @@ vi.mock('../../../src/github/client.js', () => ({ withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), })); +vi.mock('../../../src/gitlab/client.js', () => ({ + withGitLabToken: vi.fn((_token: string, fn: () => Promise, _host?: string) => fn()), +})); + vi.mock('../../../src/trello/client.js', () => ({ withTrelloCredentials: vi.fn( (_creds: { apiKey: string; token: string }, fn: () => Promise) => fn(), @@ -69,6 +73,8 @@ describe('CredentialScopedCommand', () => { beforeEach(() => { process.env = { ...originalEnv }; process.env.GITHUB_TOKEN = undefined; + process.env.GITHUB_TOKEN_IMPLEMENTER = undefined; + process.env.GITLAB_TOKEN_IMPLEMENTER = undefined; process.env.TRELLO_API_KEY = undefined; process.env.TRELLO_TOKEN = undefined; }); diff --git a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts index 4879cf7f..5e12f391 100644 --- a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts +++ b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts @@ -163,6 +163,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: undefined, }); }); @@ -182,6 +183,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: 'https://cascade.example.com', trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: undefined, }); }); @@ -201,6 +203,7 @@ describe('WebhooksCreate (webhooks create)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, }); }); @@ -242,6 +245,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: undefined, }); }); @@ -261,6 +265,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: 'https://cascade.example.com', trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: undefined, }); }); @@ -280,6 +285,7 @@ describe('WebhooksDelete (webhooks delete)', () => { callbackBaseUrl: baseConfig.serverUrl, trelloOnly: false, githubOnly: false, + gitlabOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, }); }); From 58294cb91d0139f54eaf12f798baa79869b0d5e5 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 20:31:54 +0000 Subject: [PATCH 3/8] fix(security): override axios to >=1.15.0 to resolve critical SSRF vulnerability Adds an npm override to force axios transitive dependency (from jira.js and trello.js) to >=1.15.0, fixing CVE GHSA-3p68-rc4w-qgx5 (NO_PROXY hostname normalization bypass leading to SSRF). Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 17 +++++++++++------ package.json | 3 ++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index eefe6eb0..46157bcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4915,14 +4915,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/b4a": { @@ -9513,8 +9513,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { "version": "3.0.3", diff --git a/package.json b/package.json index 5e7e518d..9df43b61 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "overrides": { "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "brace-expansion": "^2.0.3" + "brace-expansion": "^2.0.3", + "axios": ">=1.15.0" } } From 5f21e998560c131e43eeea2e1b5634fc9232d2e9 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 22:56:53 +0000 Subject: [PATCH 4/8] fix(trello): route webhooks to all matching projects for shared boards When multiple projects share the same Trello board and use requiredLabelId to distinguish which cards belong to each project, the previous code only checked the first matching project (via find()), causing bdgt-labeled cards to be dispatched to the cascade project when cascade was first in the list. Add resolveAllProjects() to RouterPlatformAdapter (optional) and implement it in TrelloRouterAdapter to return all projects matching a board ID. Update processRouterWebhook to iterate over all candidate projects and dispatch to the first one whose label check passes, using that matched project for postAck, buildJob, and job enqueue so the correct projectId flows through the entire pipeline. Co-Authored-By: Claude Sonnet 4.6 --- src/router/adapters/trello.ts | 5 + src/router/platform-adapter.ts | 12 +++ src/router/webhook-processor.ts | 59 ++++++++--- tests/unit/router/adapters/trello.test.ts | 42 ++++++++ tests/unit/router/webhook-processor.test.ts | 108 ++++++++++++++++++++ 5 files changed, 212 insertions(+), 14 deletions(-) diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index 56ffe712..265f3675 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -102,6 +102,11 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { return config.projects.find((p) => p.trello?.boardId === event.projectIdentifier) ?? null; } + async resolveAllProjects(event: ParsedWebhookEvent): Promise { + const config = await loadProjectConfig(); + return config.projects.filter((p) => p.trello?.boardId === event.projectIdentifier); + } + async dispatchWithCredentials( _event: ParsedWebhookEvent, payload: unknown, diff --git a/src/router/platform-adapter.ts b/src/router/platform-adapter.ts index 3d672a0e..2f36b70c 100644 --- a/src/router/platform-adapter.ts +++ b/src/router/platform-adapter.ts @@ -89,6 +89,18 @@ export interface RouterPlatformAdapter { */ resolveProject(event: ParsedWebhookEvent): Promise; + /** + * Resolve ALL project configs matching the event's project identifier. + * Used when multiple projects share the same platform identifier (e.g., same Trello board). + * + * When implemented, `processRouterWebhook` calls this instead of `resolveProject` + * and iterates over the returned projects, dispatching to the first one that matches + * (e.g., whose `requiredLabelId` matches the card's labels). + * + * Falls back to `resolveProject` (single project) when not implemented. + */ + resolveAllProjects?(event: ParsedWebhookEvent): Promise; + /** * Run the authoritative trigger dispatch inside platform credential scope. * The adapter wraps `triggerRegistry.dispatch(ctx)` with appropriate diff --git a/src/router/webhook-processor.ts b/src/router/webhook-processor.ts index 46d8f16f..906a1328 100644 --- a/src/router/webhook-processor.ts +++ b/src/router/webhook-processor.ts @@ -11,6 +11,7 @@ */ import type { TriggerRegistry } from '../triggers/registry.js'; +import type { TriggerResult } from '../types/index.js'; import { logger } from '../utils/logging.js'; import { isDuplicateAction, markActionProcessed } from './action-dedup.js'; import { @@ -18,6 +19,7 @@ import { markAgentTypeEnqueued, markRecentlyDispatched, } from './agent-type-lock.js'; +import type { RouterProjectConfig } from './config.js'; import type { RouterPlatformAdapter } from './platform-adapter.js'; import { addJob } from './queue.js'; import { isWorkItemLocked, markWorkItemEnqueued } from './work-item-lock.js'; @@ -94,9 +96,22 @@ export async function processRouterWebhook( // Step 5: Fire acknowledgment reaction (fire-and-forget) adapter.sendReaction(event, payload); - // Step 6: Resolve project config - const project = await adapter.resolveProject(event); - if (!project) { + // Step 6: Resolve project config(s) + // When the adapter implements resolveAllProjects (e.g. Trello, where multiple projects can + // share the same board and are distinguished by requiredLabelId), we collect all candidates + // and try each in turn at dispatch time. For adapters that don't implement it, we fall back + // to the single-project resolveProject path. + const projectsToTry: RouterProjectConfig[] = []; + if (adapter.resolveAllProjects) { + const allProjects = await adapter.resolveAllProjects(event); + projectsToTry.push(...allProjects); + } + if (projectsToTry.length === 0) { + const singleProject = await adapter.resolveProject(event); + if (singleProject) projectsToTry.push(singleProject); + } + + if (projectsToTry.length === 0) { logger.info(`No project config found for ${adapter.type} event`, { projectIdentifier: event.projectIdentifier, }); @@ -106,25 +121,41 @@ export async function processRouterWebhook( }; } - // Step 7: Dispatch triggers with credential scope - let result = null; - try { - result = await adapter.dispatchWithCredentials(event, payload, project, triggerRegistry); - } catch (err) { - logger.warn(`${adapter.type} trigger dispatch failed (non-fatal)`, { - error: String(err), - projectId: project.id, - }); + // Step 7: Dispatch triggers with credential scope — iterate over all candidate projects and + // use the first one whose dispatch returns a non-null result (i.e., whose requiredLabelId + // matches the card, or which has no label filter configured). + let result: TriggerResult | null = null; + let project: RouterProjectConfig | null = null; + + for (const proj of projectsToTry) { + try { + const dispatchResult = await adapter.dispatchWithCredentials( + event, + payload, + proj, + triggerRegistry, + ); + if (dispatchResult !== null) { + result = dispatchResult; + project = proj; + break; + } + } catch (err) { + logger.warn(`${adapter.type} trigger dispatch failed (non-fatal)`, { + error: String(err), + projectId: proj.id, + }); + } } - if (!result) { + if (!result || !project) { logger.info(`No trigger matched for ${adapter.type} event`, { eventType: event.eventType, workItemId: event.workItemId, }); return { shouldProcess: true, - projectId: project.id, + projectId: projectsToTry[0]?.id, decisionReason: 'No trigger matched for event', }; } diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index a6ab36da..ae16c8b6 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -202,6 +202,48 @@ describe('TrelloRouterAdapter', () => { }); }); + describe('resolveAllProjects', () => { + it('returns all projects matching boardId', async () => { + const secondProject: RouterProjectConfig = { + ...mockProject, + id: 'p2', + trello: { ...mockProject.trello!, requiredLabelId: 'label-bdgt' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject, secondProject], + fullProjects: [{ id: 'p1' } as never, { id: 'p2' } as never], + }); + + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'board1', + eventType: 'updateCard', + isCommentEvent: false, + }); + expect(projects).toHaveLength(2); + expect(projects[0].id).toBe('p1'); + expect(projects[1].id).toBe('p2'); + }); + + it('returns empty array for unknown boardId', async () => { + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'unknown-board', + eventType: 'updateCard', + isCommentEvent: false, + }); + expect(projects).toHaveLength(0); + }); + + it('returns single project when only one matches', async () => { + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'board1', + eventType: 'updateCard', + isCommentEvent: false, + }); + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('p1'); + }); + }); + describe('dispatchWithCredentials', () => { it('dispatches to trigger registry', async () => { vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValue({ diff --git a/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index 55109284..5de45098 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -463,4 +463,112 @@ describe('processRouterWebhook', () => { expect(addJob).toHaveBeenCalled(); expect(markWorkItemEnqueued).not.toHaveBeenCalled(); }); + + describe('multi-project routing via resolveAllProjects', () => { + const project1: RouterProjectConfig = { id: 'cascade', repo: 'org/cascade', pmType: 'trello' }; + const project2: RouterProjectConfig = { id: 'bdgt', repo: 'org/bdgt', pmType: 'trello' }; + + it('tries all projects until one dispatches successfully', async () => { + const triggerResult = { agentType: 'implementation', agentInput: {} }; + vi.mocked(addJob).mockResolvedValue('job-1'); + // project1 fails label check (null), project2 succeeds + const dispatchWithCredentials = vi + .fn() + .mockResolvedValueOnce(null) // project1 — label mismatch + .mockResolvedValueOnce(triggerResult); // project2 — label matches + + const adapter = makeMockAdapter({ + resolveAllProjects: vi.fn().mockResolvedValue([project1, project2]), + dispatchWithCredentials, + buildJob: vi.fn().mockReturnValue({ + type: 'trello', + source: 'trello', + payload: {}, + projectId: 'bdgt', + cardId: 'card1', + actionType: 'updateCard', + receivedAt: new Date().toISOString(), + } as CascadeJob), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(true); + expect(result.projectId).toBe('bdgt'); // matched project2 + expect(dispatchWithCredentials).toHaveBeenCalledTimes(2); + expect(dispatchWithCredentials).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + project1, + mockTriggerRegistry, + ); + expect(dispatchWithCredentials).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + project2, + mockTriggerRegistry, + ); + expect(addJob).toHaveBeenCalled(); + }); + + it('passes matched project to postAck and buildJob', async () => { + const triggerResult = { agentType: 'implementation', agentInput: {} }; + vi.mocked(addJob).mockResolvedValue('job-1'); + + const adapter = makeMockAdapter({ + resolveAllProjects: vi.fn().mockResolvedValue([project1, project2]), + dispatchWithCredentials: vi + .fn() + .mockResolvedValueOnce(null) // project1 skipped + .mockResolvedValueOnce(triggerResult), // project2 matched + postAck: vi.fn().mockResolvedValue({ commentId: 'c1', message: 'ack' }), + }); + + await processRouterWebhook(adapter, {}, mockTriggerRegistry); + // postAck and buildJob must receive project2 (the matched project), not project1 + expect(adapter.postAck).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + project2, + 'implementation', + triggerResult, + ); + expect(adapter.buildJob).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + project2, + triggerResult, + expect.anything(), + ); + }); + + it('returns No trigger matched when all projects dispatch null', async () => { + const adapter = makeMockAdapter({ + resolveAllProjects: vi.fn().mockResolvedValue([project1, project2]), + dispatchWithCredentials: vi.fn().mockResolvedValue(null), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('No trigger matched for event'); + expect(addJob).not.toHaveBeenCalled(); + }); + + it('falls back to resolveProject when resolveAllProjects not implemented', async () => { + const triggerResult = { agentType: 'implementation', agentInput: {} }; + vi.mocked(addJob).mockResolvedValue('job-1'); + + const adapter = makeMockAdapter({ + // no resolveAllProjects — falls back to resolveProject + resolveProject: vi.fn().mockResolvedValue(project1), + dispatchWithCredentials: vi.fn().mockResolvedValue(triggerResult), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(true); + expect(result.projectId).toBe('cascade'); + expect(addJob).toHaveBeenCalled(); + }); + }); }); From 3a742c869032321642247f7daff966b1c26ee465 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 08:26:40 +0000 Subject: [PATCH 5/8] fix(trello): pre-filter projects by card labels in resolveAllProjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Trello webhook payload does not include the labels currently assigned to a card — only movement information (listBefore/listAfter). When multiple projects share the same board and are distinguished by requiredLabelId, the previous implementation relied on dispatchWithCredentials (called per-project) to discover a label mismatch. This caused the first project in the list (cascade) to be selected even when the card carried a different project's label. This fix moves the label lookup to resolveAllProjects, which now: - Fetches the card's labels via the Trello API once, before the dispatch loop - Returns only projects whose requiredLabelId matches the card's labels - Falls back to catch-all projects (no requiredLabelId) when no specific match - Returns all candidates if the API call fails (preserving the existing per-project guard in dispatchWithCredentials as a secondary check) Also renames _event → event in dispatchWithCredentials (the parameter is actively read, so the underscore prefix was misleading). Co-Authored-By: Claude Sonnet 4.6 --- src/router/adapters/trello.ts | 73 ++++++++- tests/unit/router/adapters/trello.test.ts | 176 ++++++++++++++++++++-- 2 files changed, 231 insertions(+), 18 deletions(-) diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index 265f3675..ef5990a1 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -7,7 +7,7 @@ * `processRouterWebhook()` function. */ -import { withTrelloCredentials } from '../../trello/client.js'; +import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -102,13 +102,72 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { return config.projects.find((p) => p.trello?.boardId === event.projectIdentifier) ?? null; } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: label pre-filter requires branching over API result, fallback, and empty cases async resolveAllProjects(event: ParsedWebhookEvent): Promise { const config = await loadProjectConfig(); - return config.projects.filter((p) => p.trello?.boardId === event.projectIdentifier); + const candidates = config.projects.filter((p) => p.trello?.boardId === event.projectIdentifier); + + // When multiple projects share the same board and at least one uses a required-label + // filter, fetch the card's labels from the Trello API now — before the dispatch loop — + // so we route to the correct project immediately rather than relying on each + // dispatchWithCredentials call to discover the mismatch. + // + // The Trello webhook payload does NOT include the card's current labels, so an explicit + // API lookup is necessary for correct multi-project routing. + if (event.workItemId && candidates.some((p) => p.trello?.requiredLabelId)) { + for (const proj of candidates) { + const creds = await resolveTrelloCredentials(proj.id); + if (!creds) continue; + + try { + const cardLabelIds = await withTrelloCredentials(creds, async () => { + const card = await trelloClient.getCard(event.workItemId as string); + return card.labels.map((l) => l.id); + }); + + // Return projects whose required label is present on the card. + const labelMatched = candidates.filter( + (p) => p.trello?.requiredLabelId && cardLabelIds.includes(p.trello.requiredLabelId), + ); + if (labelMatched.length > 0) { + logger.info('Pre-filtered projects by card labels', { + cardId: event.workItemId, + matched: labelMatched.map((p) => p.id), + }); + return labelMatched; + } + + // No label-specific match — fall back to projects without a required label (catch-all) + const catchAll = candidates.filter((p) => !p.trello?.requiredLabelId); + if (catchAll.length > 0) { + logger.info('No label-matched project; falling back to catch-all projects', { + cardId: event.workItemId, + catchAll: catchAll.map((p) => p.id), + }); + return catchAll; + } + + // Card has no label that matches any configured project — drop. + logger.info('Card labels do not match any project requiredLabelId, skipping', { + cardId: event.workItemId, + cardLabelIds, + }); + return []; + } catch (err) { + logger.warn( + 'Failed to look up card labels for project pre-filtering, falling back to all candidates', + { cardId: event.workItemId, error: String(err) }, + ); + break; + } + } + } + + return candidates; } async dispatchWithCredentials( - _event: ParsedWebhookEvent, + event: ParsedWebhookEvent, payload: unknown, project: RouterProjectConfig, triggerRegistry: TriggerRegistry, @@ -132,14 +191,16 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; return withTrelloCredentials(trelloCreds, async () => { - if (project.trello?.requiredLabelId && _event.workItemId) { + // Secondary label guard: resolveAllProjects pre-filters by label, but this + // check ensures correctness even when pre-filtering was skipped (e.g. API error). + if (project.trello?.requiredLabelId && event.workItemId) { const hasLabel = await checkCardHasRequiredLabel( - _event.workItemId, + event.workItemId, project.trello.requiredLabelId, ); if (!hasLabel) { logger.info('Card lacks required label, skipping dispatch', { - cardId: _event.workItemId, + cardId: event.workItemId, requiredLabelId: project.trello.requiredLabelId, }); return null; diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index ae16c8b6..c203f98f 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -35,6 +35,17 @@ vi.mock('../../../../src/utils/runLink.js', () => ({ })); vi.mock('../../../../src/trello/client.js', () => ({ withTrelloCredentials: vi.fn().mockImplementation((_creds: unknown, fn: () => unknown) => fn()), + trelloClient: { + getCard: vi.fn().mockResolvedValue({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [], + url: 'https://trello.com/c/card1', + shortUrl: 'https://trello.com/c/card1', + }), + }, })); vi.mock('../../../../src/router/trello.js', () => ({ isAgentLogFilename: vi.fn().mockReturnValue(false), @@ -56,6 +67,7 @@ import { isCardInTriggerList, isSelfAuthoredTrelloComment, } from '../../../../src/router/trello.js'; +import { trelloClient } from '../../../../src/trello/client.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; @@ -203,44 +215,184 @@ describe('TrelloRouterAdapter', () => { }); describe('resolveAllProjects', () => { - it('returns all projects matching boardId', async () => { - const secondProject: RouterProjectConfig = { + it('returns empty array for unknown boardId', async () => { + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'unknown-board', + eventType: 'updateCard', + isCommentEvent: false, + }); + expect(projects).toHaveLength(0); + }); + + it('returns single project when only one matches and no requiredLabelId', async () => { + // No project has requiredLabelId, no label lookup needed + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'board1', + eventType: 'updateCard', + isCommentEvent: false, + }); + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('p1'); + expect(trelloClient.getCard).not.toHaveBeenCalled(); + }); + + it('pre-filters by card labels when multiple projects share a board', async () => { + const projectCascade: RouterProjectConfig = { ...mockProject, - id: 'p2', + id: 'cascade', + trello: { ...mockProject.trello!, requiredLabelId: 'label-cascade' }, + }; + const projectBdgt: RouterProjectConfig = { + ...mockProject, + id: 'bdgt', trello: { ...mockProject.trello!, requiredLabelId: 'label-bdgt' }, }; vi.mocked(loadProjectConfig).mockResolvedValue({ - projects: [mockProject, secondProject], - fullProjects: [{ id: 'p1' } as never, { id: 'p2' } as never], + projects: [projectCascade, projectBdgt], + fullProjects: [{ id: 'cascade' } as never, { id: 'bdgt' } as never], + }); + // Card only has the bdgt label + vi.mocked(trelloClient.getCard).mockResolvedValueOnce({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [{ id: 'label-bdgt', name: 'project:bdgt', color: 'orange' }], + url: 'https://trello.com/c/card1', + shortUrl: 'https://trello.com/c/card1', }); const projects = await adapter.resolveAllProjects({ projectIdentifier: 'board1', eventType: 'updateCard', + workItemId: 'card1', isCommentEvent: false, }); - expect(projects).toHaveLength(2); - expect(projects[0].id).toBe('p1'); - expect(projects[1].id).toBe('p2'); + // Only bdgt should be returned (cascade's label not on card) + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('bdgt'); }); - it('returns empty array for unknown boardId', async () => { + it('returns catch-all projects when card has no label matching any project', async () => { + const projectCascade: RouterProjectConfig = { + ...mockProject, + id: 'cascade', + trello: { ...mockProject.trello!, requiredLabelId: 'label-cascade' }, + }; + const projectBdgt: RouterProjectConfig = { + ...mockProject, + id: 'bdgt', + trello: { ...mockProject.trello!, requiredLabelId: 'label-bdgt' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectCascade, projectBdgt], + fullProjects: [{ id: 'cascade' } as never, { id: 'bdgt' } as never], + }); + // Card has no project-specific labels + vi.mocked(trelloClient.getCard).mockResolvedValueOnce({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [], + url: 'https://trello.com/c/card1', + shortUrl: 'https://trello.com/c/card1', + }); + const projects = await adapter.resolveAllProjects({ - projectIdentifier: 'unknown-board', + projectIdentifier: 'board1', eventType: 'updateCard', + workItemId: 'card1', isCommentEvent: false, }); + // No label match and no catch-all → empty expect(projects).toHaveLength(0); }); - it('returns single project when only one matches', async () => { + it('returns catch-all project when card has no matching label but catch-all exists', async () => { + const projectCatchAll: RouterProjectConfig = { + ...mockProject, + id: 'catch-all', + // no requiredLabelId + }; + const projectBdgt: RouterProjectConfig = { + ...mockProject, + id: 'bdgt', + trello: { ...mockProject.trello!, requiredLabelId: 'label-bdgt' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectCatchAll, projectBdgt], + fullProjects: [{ id: 'catch-all' } as never, { id: 'bdgt' } as never], + }); + // Card has no labels → no specific match → fall back to catch-all + vi.mocked(trelloClient.getCard).mockResolvedValueOnce({ + id: 'card1', + name: 'Test card', + desc: '', + idList: 'list1', + labels: [], + url: 'https://trello.com/c/card1', + shortUrl: 'https://trello.com/c/card1', + }); + + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'board1', + eventType: 'updateCard', + workItemId: 'card1', + isCommentEvent: false, + }); + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('catch-all'); + }); + + it('falls back to all candidates when getCard API call fails', async () => { + const projectCascade: RouterProjectConfig = { + ...mockProject, + id: 'cascade', + trello: { ...mockProject.trello!, requiredLabelId: 'label-cascade' }, + }; + const projectBdgt: RouterProjectConfig = { + ...mockProject, + id: 'bdgt', + trello: { ...mockProject.trello!, requiredLabelId: 'label-bdgt' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectCascade, projectBdgt], + fullProjects: [{ id: 'cascade' } as never, { id: 'bdgt' } as never], + }); + vi.mocked(trelloClient.getCard).mockRejectedValueOnce(new Error('API error')); + const projects = await adapter.resolveAllProjects({ projectIdentifier: 'board1', eventType: 'updateCard', + workItemId: 'card1', isCommentEvent: false, }); + // Falls back to all candidates on API failure + expect(projects).toHaveLength(2); + }); + + it('skips label lookup when workItemId is absent', async () => { + const projectWithLabel: RouterProjectConfig = { + ...mockProject, + id: 'p1', + trello: { ...mockProject.trello!, requiredLabelId: 'label-cascade' }, + }; + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [projectWithLabel], + fullProjects: [{ id: 'p1' } as never], + }); + vi.mocked(trelloClient.getCard).mockClear(); + + // No workItemId in event + const projects = await adapter.resolveAllProjects({ + projectIdentifier: 'board1', + eventType: 'addLabelToCard', + isCommentEvent: false, + }); + // Returns all candidates without label lookup expect(projects).toHaveLength(1); - expect(projects[0].id).toBe('p1'); + expect(trelloClient.getCard).not.toHaveBeenCalled(); }); }); From 8f5bf3e5cd1bf7f3a8de59f8a48ce15898f0f151 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 08:41:43 +0000 Subject: [PATCH 6/8] fix(trello): correct resolveAllProjects fallthrough and eliminate double getCard call - Fix webhook-processor.ts: when adapter implements resolveAllProjects, use its result directly (even []) instead of falling through to resolveProject. An empty result means the event was definitively filtered (label mismatch) and must not re-introduce excluded projects. - Eliminate double getCard API call in happy path: mark projects returned by successful resolveAllProjects pre-filtering with _labelPreFiltered, causing dispatchWithCredentials to skip its secondary label guard. The guard still fires when resolveAllProjects fell back to all candidates due to an API error. - Add RouterProjectConfig._labelPreFiltered internal field to router config. - Add test: resolveAllProjects([]) short-circuits as "no project config found" without calling resolveProject or dispatchWithCredentials. Co-Authored-By: Claude Sonnet 4.6 --- src/router/adapters/trello.ts | 13 ++++++++----- src/router/config.ts | 6 ++++++ src/router/webhook-processor.ts | 18 +++++++++--------- tests/unit/router/webhook-processor.test.ts | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index ef5990a1..01200032 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -126,6 +126,8 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { }); // Return projects whose required label is present on the card. + // Mark returned projects as pre-filtered so dispatchWithCredentials skips its + // secondary label guard (avoiding a redundant getCard API call). const labelMatched = candidates.filter( (p) => p.trello?.requiredLabelId && cardLabelIds.includes(p.trello.requiredLabelId), ); @@ -134,7 +136,7 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { cardId: event.workItemId, matched: labelMatched.map((p) => p.id), }); - return labelMatched; + return labelMatched.map((p) => ({ ...p, _labelPreFiltered: true })); } // No label-specific match — fall back to projects without a required label (catch-all) @@ -144,7 +146,7 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { cardId: event.workItemId, catchAll: catchAll.map((p) => p.id), }); - return catchAll; + return catchAll.map((p) => ({ ...p, _labelPreFiltered: true })); } // Card has no label that matches any configured project — drop. @@ -191,9 +193,10 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; return withTrelloCredentials(trelloCreds, async () => { - // Secondary label guard: resolveAllProjects pre-filters by label, but this - // check ensures correctness even when pre-filtering was skipped (e.g. API error). - if (project.trello?.requiredLabelId && event.workItemId) { + // Secondary label guard: ensures correctness when resolveAllProjects errored and + // returned all candidates unfiltered. Skipped when _labelPreFiltered is set, + // meaning resolveAllProjects already verified the label (avoids a duplicate getCard call). + if (project.trello?.requiredLabelId && event.workItemId && !project._labelPreFiltered) { const hasLabel = await checkCardHasRequiredLabel( event.workItemId, project.trello.requiredLabelId, diff --git a/src/router/config.ts b/src/router/config.ts index c4e56f55..3bdcdad9 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -17,6 +17,12 @@ export interface RouterProjectConfig { projectKey: string; baseUrl: string; }; + /** + * @internal Set by resolveAllProjects when label pre-filtering was successful. + * When true, dispatchWithCredentials skips the secondary checkCardHasRequiredLabel + * guard since the label was already verified during project resolution. + */ + _labelPreFiltered?: boolean; } export interface RouterConfig { diff --git a/src/router/webhook-processor.ts b/src/router/webhook-processor.ts index 906a1328..14a94d13 100644 --- a/src/router/webhook-processor.ts +++ b/src/router/webhook-processor.ts @@ -98,17 +98,17 @@ export async function processRouterWebhook( // Step 6: Resolve project config(s) // When the adapter implements resolveAllProjects (e.g. Trello, where multiple projects can - // share the same board and are distinguished by requiredLabelId), we collect all candidates - // and try each in turn at dispatch time. For adapters that don't implement it, we fall back - // to the single-project resolveProject path. - const projectsToTry: RouterProjectConfig[] = []; + // share the same board and are distinguished by requiredLabelId), we use its result directly. + // An empty array means the event was definitively filtered out (e.g. card lacks required label) + // and we must NOT fall back to resolveProject — that would bypass the filter and re-introduce + // projects that were intentionally excluded. + // For adapters that don't implement resolveAllProjects, we fall back to resolveProject. + let projectsToTry: RouterProjectConfig[]; if (adapter.resolveAllProjects) { - const allProjects = await adapter.resolveAllProjects(event); - projectsToTry.push(...allProjects); - } - if (projectsToTry.length === 0) { + projectsToTry = await adapter.resolveAllProjects(event); + } else { const singleProject = await adapter.resolveProject(event); - if (singleProject) projectsToTry.push(singleProject); + projectsToTry = singleProject ? [singleProject] : []; } if (projectsToTry.length === 0) { diff --git a/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index 5de45098..0d612333 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -555,6 +555,22 @@ describe('processRouterWebhook', () => { expect(addJob).not.toHaveBeenCalled(); }); + it('short-circuits as "no project config found" when resolveAllProjects returns []', async () => { + // resolveAllProjects returning [] means the event was definitively filtered + // (e.g. card lacks required label). Should NOT fall through to resolveProject. + const adapter = makeMockAdapter({ + resolveAllProjects: vi.fn().mockResolvedValue([]), + resolveProject: vi.fn().mockResolvedValue(project1), // must NOT be called + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toMatch(/No project config for identifier/); + expect(adapter.resolveProject).not.toHaveBeenCalled(); + expect(adapter.dispatchWithCredentials).not.toHaveBeenCalled(); + expect(addJob).not.toHaveBeenCalled(); + }); + it('falls back to resolveProject when resolveAllProjects not implemented', async () => { const triggerResult = { agentType: 'implementation', agentInput: {} }; vi.mocked(addJob).mockResolvedValue('job-1'); From 09776b2ea2dfcf37827a0d03803deb2db69faafa Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 08:52:03 +0000 Subject: [PATCH 7/8] fix(trello): add requiredLabelId to Zod schema to prevent silent stripping `ProjectConfigSchema` in `src/config/schema.ts` was missing `requiredLabelId` from the trello object schema. Zod strips unknown keys by default, so the field was silently discarded every time config was loaded from the database via `validateConfig()`. This meant `project.trello?.requiredLabelId` was always `undefined` at runtime, causing the required-label filter to never activate. Co-Authored-By: Claude Sonnet 4.6 --- src/config/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/schema.ts b/src/config/schema.ts index 60cbe368..5995e182 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -63,6 +63,7 @@ export const ProjectConfigSchema = z.object({ cost: z.string().optional(), }) .optional(), + requiredLabelId: z.string().optional(), }) .optional(), From e6babc9a021a6ac120e24751750b893f2b8c79c9 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 15:01:48 +0000 Subject: [PATCH 8/8] fix(trello): thread projectId through worker to prevent wrong project resolution When multiple projects share the same Trello board (distinguished by requiredLabelId), the router correctly routes to the right project (e.g. bdgt) and stores projectId in the job. However the worker was re-resolving the project by boardId in processPMWebhook, returning the first matching project (e.g. cascade) instead of the intended one. Fix: pass jobData.projectId from the worker through processTrelloWebhook and processJiraWebhook to processPMWebhook, where it bypasses the boardId-based lookupProject and uses loadProjectConfigById directly. Co-Authored-By: Claude Sonnet 4.6 --- src/pm/webhook-handler.ts | 18 ++++++++++++++++-- src/triggers/jira/webhook-handler.ts | 3 ++- src/triggers/trello/webhook-handler.ts | 3 ++- src/worker-entry.ts | 4 ++++ tests/unit/worker-entry.test.ts | 7 +++++-- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index 540fb7cb..1704536a 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -7,6 +7,7 @@ * ack comment management) is delegated to the PMIntegration interface. */ +import { loadProjectConfigById } from '../config/provider.js'; import { checkAgentTypeConcurrency, clearAgentTypeEnqueued, @@ -155,6 +156,12 @@ async function handleMatchedTrigger( * and runs the matched agent. * * Used by both Trello and JIRA webhook handlers. + * + * @param projectId - When provided (e.g. from a router-enqueued job), the project is + * looked up by ID directly instead of by the payload's board/project identifier. + * This is required for multi-project boards (e.g. two projects sharing the same + * Trello board distinguished by `requiredLabelId`) where a boardId lookup would + * return the wrong project. */ export async function processPMWebhook( integration: PMIntegration, @@ -162,9 +169,11 @@ export async function processPMWebhook( registry: TriggerRegistry, ackCommentId?: string, triggerResult?: TriggerResult, + projectId?: string, ): Promise { logger.info(`Processing ${integration.type} webhook`, { hasTriggerResult: !!triggerResult, + projectId, }); const event = integration.parseWebhookPayload(payload); @@ -181,10 +190,15 @@ export async function processPMWebhook( eventType: event.eventType, }); - const projectConfig = await integration.lookupProject(event.projectIdentifier); + // When a projectId is supplied (from a router-enqueued job), use it directly to + // avoid re-resolving the project by boardId — which returns only the first matching + // project and would pick the wrong one when multiple projects share the same board. + const projectConfig = projectId + ? await loadProjectConfigById(projectId) + : await integration.lookupProject(event.projectIdentifier); if (!projectConfig) { logger.warn(`No project configured for ${integration.type} identifier`, { - identifier: event.projectIdentifier, + identifier: projectId ?? event.projectIdentifier, }); return; } diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts index d641e297..c5954add 100644 --- a/src/triggers/jira/webhook-handler.ts +++ b/src/triggers/jira/webhook-handler.ts @@ -15,7 +15,8 @@ export async function processJiraWebhook( registry: TriggerRegistry, ackCommentId?: string, triggerResult?: TriggerResult, + projectId?: string, ): Promise { const integration = pmRegistry.get('jira'); - await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult, projectId); } diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 1e64b18a..02d0763f 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -15,7 +15,8 @@ export async function processTrelloWebhook( registry: TriggerRegistry, ackCommentId?: string, triggerResult?: TriggerResult, + projectId?: string, ): Promise { const integration = pmRegistry.get('trello'); - await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult, projectId); } diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 2bbe64e9..8ac231af 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -182,6 +182,7 @@ export async function dispatchJob( case 'trello': logger.info('[Worker] Processing Trello job', { jobId, + projectId: jobData.projectId, workItemId: jobData.workItemId, actionType: jobData.actionType, ackCommentId: jobData.ackCommentId, @@ -192,6 +193,7 @@ export async function dispatchJob( triggerRegistry, jobData.ackCommentId, jobData.triggerResult, + jobData.projectId, ); break; case 'github': @@ -231,6 +233,7 @@ export async function dispatchJob( case 'jira': logger.info('[Worker] Processing JIRA job', { jobId, + projectId: jobData.projectId, issueKey: jobData.issueKey, webhookEvent: jobData.webhookEvent, ackCommentId: jobData.ackCommentId, @@ -241,6 +244,7 @@ export async function dispatchJob( triggerRegistry, jobData.ackCommentId, jobData.triggerResult, + jobData.projectId, ); break; case 'sentry': diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts index 13f9072e..ffe5d574 100644 --- a/tests/unit/worker-entry.test.ts +++ b/tests/unit/worker-entry.test.ts @@ -105,7 +105,7 @@ import { // ── dispatchJob routing tests ───────────────────────────────────────────────── describe('dispatchJob routing', () => { - it('routes trello job to processTrelloWebhook with payload, registry, ackCommentId, triggerResult', async () => { + it('routes trello job to processTrelloWebhook with payload, registry, ackCommentId, triggerResult, projectId', async () => { const mockRegistry = {}; const jobPayload = { action: { type: 'updateCard' } }; const triggerResult = { matched: true, agentType: 'implementation' } as never; @@ -129,6 +129,7 @@ describe('dispatchJob routing', () => { mockRegistry, 'comment-123', triggerResult, + 'proj-1', ); }); @@ -161,7 +162,7 @@ describe('dispatchJob routing', () => { ); }); - it('routes jira job to processJiraWebhook with payload, registry, ackCommentId, triggerResult', async () => { + it('routes jira job to processJiraWebhook with payload, registry, ackCommentId, triggerResult, projectId', async () => { const mockRegistry = {}; const jobPayload = { issue: { key: 'PROJ-1' } }; const triggerResult = { matched: true, agentType: 'implementation' } as never; @@ -185,6 +186,7 @@ describe('dispatchJob routing', () => { mockRegistry, 'jira-comment-789', triggerResult, + 'proj-1', ); }); @@ -585,6 +587,7 @@ describe('main() - environment variable validation', () => { expect.anything(), 'comment-123', undefined, + 'proj-1', ); // flush is called before exit(0) expect(flush).toHaveBeenCalled();