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" } } 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(), 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/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/router/adapters/trello.ts b/src/router/adapters/trello.ts index 88a4b8f7..01200032 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'; @@ -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, @@ -101,8 +102,74 @@ 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(); + 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. + // 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), + ); + if (labelMatched.length > 0) { + logger.info('Pre-filtered projects by card labels', { + cardId: event.workItemId, + matched: labelMatched.map((p) => p.id), + }); + return labelMatched.map((p) => ({ ...p, _labelPreFiltered: true })); + } + + // 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.map((p) => ({ ...p, _labelPreFiltered: true })); + } + + // 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, @@ -125,7 +192,25 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { } const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; - return withTrelloCredentials(trelloCreds, () => triggerRegistry.dispatch(ctx)); + return withTrelloCredentials(trelloCreds, async () => { + // 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, + ); + 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..3bdcdad9 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -11,11 +11,18 @@ export interface RouterProjectConfig { boardId: string; lists: Record; labels: Record; + requiredLabelId?: string; }; jira?: { 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 { @@ -93,6 +100,7 @@ export async function loadProjectConfig(): Promise<{ boardId: trelloConfig.boardId, lists: trelloConfig.lists, labels: trelloConfig.labels, + requiredLabelId: trelloConfig.requiredLabelId, }, }), ...(jiraConfig && { 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/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/src/router/webhook-processor.ts b/src/router/webhook-processor.ts index 46d8f16f..14a94d13 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 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) { + projectsToTry = await adapter.resolveAllProjects(event); + } else { + const singleProject = await adapter.resolveProject(event); + projectsToTry = singleProject ? [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/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/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' }, }); }); diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index 57e2945a..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), @@ -42,6 +53,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 +62,12 @@ 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 { trelloClient } from '../../../../src/trello/client.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; @@ -197,6 +214,188 @@ describe('TrelloRouterAdapter', () => { }); }); + describe('resolveAllProjects', () => { + 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: '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 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, + }); + // Only bdgt should be returned (cascade's label not on card) + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('bdgt'); + }); + + 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: 'board1', + eventType: 'updateCard', + workItemId: 'card1', + isCommentEvent: false, + }); + // No label match and no catch-all → empty + expect(projects).toHaveLength(0); + }); + + 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(trelloClient.getCard).not.toHaveBeenCalled(); + }); + }); + describe('dispatchWithCredentials', () => { it('dispatches to trigger registry', async () => { vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValue({ @@ -312,6 +511,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/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index 55109284..0d612333 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -463,4 +463,128 @@ 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('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'); + + 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(); + }); + }); }); 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(); 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 */}