Skip to content
17 changes: 11 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const ProjectConfigSchema = z.object({
cost: z.string().optional(),
})
.optional(),
requiredLabelId: z.string().optional(),
})
.optional(),

Expand Down
3 changes: 3 additions & 0 deletions src/db/repositories/configMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface TrelloIntegrationConfig {
lists: Record<string, string>;
labels: Record<string, string>;
customFields?: { cost?: string };
requiredLabelId?: string;
}

export interface JiraIntegrationConfig {
Expand Down Expand Up @@ -98,6 +99,7 @@ export interface ProjectConfigRaw {
lists: Record<string, string>;
labels: Record<string, string>;
customFields?: { cost?: string };
requiredLabelId?: string;
};
jira?: {
projectKey: string;
Expand Down Expand Up @@ -171,6 +173,7 @@ function buildTrelloConfig(config: TrelloIntegrationConfig): ProjectConfigRaw['t
lists: config.lists,
labels: config.labels,
customFields: config.customFields,
requiredLabelId: config.requiredLabelId,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/pm/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface TrelloConfig {
lists: Record<string, string>;
labels: Record<string, string>;
customFields?: { cost?: string };
requiredLabelId?: string;
}

/** JIRA-specific configuration (from project_integrations JSONB) */
Expand Down
18 changes: 16 additions & 2 deletions src/pm/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* ack comment management) is delegated to the PMIntegration interface.
*/

import { loadProjectConfigById } from '../config/provider.js';
import {
checkAgentTypeConcurrency,
clearAgentTypeEnqueued,
Expand Down Expand Up @@ -155,16 +156,24 @@ 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,
payload: unknown,
registry: TriggerRegistry,
ackCommentId?: string,
triggerResult?: TriggerResult,
projectId?: string,
): Promise<void> {
logger.info(`Processing ${integration.type} webhook`, {
hasTriggerResult: !!triggerResult,
projectId,
});

const event = integration.parseWebhookPayload(payload);
Expand All @@ -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;
}
Expand Down
91 changes: 88 additions & 3 deletions src/router/adapters/trello.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<RouterProjectConfig[]> {
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,
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/router/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ export interface RouterProjectConfig {
boardId: string;
lists: Record<string, string>;
labels: Record<string, string>;
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 {
Expand Down Expand Up @@ -93,6 +100,7 @@ export async function loadProjectConfig(): Promise<{
boardId: trelloConfig.boardId,
lists: trelloConfig.lists,
labels: trelloConfig.labels,
requiredLabelId: trelloConfig.requiredLabelId,
},
}),
...(jiraConfig && {
Expand Down
12 changes: 12 additions & 0 deletions src/router/platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ export interface RouterPlatformAdapter {
*/
resolveProject(event: ParsedWebhookEvent): Promise<RouterProjectConfig | null>;

/**
* 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<RouterProjectConfig[]>;

/**
* Run the authoritative trigger dispatch inside platform credential scope.
* The adapter wraps `triggerRegistry.dispatch(ctx)` with appropriate
Expand Down
20 changes: 20 additions & 0 deletions src/router/trello.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> {
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,
Expand Down
Loading
Loading