Skip to content
Closed
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
29 changes: 29 additions & 0 deletions src/config/configCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class ConfigCache {
private projectByJiraKey = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectByLinearTeamId = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private orgIdByProject = new Map<string, CacheEntry<string>>();
/**
* Unified PM identifier cache, keyed by `${provider}:${identifier}`.
* Replaces per-provider caches for generic lookups.
*/
private projectByPMIdentifier = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private ttlMs: number;

constructor(ttlMs = DEFAULT_TTL_MS) {
Expand Down Expand Up @@ -81,13 +86,37 @@ class ConfigCache {
this.orgIdByProject.set(projectId, this.makeEntry(orgId));
}

/**
* Get a cached project by PM provider type + identifier.
* Key format: `${provider}:${identifier}` (e.g. `trello:boardId123`).
* Returns null if not cached or expired.
*/
getProjectByPMIdentifier(provider: string, identifier: string): ProjectConfig | undefined | null {
const key = `${provider}:${identifier}`;
const entry = this.projectByPMIdentifier.get(key);
return this.isValid(entry) ? entry.data : null;
}

/**
* Cache a project lookup by PM provider type + identifier.
*/
setProjectByPMIdentifier(
provider: string,
identifier: string,
project: ProjectConfig | undefined,
): void {
const key = `${provider}:${identifier}`;
this.projectByPMIdentifier.set(key, this.makeEntry(project));
}

invalidate(): void {
this.configEntry = null;
this.projectByBoardId.clear();
this.projectByRepo.clear();
this.projectByJiraKey.clear();
this.projectByLinearTeamId.clear();
this.orgIdByProject.clear();
this.projectByPMIdentifier.clear();
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
findProjectByIdFromDb,
findProjectByJiraProjectKeyFromDb,
findProjectByLinearTeamIdFromDb,
findProjectByPMIdentifierFromDb,
findProjectByRepoFromDb,
findProjectWithConfigByBoardId,
findProjectWithConfigById,
findProjectWithConfigByJiraProjectKey,
findProjectWithConfigByLinearTeamId,
findProjectWithConfigByPMIdentifierFromDb,
findProjectWithConfigByRepo,
loadConfigFromDb,
} from '../db/repositories/configRepository.js';
Expand Down Expand Up @@ -73,6 +75,27 @@ export async function findProjectById(id: string): Promise<ProjectConfig | undef
return findProjectByIdFromDb(id);
}

/**
* Find a project by PM provider type + provider-specific identifier.
*
* Replaces per-provider lookup functions (`findProjectByBoardId`, etc.)
* with a single generic function that uses a unified cache.
*
* @param provider - PM provider type: 'trello', 'jira', 'linear'
* @param identifier - Provider-specific ID (boardId, projectKey, teamId)
*/
export async function findProjectByPMIdentifier(
provider: string,
identifier: string,
): Promise<ProjectConfig | undefined> {
const cached = configCache.getProjectByPMIdentifier(provider, identifier);
if (cached !== null) return cached;

const project = await findProjectByPMIdentifierFromDb(provider, identifier);
configCache.setProjectByPMIdentifier(provider, identifier, project);
return project;
}

// --- Project + org-scoped config lookups (for webhook handlers / agent runners) ---

type ProjectWithConfig = { project: ProjectConfig; config: CascadeConfig };
Expand Down Expand Up @@ -105,6 +128,22 @@ export async function loadProjectConfigById(id: string): Promise<ProjectWithConf
return findProjectWithConfigById(id);
}

/**
* Load both project + cascade config for a PM provider + identifier.
*
* Replaces per-provider config lookup functions (`loadProjectConfigByBoardId`, etc.)
* with a single generic function.
*
* @param provider - PM provider type: 'trello', 'jira', 'linear'
* @param identifier - Provider-specific ID (boardId, projectKey, teamId)
*/
export async function loadProjectConfigByPMIdentifier(
provider: string,
identifier: string,
): Promise<ProjectWithConfig | undefined> {
return findProjectWithConfigByPMIdentifierFromDb(provider, identifier);
}

// ============================================================================
// CredentialResolver interface and implementations
// ============================================================================
Expand Down
9 changes: 9 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ export const ProjectConfigSchema = z.object({
})
.default({ type: 'trello' }),

/**
* Generic PM config — provider-agnostic view of the active PM integration's config.
* Populated from the active provider's `project_integrations.config` JSONB at load time.
* Consumers should use typed accessors (getTrelloConfig, getJiraConfig, getLinearConfig)
* or the generic getPMConfig() for read access. This field coexists with the
* per-provider top-level fields (trello, jira, linear) for backward compatibility.
*/
pmConfig: z.record(z.unknown()).optional(),

