Skip to content
Merged
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
11 changes: 11 additions & 0 deletions src/config/configCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ConfigCache {
private projectByBoardId = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectByRepo = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectByJiraKey = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectByLinearTeamId = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private orgIdByProject = new Map<string, CacheEntry<string>>();
private ttlMs: number;

Expand Down Expand Up @@ -62,6 +63,15 @@ class ConfigCache {
this.projectByJiraKey.set(projectKey, this.makeEntry(project));
}

getProjectByLinearTeamId(teamId: string): ProjectConfig | undefined | null {
const entry = this.projectByLinearTeamId.get(teamId);
return this.isValid(entry) ? entry.data : null;
}

setProjectByLinearTeamId(teamId: string, project: ProjectConfig | undefined): void {
this.projectByLinearTeamId.set(teamId, this.makeEntry(project));
}

getOrgIdForProject(projectId: string): string | null {
const entry = this.orgIdByProject.get(projectId);
return this.isValid(entry) ? entry.data : null;
Expand All @@ -76,6 +86,7 @@ class ConfigCache {
this.projectByBoardId.clear();
this.projectByRepo.clear();
this.projectByJiraKey.clear();
this.projectByLinearTeamId.clear();
this.orgIdByProject.clear();
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import {
findProjectByBoardIdFromDb,
findProjectByIdFromDb,
findProjectByJiraProjectKeyFromDb,
findProjectByLinearTeamIdFromDb,
findProjectByRepoFromDb,
findProjectWithConfigByBoardId,
findProjectWithConfigById,
findProjectWithConfigByJiraProjectKey,
findProjectWithConfigByLinearTeamId,
findProjectWithConfigByRepo,
loadConfigFromDb,
} from '../db/repositories/configRepository.js';
Expand Down Expand Up @@ -55,6 +57,17 @@ export async function findProjectByJiraProjectKey(
return project;
}

export async function findProjectByLinearTeamId(
teamId: string,
): Promise<ProjectConfig | undefined> {
const cached = configCache.getProjectByLinearTeamId(teamId);
if (cached !== null) return cached;

const project = await findProjectByLinearTeamIdFromDb(teamId);
configCache.setProjectByLinearTeamId(teamId, project);
return project;
}

export async function findProjectById(id: string): Promise<ProjectConfig | undefined> {
// No cache for by-id lookups (less frequent, PK is fast)
return findProjectByIdFromDb(id);
Expand Down Expand Up @@ -82,6 +95,12 @@ export async function loadProjectConfigByJiraProjectKey(
return findProjectWithConfigByJiraProjectKey(projectKey);
}

export async function loadProjectConfigByLinearTeamId(
teamId: string,
): Promise<ProjectWithConfig | undefined> {
return findProjectWithConfigByLinearTeamId(teamId);
}

export async function loadProjectConfigById(id: string): Promise<ProjectWithConfig | undefined> {
return findProjectWithConfigById(id);
}
Expand Down
50 changes: 48 additions & 2 deletions src/db/repositories/configMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ export interface JiraIntegrationConfig {
labels?: Record<string, string>;
}

export interface LinearIntegrationConfig {
teamId: string;
statuses: Record<string, string>;
labels?: {
processing?: string;
processed?: string;
error?: string;
readyToProcess?: string;
auto?: string;
};
customFields?: { cost?: string };
}

// biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials)
export type GitHubIntegrationConfig = {};

Expand Down Expand Up @@ -60,6 +73,7 @@ export interface MapProjectInput {
projectAgentConfigs: AgentConfigRow[];
trelloConfig?: TrelloIntegrationConfig;
jiraConfig?: JiraIntegrationConfig;
linearConfig?: LinearIntegrationConfig;
githubConfig?: GitHubIntegrationConfig;
}

Expand Down Expand Up @@ -103,6 +117,18 @@ export interface ProjectConfigRaw {
customFields?: { cost?: string };
labels?: Record<string, string>;
};
linear?: {
teamId: string;
statuses: Record<string, string>;
labels?: {
processing?: string;
processed?: string;
error?: string;
readyToProcess?: string;
auto?: string;
};
customFields?: { cost?: string };
};
agentEngine?: {
default?: string;
overrides: Record<string, string>;
Expand Down Expand Up @@ -181,6 +207,15 @@ function buildJiraConfig(config: JiraIntegrationConfig): ProjectConfigRaw['jira'
};
}

function buildLinearConfig(config: LinearIntegrationConfig): ProjectConfigRaw['linear'] {
return {
teamId: config.teamId,
statuses: config.statuses,
labels: config.labels,
customFields: config.customFields,
};
}

function buildAgentEngineConfig(
row: ProjectRow,
engines: Record<string, string>,
Expand All @@ -192,7 +227,10 @@ function buildAgentEngineConfig(
};
}

function buildBaseProjectFields(row: ProjectRow, pmType: 'trello' | 'jira'): ProjectConfigRaw {
function buildBaseProjectFields(
row: ProjectRow,
pmType: 'trello' | 'jira' | 'linear',
): ProjectConfigRaw {
return {
id: row.id,
orgId: row.orgId,
Expand Down Expand Up @@ -222,15 +260,18 @@ function buildBaseProjectFields(row: ProjectRow, pmType: 'trello' | 'jira'): Pro
export function extractIntegrationConfigs(integrations: IntegrationRow[]): {
trelloConfig?: TrelloIntegrationConfig;
jiraConfig?: JiraIntegrationConfig;
linearConfig?: LinearIntegrationConfig;
githubConfig?: GitHubIntegrationConfig;
} {
const trelloRow = integrations.find((i) => i.provider === 'trello');
const jiraRow = integrations.find((i) => i.provider === 'jira');
const linearRow = integrations.find((i) => i.provider === 'linear');
const githubRow = integrations.find((i) => i.provider === 'github');

return {
trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined,
jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined,
linearConfig: linearRow?.config as LinearIntegrationConfig | undefined,
githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined,
};
}
Expand All @@ -240,6 +281,7 @@ export function mapProjectRow({
projectAgentConfigs,
trelloConfig,
jiraConfig,
linearConfig,
}: MapProjectInput): ProjectConfigRaw {
const {
models,
Expand All @@ -248,7 +290,7 @@ export function mapProjectRow({
} = buildAgentMaps(projectAgentConfigs);

// Derive PM type from integration config
const pmType = jiraConfig ? 'jira' : 'trello';
const pmType = jiraConfig ? 'jira' : linearConfig ? 'linear' : 'trello';

const project: ProjectConfigRaw = {
...buildBaseProjectFields(row, pmType),
Expand All @@ -266,6 +308,10 @@ export function mapProjectRow({
project.jira = buildJiraConfig(jiraConfig);
}

if (linearConfig) {
project.linear = buildLinearConfig(linearConfig);
}

const agentEngine = buildAgentEngineConfig(row, engines);
if (agentEngine) {
project.agentEngine = agentEngine;
Expand Down
23 changes: 22 additions & 1 deletion src/db/repositories/configRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ function buildRawConfig({
return {
projects: projectRows.map((row) => {
const integrations = integrationsByProject.get(row.id) ?? [];
const { trelloConfig, jiraConfig, githubConfig } = extractIntegrationConfigs(integrations);
const { trelloConfig, jiraConfig, linearConfig, githubConfig } =
extractIntegrationConfigs(integrations);
return mapProjectRow({
row,
projectAgentConfigs: projectAgentConfigsMap.get(row.id) ?? [],
trelloConfig,
jiraConfig,
linearConfig,
githubConfig,
});
}),
Expand Down Expand Up @@ -126,6 +128,13 @@ const jiraProjectKeyWhereClause = (projectKey: string) =>
AND ${projectIntegrations.config}->>'projectKey' = ${projectKey}
)`;

const linearTeamIdWhereClause = (teamId: string) =>
sql`${projects.id} IN (
SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations}
WHERE ${projectIntegrations.provider} = 'linear'
AND ${projectIntegrations.config}->>'teamId' = ${teamId}
)`;

export function findProjectByBoardIdFromDb(boardId: string): Promise<ProjectConfig | undefined> {
return findProjectFromDb(boardIdWhereClause(boardId));
}
Expand All @@ -144,6 +153,12 @@ export function findProjectByJiraProjectKeyFromDb(
return findProjectFromDb(jiraProjectKeyWhereClause(projectKey));
}

export function findProjectByLinearTeamIdFromDb(
teamId: string,
): Promise<ProjectConfig | undefined> {
return findProjectFromDb(linearTeamIdWhereClause(teamId));
}

// WithConfig variants — return both the project and its org-scoped CascadeConfig

export function findProjectWithConfigByBoardId(
Expand All @@ -165,3 +180,9 @@ export function findProjectWithConfigByJiraProjectKey(
): Promise<ProjectWithConfig | undefined> {
return findProjectConfigFromDb(jiraProjectKeyWhereClause(projectKey));
}

export function findProjectWithConfigByLinearTeamId(
teamId: string,
): Promise<ProjectWithConfig | undefined> {
return findProjectConfigFromDb(linearTeamIdWhereClause(teamId));
}
41 changes: 41 additions & 0 deletions tests/unit/config/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({
findProjectByBoardIdFromDb: vi.fn(),
findProjectByRepoFromDb: vi.fn(),
findProjectByJiraProjectKeyFromDb: vi.fn(),
findProjectByLinearTeamIdFromDb: vi.fn(),
findProjectByIdFromDb: vi.fn(),
findProjectWithConfigByLinearTeamId: vi.fn(),
}));

vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({
Expand All @@ -25,6 +27,8 @@ vi.mock('../../../src/config/configCache.js', () => ({
setProjectByRepo: vi.fn(),
getProjectByJiraKey: vi.fn(),
setProjectByJiraKey: vi.fn(),
getProjectByLinearTeamId: vi.fn(),
setProjectByLinearTeamId: vi.fn(),
getOrgIdForProject: vi.fn(),
setOrgIdForProject: vi.fn(),
invalidate: vi.fn(),
Expand All @@ -38,6 +42,7 @@ import {
findProjectByBoardId,
findProjectById,
findProjectByJiraProjectKey,
findProjectByLinearTeamId,
findProjectByRepo,
getAllProjectCredentials,
getIntegrationCredential,
Expand All @@ -51,6 +56,7 @@ import {
findProjectByBoardIdFromDb,
findProjectByIdFromDb,
findProjectByJiraProjectKeyFromDb,
findProjectByLinearTeamIdFromDb,
findProjectByRepoFromDb,
loadConfigFromDb,
} from '../../../src/db/repositories/configRepository.js';
Expand Down Expand Up @@ -253,6 +259,41 @@ describe('config/provider', () => {
});
});

describe('findProjectByLinearTeamId', () => {
it('returns cached project when available', async () => {
vi.mocked(configCache.getProjectByLinearTeamId).mockReturnValue(mockProject);

const result = await findProjectByLinearTeamId('team-abc123');

expect(result).toBe(mockProject);
expect(findProjectByLinearTeamIdFromDb).not.toHaveBeenCalled();
});

it('loads project from DB when not cached', async () => {
vi.mocked(configCache.getProjectByLinearTeamId).mockReturnValue(null);
vi.mocked(findProjectByLinearTeamIdFromDb).mockResolvedValue(mockProject);

const result = await findProjectByLinearTeamId('team-abc123');

expect(result).toBe(mockProject);
expect(findProjectByLinearTeamIdFromDb).toHaveBeenCalledWith('team-abc123');
expect(configCache.setProjectByLinearTeamId).toHaveBeenCalledWith('team-abc123', mockProject);
});

it('caches undefined when project not found', async () => {
vi.mocked(configCache.getProjectByLinearTeamId).mockReturnValue(null);
vi.mocked(findProjectByLinearTeamIdFromDb).mockResolvedValue(undefined);

const result = await findProjectByLinearTeamId('nonexistent-team');

expect(result).toBeUndefined();
expect(configCache.setProjectByLinearTeamId).toHaveBeenCalledWith(
'nonexistent-team',
undefined,
);
});
});

describe('findProjectById', () => {
it('does not use cache for by-id lookups', async () => {
vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject);
Expand Down
Loading
Loading