diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 19d84517..1e30cc1c 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 { @@ -113,6 +114,7 @@ export interface ProjectConfigRaw { lists: Record; labels: Record; customFields?: { cost?: string }; + requiredLabelId?: string; }; jira?: { projectKey: string; @@ -199,6 +201,7 @@ function buildTrelloConfig(config: TrelloIntegrationConfig): ProjectConfigRaw['t lists: config.lists, labels: config.labels, customFields: config.customFields, + requiredLabelId: config.requiredLabelId, }; } diff --git a/src/integrations/pm/trello/config-schema.ts b/src/integrations/pm/trello/config-schema.ts index d6ac2cf3..d77dfa30 100644 --- a/src/integrations/pm/trello/config-schema.ts +++ b/src/integrations/pm/trello/config-schema.ts @@ -46,6 +46,12 @@ export const trelloConfigSchema = z cost: z.string().optional(), }) .optional(), + + /** + * Optional Trello label ID. When set, only cards carrying this label are + * processed by webhook triggers; cards without it are skipped. + */ + requiredLabelId: z.string().optional(), }) .describe('Trello project integration config'); diff --git a/src/pm/config.ts b/src/pm/config.ts index 3810ef39..e60ffb93 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 35b92342..f6e11028 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, @@ -102,8 +103,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, @@ -126,9 +193,25 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { } const ctx: TriggerContext = { project: fullProject, source: 'trello', payload }; - return withTrelloCredentials(trelloCreds, () => - withPMScopeForDispatch(fullProject, () => 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 withPMScopeForDispatch(fullProject, () => triggerRegistry.dispatch(ctx)); + }); } async postAck( diff --git a/src/router/config.ts b/src/router/config.ts index d8588723..e2cc70df 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; @@ -20,6 +21,12 @@ export interface RouterProjectConfig { teamId: string; projectId?: 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 { @@ -104,6 +111,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 a21dafb5..70ff7818 100644 --- a/src/router/webhook-processor.ts +++ b/src/router/webhook-processor.ts @@ -11,8 +11,10 @@ */ 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 type { RouterProjectConfig } from './config.js'; import type { RouterPlatformAdapter } from './platform-adapter.js'; import { handleTriggerOutcome, @@ -81,9 +83,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, }); @@ -93,25 +108,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/worker-entry.ts b/src/worker-entry.ts index 0fe539e5..12bf6528 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -305,6 +305,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, @@ -372,6 +373,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, diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index d3a93d82..0b1d4c21 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', + }), + }, })); // Spec 017 / plan 2: PM router adapters wrap dispatch in `withPMScopeForDispatch`. // Mock as passthrough so the existing tests don't pull the real PM manifest registry. @@ -47,6 +58,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'; @@ -55,7 +67,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'; @@ -202,6 +219,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({ @@ -317,6 +516,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 df716c62..6c9b7d91 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -1110,4 +1110,128 @@ describe('processRouterWebhook', () => { expect(result.decisionReason).toBe('Trigger completed without agent (PM operation)'); }); }); + + 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/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts index 211baaa6..94c8236d 100644 --- a/tests/unit/web/pm-wizard-state.test.ts +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -88,6 +88,7 @@ describe('provider state slices', () => { trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', }); }); @@ -126,6 +127,7 @@ describe('provider state slices', () => { trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', }); }); diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts index b73c1af1..a15daacd 100644 --- a/tests/unit/worker-entry.test.ts +++ b/tests/unit/worker-entry.test.ts @@ -130,7 +130,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; @@ -217,7 +217,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; diff --git a/web/src/components/projects/pm-providers/trello/state.ts b/web/src/components/projects/pm-providers/trello/state.ts index 6a31aa34..b7e96e8b 100644 --- a/web/src/components/projects/pm-providers/trello/state.ts +++ b/web/src/components/projects/pm-providers/trello/state.ts @@ -19,6 +19,7 @@ export interface TrelloWizardStateSlice { trelloListMappings: Record; trelloLabelMappings: Record; trelloCostFieldId: string; + trelloRequiredLabelId: string; } interface VerificationState { @@ -35,6 +36,7 @@ export type TrelloWizardAction = | { 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: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } } | { type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD'; @@ -51,6 +53,7 @@ export function createInitialTrelloState(): TrelloWizardStateSlice { trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', }; } @@ -98,6 +101,8 @@ export function trelloWizardReducer { return { trelloBoardId, @@ -135,5 +141,6 @@ export function resetTrelloBoardState( trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', + trelloRequiredLabelId: '', }; } diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 0469e67d..163f0f3a 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -17,8 +17,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; -import type { ReactElement } from 'react'; -import { useState } from 'react'; +import { createElement, Fragment, type ReactElement, useState } from 'react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import type { WizardState } from '../../pm-wizard-state.js'; @@ -206,7 +207,7 @@ function TrelloLabelMappingAdapter({ providerHooks, }: ProviderWizardStepProps): ReactElement { const h = asTrelloHooks(providerHooks); - return LabelMappingStep({ + const labelStep = LabelMappingStep({ step: { kind: 'label-mapping', id: 'trello-labels' }, providerId: 'trello', labelSlots: TRELLO_LABEL_SLOTS, @@ -219,6 +220,52 @@ function TrelloLabelMappingAdapter({ labelDefaults: TRELLO_LABEL_DEFAULTS, loading: h.boardDetailsLoading, }); + + // Required-label filter (optional): when set, only cards carrying this label + // trigger CASCADE. Renders a native picker from the board's labels when + // available, with a manual label-ID input fallback. + const hasBoardLabels = h.providerLabels.length > 0; + const requiredLabelControl = createElement( + 'div', + { className: 'space-y-2 border-t pt-4', key: 'trello-required-label' }, + createElement(Label, { key: 'label' }, 'Required Label (optional)'), + createElement( + 'p', + { key: 'hint', className: 'text-xs text-muted-foreground' }, + 'When set, only cards carrying this label will trigger CASCADE. Leave blank to process all cards.', + ), + hasBoardLabels + ? createElement( + 'select', + { + key: 'select', + className: + 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm', + value: state.trelloRequiredLabelId, + onChange: (e: { target: { value: string } }) => + dispatch({ type: 'SET_TRELLO_REQUIRED_LABEL', id: e.target.value }), + }, + createElement('option', { key: '', value: '' }, '— none —'), + ...h.providerLabels + .filter((l) => l.name) + .map((l) => + createElement( + 'option', + { key: l.id, value: l.id }, + l.color ? `${l.name} (${l.color})` : l.name, + ), + ), + ) + : createElement(Input, { + key: 'input', + value: state.trelloRequiredLabelId, + onChange: (e: { target: { value: string } }) => + dispatch({ type: 'SET_TRELLO_REQUIRED_LABEL', id: e.target.value }), + placeholder: 'Label ID to filter by (optional)', + }), + ); + + return createElement(Fragment, null, labelStep, requiredLabelControl); } function TrelloCustomFieldMappingAdapter({ @@ -299,6 +346,7 @@ export const trelloProviderWizard: ProviderWizardDefinition = { lists: state.trelloListMappings, labels: state.trelloLabelMappings, ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), + ...(state.trelloRequiredLabelId ? { requiredLabelId: state.trelloRequiredLabelId } : {}), }), buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => @@ -314,6 +362,7 @@ export const trelloProviderWizard: ProviderWizardDefinition = { trelloBoardId: (initialConfig.boardId as string) ?? '', trelloCostFieldId: (initialConfig.customFields as Record | undefined)?.cost ?? '', + trelloRequiredLabelId: (initialConfig.requiredLabelId as string) ?? '', hasStoredCredentials: configuredKeys.has('TRELLO_API_KEY') && configuredKeys.has('TRELLO_TOKEN'), } satisfies Partial; diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 78a8c99a..3c644fdb 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -90,6 +90,7 @@ export interface WizardState { trelloListMappings: Record; trelloLabelMappings: Record; trelloCostFieldId: string; + trelloRequiredLabelId: string; // JIRA mappings jiraStatusMappings: Record; jiraIssueTypes: Record;