trello: z
.object({
boardId: z.string().min(1),
Expand Down
16 changes: 16 additions & 0 deletions src/db/repositories/configMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ export interface ProjectConfigRaw {
baseBranch: string;
branchPrefix: string;
pm: { type: string };
/**
* Generic PM config — a provider-agnostic view of the active PM integration config.
* Populated from the active provider's integration config at load time.
* Coexists with per-provider fields (trello, jira, linear) for backward compat.
*/
pmConfig?: Record<string, unknown>;
model?: string;
agentModels?: Record<string, string>;
maxIterations?: number;
Expand Down Expand Up @@ -292,8 +298,18 @@ export function mapProjectRow({
// Derive PM type from integration config
const pmType = jiraConfig ? 'jira' : linearConfig ? 'linear' : 'trello';

// Build the generic pmConfig from the active provider's config
const activePmConfig: Record<string, unknown> | undefined = jiraConfig
? (jiraConfig as unknown as Record<string, unknown>)
: linearConfig
? (linearConfig as unknown as Record<string, unknown>)
: trelloConfig
? (trelloConfig as unknown as Record<string, unknown>)
: undefined;

const project: ProjectConfigRaw = {
...buildBaseProjectFields(row, pmType),
pmConfig: activePmConfig,
agentModels: orUndefined(models),
agentEngineSettings: orUndefined(agentEngineSettingsMap) as
| Record<string, EngineSettings>
Expand Down
39 changes: 39 additions & 0 deletions src/db/repositories/configRepository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { eq, type SQL, sql } from 'drizzle-orm';
import { validateConfig } from '../../config/schema.js';
import { PM_IDENTIFIER_KEYS } from '../../pm/constants.js';
import type { CascadeConfig, ProjectConfig } from '../../types/index.js';
import { getDb } from '../client.js';
import { agentConfigs, projectIntegrations, projects } from '../schema/index.js';
Expand Down Expand Up @@ -186,3 +187,41 @@ export function findProjectWithConfigByLinearTeamId(
): Promise<ProjectWithConfig | undefined> {
return findProjectConfigFromDb(linearTeamIdWhereClause(teamId));
}

// ---------------------------------------------------------------------------
// Generic PM identifier lookup (Step 2 of PM refactor)
// ---------------------------------------------------------------------------

/**
* Find a project by PM provider + provider-specific identifier string.
* Uses the `project_integrations` JSONB config to look up the identifier key.
*
* @param provider - e.g. 'trello', 'jira', 'linear'
* @param identifier - provider-specific ID (boardId, projectKey, teamId)
*/
function pmIdentifierWhereClause(provider: string, identifier: string): SQL {
const key = PM_IDENTIFIER_KEYS[provider];
if (!key) {
// Unknown provider — return a condition that matches nothing
return sql`false`;
}
return sql`${projects.id} IN (
SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations}
WHERE ${projectIntegrations.provider} = ${provider}
AND ${projectIntegrations.config}->>${key} = ${identifier}
)`;
}

export function findProjectByPMIdentifierFromDb(
provider: string,
identifier: string,
): Promise<ProjectConfig | undefined> {
return findProjectFromDb(pmIdentifierWhereClause(provider, identifier));
}

export function findProjectWithConfigByPMIdentifierFromDb(
provider: string,
identifier: string,
): Promise<ProjectWithConfig | undefined> {
return findProjectConfigFromDb(pmIdentifierWhereClause(provider, identifier));
}
36 changes: 29 additions & 7 deletions src/pm/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,37 @@ export function getLinearConfig(project: ProjectConfig): LinearConfig | undefine
return project.linear as LinearConfig | undefined;
}

/**
* Get the active PM provider's config as a generic record.
*
* Returns the `pmConfig` field when available (populated from `project_integrations.config`
* via the configMapper). Falls back to the per-provider typed accessor for backward compat
* with test fixtures and legacy projects that don't have `pmConfig` populated.
*
* Use the typed accessors (getTrelloConfig, getJiraConfig, getLinearConfig) when you need
* provider-specific fields with compile-time type safety. Use this accessor for generic
* operations that apply to all providers (e.g., extracting the cost field ID).
*/
export function getPMConfig(project: ProjectConfig): Record<string, unknown> | undefined {
// Use the unified pmConfig field when available
if (project.pmConfig) return project.pmConfig;

// Fallback: derive from per-provider typed fields (backward compat)
const pmType = project.pm?.type ?? 'trello';
if (pmType === 'jira') return project.jira as Record<string, unknown> | undefined;
if (pmType === 'linear') return project.linear as Record<string, unknown> | undefined;
return project.trello as Record<string, unknown> | undefined;
}

/**
* Get the cost custom field ID for a project, regardless of PM type.
*
* Delegates to getPMConfig() which already handles both the unified pmConfig
* field and the per-provider fallback (trello/jira/linear), so no additional
* branching is needed here.
*/
export function getCostFieldId(project: ProjectConfig): string | undefined {
if (project.pm?.type === 'jira') {
return getJiraConfig(project)?.customFields?.cost;
}
if (project.pm?.type === 'linear') {
return getLinearConfig(project)?.customFields?.cost;
}
return getTrelloConfig(project)?.customFields?.cost;
const pmConfig = getPMConfig(project);
const customFields = pmConfig?.customFields as { cost?: string } | undefined;
return customFields?.cost;
}
23 changes: 23 additions & 0 deletions src/pm/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* PM provider constants shared across the config layer.
*
* Centralises the provider → identifier-key mapping so that
* `configRepository.ts`, `router/config.ts`, and any future callers
* only need to update one place when a new PM provider is added.
*/

/**
* Maps a PM provider name to the JSONB field that uniquely identifies
* a project in that provider's `project_integrations.config`.
*
* | Provider | Identifier key |
* |----------|-----------------|
* | trello | boardId |
* | jira | projectKey |
* | linear | teamId |
*/
export const PM_IDENTIFIER_KEYS: Record<string, string> = {
trello: 'boardId',
jira: 'projectKey',
linear: 'teamId',
};
27 changes: 25 additions & 2 deletions src/router/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { loadConfig } from '../config/provider.js';
import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../pm/config.js';
import { getJiraConfig, getLinearConfig, getPMConfig, getTrelloConfig } from '../pm/config.js';
import { PM_IDENTIFIER_KEYS } from '../pm/constants.js';
import type { CascadeConfig, ProjectConfig } from '../types/index.js';

// Minimal config types - what router needs for quick filtering
export interface RouterProjectConfig {
id: string;
repo?: string; // owner/repo format (optional for projects without SCM integration)
pmType: 'trello' | 'jira' | 'linear';
/**
* Generic PM identifier for the active provider.
* Replaces per-provider fields (trello.boardId, jira.projectKey, linear.teamId)
* for generic lookups. Populated by loadProjectConfig() from the active config.
*
* Values by provider:
* - trello: boardId
* - jira: projectKey
* - linear: teamId
*/
pmIdentifier?: string;
trello?: {
boardId: string;
lists: Record<string, string>;
Expand Down Expand Up @@ -88,10 +100,21 @@ export async function loadProjectConfig(): Promise<{
const trelloConfig = getTrelloConfig(p);
const jiraConfig = getJiraConfig(p);
const linearConfig = getLinearConfig(p);
const pmType = p.pm?.type ?? 'trello';

// Derive the generic pmIdentifier from the active provider's config.
// Uses PM_IDENTIFIER_KEYS to map pmType → the config key that holds the
// identifier (e.g. trello→boardId, jira→projectKey, linear→teamId).
const pmConfig = getPMConfig(p);
const identifierKey = PM_IDENTIFIER_KEYS[pmType];
const pmIdentifier: string | undefined =
pmConfig && identifierKey ? (pmConfig[identifierKey] as string | undefined) : undefined;

return {
id: p.id,
repo: p.repo,
pmType: p.pm?.type ?? 'trello',
pmType,
pmIdentifier,
...(trelloConfig && {
trello: {
boardId: trelloConfig.boardId,
Expand Down
10 changes: 7 additions & 3 deletions src/router/platformClients/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function resolveLinearCredentials(
*/
export async function resolveWebhookSecret(
projectId: string,
provider: 'github' | 'trello' | 'jira' | 'sentry' | 'linear',
provider: string,
): Promise<string | null> {
if (provider === 'github') {
return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret');
Expand All @@ -95,8 +95,12 @@ export async function resolveWebhookSecret(
if (provider === 'linear') {
return getIntegrationCredentialOrNull(projectId, 'pm', 'webhook_secret');
}
// Trello signs webhook payloads with the API Secret, not the public API Key.
return getIntegrationCredentialOrNull(projectId, 'pm', 'api_secret');
if (provider === 'trello') {
// Trello signs webhook payloads with the API Secret, not the public API Key.
return getIntegrationCredentialOrNull(projectId, 'pm', 'api_secret');
}
// Unknown provider — return null rather than silently falling back to Trello credentials.
return null;
}

/**
Expand Down
26 changes: 25 additions & 1 deletion src/router/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,31 @@ export interface LinearJob {
triggerResult?: TriggerResult;
}

export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob | LinearJob;
/**
* Unified PM job — represents a webhook event from any PM provider (Trello, JIRA, Linear).
*
* Replaces the per-provider `TrelloJob`, `JiraJob`, `LinearJob` discriminated union members
* with a single generic shape. The `source` field carries the provider name (e.g. 'trello')
* so workers can still route to the correct webhook handler.
*
* Per-provider job types are kept for backward compatibility with jobs already in Redis.
* New code should prefer `PMJob` when dispatching PM events.
*/
export interface PMJob {
type: 'pm';
/** PM provider identifier — e.g. 'trello', 'jira', 'linear' */
source: string;
payload: unknown;
projectId: string;
workItemId?: string;
/** Provider-specific event type (actionType, webhookEvent, Linear event type) */
eventType: string;
receivedAt: string;
ackCommentId?: string;
triggerResult?: TriggerResult;
}

export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob | LinearJob | PMJob;

// Create the job queue
export const jobQueue = new Queue<CascadeJob>('cascade-jobs', {
Expand Down
Loading
Loading