From c6d1ad33c73c9af15add4e6f6f646c25565eee8b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 19:40:23 +0000 Subject: [PATCH] feat(linear): register Linear in bootstrap and add DB lookup functions --- src/config/configCache.ts | 11 ++++ src/config/provider.ts | 19 ++++++ src/db/repositories/configMapper.ts | 50 +++++++++++++++- src/db/repositories/configRepository.ts | 23 ++++++- tests/unit/config/provider.test.ts | 41 +++++++++++++ .../unit/db/repositories/configMapper.test.ts | 46 ++++++++++++++ .../db/repositories/configRepository.test.ts | 60 +++++++++++++++++++ 7 files changed, 247 insertions(+), 3 deletions(-) diff --git a/src/config/configCache.ts b/src/config/configCache.ts index 8aa5b643..d9075617 100644 --- a/src/config/configCache.ts +++ b/src/config/configCache.ts @@ -12,6 +12,7 @@ class ConfigCache { private projectByBoardId = new Map>(); private projectByRepo = new Map>(); private projectByJiraKey = new Map>(); + private projectByLinearTeamId = new Map>(); private orgIdByProject = new Map>(); private ttlMs: number; @@ -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; @@ -76,6 +86,7 @@ class ConfigCache { this.projectByBoardId.clear(); this.projectByRepo.clear(); this.projectByJiraKey.clear(); + this.projectByLinearTeamId.clear(); this.orgIdByProject.clear(); } } diff --git a/src/config/provider.ts b/src/config/provider.ts index d26f9a7d..437e142e 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -2,10 +2,12 @@ import { findProjectByBoardIdFromDb, findProjectByIdFromDb, findProjectByJiraProjectKeyFromDb, + findProjectByLinearTeamIdFromDb, findProjectByRepoFromDb, findProjectWithConfigByBoardId, findProjectWithConfigById, findProjectWithConfigByJiraProjectKey, + findProjectWithConfigByLinearTeamId, findProjectWithConfigByRepo, loadConfigFromDb, } from '../db/repositories/configRepository.js'; @@ -55,6 +57,17 @@ export async function findProjectByJiraProjectKey( return project; } +export async function findProjectByLinearTeamId( + teamId: string, +): Promise { + 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 { // No cache for by-id lookups (less frequent, PK is fast) return findProjectByIdFromDb(id); @@ -82,6 +95,12 @@ export async function loadProjectConfigByJiraProjectKey( return findProjectWithConfigByJiraProjectKey(projectKey); } +export async function loadProjectConfigByLinearTeamId( + teamId: string, +): Promise { + return findProjectWithConfigByLinearTeamId(teamId); +} + export async function loadProjectConfigById(id: string): Promise { return findProjectWithConfigById(id); } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 64889b01..ad8a2aa3 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -28,6 +28,19 @@ export interface JiraIntegrationConfig { labels?: Record; } +export interface LinearIntegrationConfig { + teamId: string; + statuses: Record; + 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 = {}; @@ -60,6 +73,7 @@ export interface MapProjectInput { projectAgentConfigs: AgentConfigRow[]; trelloConfig?: TrelloIntegrationConfig; jiraConfig?: JiraIntegrationConfig; + linearConfig?: LinearIntegrationConfig; githubConfig?: GitHubIntegrationConfig; } @@ -103,6 +117,18 @@ export interface ProjectConfigRaw { customFields?: { cost?: string }; labels?: Record; }; + linear?: { + teamId: string; + statuses: Record; + labels?: { + processing?: string; + processed?: string; + error?: string; + readyToProcess?: string; + auto?: string; + }; + customFields?: { cost?: string }; + }; agentEngine?: { default?: string; overrides: Record; @@ -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, @@ -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, @@ -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, }; } @@ -240,6 +281,7 @@ export function mapProjectRow({ projectAgentConfigs, trelloConfig, jiraConfig, + linearConfig, }: MapProjectInput): ProjectConfigRaw { const { models, @@ -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), @@ -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; diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 558a6a04..9eb4bc6c 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -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, }); }), @@ -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 { return findProjectFromDb(boardIdWhereClause(boardId)); } @@ -144,6 +153,12 @@ export function findProjectByJiraProjectKeyFromDb( return findProjectFromDb(jiraProjectKeyWhereClause(projectKey)); } +export function findProjectByLinearTeamIdFromDb( + teamId: string, +): Promise { + return findProjectFromDb(linearTeamIdWhereClause(teamId)); +} + // WithConfig variants — return both the project and its org-scoped CascadeConfig export function findProjectWithConfigByBoardId( @@ -165,3 +180,9 @@ export function findProjectWithConfigByJiraProjectKey( ): Promise { return findProjectConfigFromDb(jiraProjectKeyWhereClause(projectKey)); } + +export function findProjectWithConfigByLinearTeamId( + teamId: string, +): Promise { + return findProjectConfigFromDb(linearTeamIdWhereClause(teamId)); +} diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts index 35e0a815..49505718 100644 --- a/tests/unit/config/provider.test.ts +++ b/tests/unit/config/provider.test.ts @@ -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', () => ({ @@ -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(), @@ -38,6 +42,7 @@ import { findProjectByBoardId, findProjectById, findProjectByJiraProjectKey, + findProjectByLinearTeamId, findProjectByRepo, getAllProjectCredentials, getIntegrationCredential, @@ -51,6 +56,7 @@ import { findProjectByBoardIdFromDb, findProjectByIdFromDb, findProjectByJiraProjectKeyFromDb, + findProjectByLinearTeamIdFromDb, findProjectByRepoFromDb, loadConfigFromDb, } from '../../../src/db/repositories/configRepository.js'; @@ -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); diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 5decb6fa..f97ff7d4 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -68,6 +68,19 @@ const githubIntegrationRow: IntegrationRow = { config: {}, }; +const linearConfig = { + teamId: 'team-abc123', + statuses: { todo: 'Todo', inProgress: 'In Progress', done: 'Done' }, + labels: { processing: 'label-processing', readyToProcess: 'label-ready' }, +}; + +const linearIntegrationRow: IntegrationRow = { + projectId: 'proj1', + category: 'pm', + provider: 'linear', + config: linearConfig, +}; + // --------------------------------------------------------------------------- // orUndefined // --------------------------------------------------------------------------- @@ -212,10 +225,18 @@ describe('extractIntegrationConfigs', () => { expect(result.githubConfig).toEqual({}); }); + it('extracts linear config from integration rows', () => { + const result = extractIntegrationConfigs([linearIntegrationRow]); + expect(result.linearConfig).toEqual(linearConfig); + expect(result.trelloConfig).toBeUndefined(); + expect(result.jiraConfig).toBeUndefined(); + }); + it('handles empty integration list', () => { const result = extractIntegrationConfigs([]); expect(result.trelloConfig).toBeUndefined(); expect(result.jiraConfig).toBeUndefined(); + expect(result.linearConfig).toBeUndefined(); expect(result.githubConfig).toBeUndefined(); }); @@ -225,6 +246,7 @@ describe('extractIntegrationConfigs', () => { expect(result.trelloConfig).toEqual(trelloConfig); expect(result.githubConfig).toEqual({}); expect(result.jiraConfig).toBeUndefined(); + expect(result.linearConfig).toBeUndefined(); }); }); @@ -272,6 +294,11 @@ describe('mapProjectRow', () => { expect(result.pm.type).toBe('jira'); }); + it('sets pm.type to linear when linearConfig is provided', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, linearConfig })); + expect(result.pm.type).toBe('linear'); + }); + it('builds trello config with boardId, lists, labels', () => { const result = mapProjectRow(makeInput()); expect(result.trello?.boardId).toBe('board123'); @@ -286,6 +313,25 @@ describe('mapProjectRow', () => { expect(result.jira?.statuses).toEqual({ splitting: 'Briefing', todo: 'To Do' }); }); + it('builds linear config with teamId, statuses, and labels', () => { + const result = mapProjectRow(makeInput({ trelloConfig: undefined, linearConfig })); + expect(result.linear?.teamId).toBe('team-abc123'); + expect(result.linear?.statuses).toEqual({ + todo: 'Todo', + inProgress: 'In Progress', + done: 'Done', + }); + expect(result.linear?.labels).toEqual({ + processing: 'label-processing', + readyToProcess: 'label-ready', + }); + }); + + it('does not include linear field when linearConfig is not provided', () => { + const result = mapProjectRow(makeInput()); + expect(result.linear).toBeUndefined(); + }); + it('omits agentEngine when neither row.agentEngine nor agent overrides are set', () => { const result = mapProjectRow(makeInput()); expect(result.agentEngine).toBeUndefined(); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index 58481daa..0914a0e5 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -6,6 +6,7 @@ vi.mock('../../../../src/db/client.js', () => mockDbClientModule); import { findProjectByBoardIdFromDb, findProjectByIdFromDb, + findProjectByLinearTeamIdFromDb, findProjectByRepoFromDb, loadConfigFromDb, } from '../../../../src/db/repositories/configRepository.js'; @@ -68,6 +69,21 @@ const jiraIntegration = { updatedAt: new Date(), }; +const linearIntegration = { + id: 4, + projectId: 'proj1', + category: 'pm' as const, + provider: 'linear' as const, + config: { + teamId: 'team-abc123', + statuses: { todo: 'Todo', inProgress: 'In Progress' }, + labels: { processing: 'cascade-processing', readyToProcess: 'cascade-ready' }, + }, + triggers: {}, + createdAt: new Date(), + updatedAt: new Date(), +}; + const projectAgentConfig = { id: 2, projectId: 'proj1', @@ -507,4 +523,48 @@ describe('configRepository', () => { expect(proj.snapshotTtlMs).toBeUndefined(); }); }); + + describe('Linear integration', () => { + it('loads config with Linear integration from project_integrations', async () => { + const mockDb = createSequentialMockDb([[projectRow], [], [linearIntegration]]); + mockGetDb.mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.projects).toHaveLength(1); + const proj = config.projects[0]; + expect(proj.pm?.type).toBe('linear'); + expect(proj.linear?.teamId).toBe('team-abc123'); + expect(proj.linear?.statuses).toEqual({ todo: 'Todo', inProgress: 'In Progress' }); + expect(proj.linear?.labels?.processing).toBe('cascade-processing'); + expect(proj.linear?.labels?.readyToProcess).toBe('cascade-ready'); + }); + }); + + describe('findProjectByLinearTeamIdFromDb', () => { + it('returns project found via integrations teamId subquery', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], // subquery finds project + [], + [linearIntegration], + ]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectByLinearTeamIdFromDb('team-abc123'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('proj1'); + expect(result?.linear?.teamId).toBe('team-abc123'); + expect(result?.pm?.type).toBe('linear'); + }); + + it('returns undefined when no project has matching Linear team ID', async () => { + const mockDb = createSequentialMockDb([[]]); + mockGetDb.mockReturnValue(mockDb as never); + + const result = await findProjectByLinearTeamIdFromDb('nonexistent-team'); + + expect(result).toBeUndefined(); + }); + }); });