Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -113,6 +114,7 @@ export interface ProjectConfigRaw {
lists: Record<string, string>;
labels: Record<string, string>;
customFields?: { cost?: string };
requiredLabelId?: string;
};
jira?: {
projectKey: string;
Expand Down Expand Up @@ -199,6 +201,7 @@ function buildTrelloConfig(config: TrelloIntegrationConfig): ProjectConfigRaw['t
lists: config.lists,
labels: config.labels,
customFields: config.customFields,
requiredLabelId: config.requiredLabelId,
};
}

Expand Down
6 changes: 6 additions & 0 deletions src/integrations/pm/trello/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

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
93 changes: 88 additions & 5 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 @@ -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<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 @@ -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(
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,6 +11,7 @@ export interface RouterProjectConfig {
boardId: string;
lists: Record<string, string>;
labels: Record<string, string>;
requiredLabelId?: string;
};
jira?: {
projectKey: string;
Expand All @@ -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 {
Expand Down Expand Up @@ -104,6 +111,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
59 changes: 45 additions & 14 deletions src/router/webhook-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand All @@ -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',
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading