diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index cdd3bdfb..e2a68129 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { getIntegrationCredentialOrNull } from '../../config/provider.js'; import { getIntegrationByProjectAndCategory } from '../../db/repositories/integrationsRepository.js'; import { jiraClient, withJiraCredentials } from '../../jira/client.js'; +import { linearClient, withLinearCredentials } from '../../linear/client.js'; import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, router } from '../trpc.js'; @@ -27,6 +28,10 @@ const jiraCredsInput = z.object({ baseUrl: z.string().url(), }); +const linearCredsInput = z.object({ + apiKey: z.string().min(1), +}); + async function withTrelloCreds( input: z.infer, label: string, @@ -45,6 +50,14 @@ async function withJiraCreds( ); } +async function withLinearCreds( + input: z.infer, + label: string, + fn: (creds: { apiKey: string }) => Promise, +): Promise { + return wrapIntegrationCall(label, () => fn({ apiKey: input.apiKey })); +} + export const integrationsDiscoveryRouter = router({ verifyTrello: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => { logger.debug('integrationsDiscovery.verifyTrello called', { orgId: ctx.effectiveOrgId }); @@ -429,4 +442,108 @@ export const integrationsDiscoveryRouter = router({ }; }); }), + + /** + * Verify a raw Linear API key. + * Accepts a plaintext API key from the form and calls getMe() to verify it. + * Returns the authenticated user's id, name, and displayName. + */ + verifyLinear: protectedProcedure.input(linearCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyLinear called', { orgId: ctx.effectiveOrgId }); + return withLinearCreds(input, 'Failed to verify Linear credentials', (creds) => + withLinearCredentials(creds, () => + linearClient.getMe().then((me) => ({ + id: me.id, + name: me.name, + displayName: me.displayName, + })), + ), + ); + }), + + /** + * Fetch Linear teams using raw API key credentials. + * Returns all teams accessible by the provided API key. + */ + linearTeams: protectedProcedure.input(linearCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearTeams called', { orgId: ctx.effectiveOrgId }); + return withLinearCreds(input, 'Failed to fetch Linear teams', (creds) => + withLinearCredentials(creds, () => linearClient.getTeams()), + ); + }), + + /** + * Fetch Linear teams using stored project credentials. + * Resolves the API key from the project's stored credentials and returns all teams. + */ + linearTeamsByProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearTeamsByProject called', { + orgId: ctx.effectiveOrgId, + projectId: input.projectId, + }); + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + const apiKey = await getIntegrationCredentialOrNull(input.projectId, 'pm', 'api_key'); + if (!apiKey) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Linear credentials not configured', + }); + } + return wrapIntegrationCall('Failed to fetch Linear teams', () => + withLinearCredentials({ apiKey }, () => linearClient.getTeams()), + ); + }), + + /** + * Fetch Linear team workflow states and labels using raw API key credentials. + * Returns both states and labels for the given teamId. + */ + linearTeamDetails: protectedProcedure + .input(linearCredsInput.extend({ teamId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearTeamDetails called', { + orgId: ctx.effectiveOrgId, + teamId: input.teamId, + }); + return withLinearCreds(input, 'Failed to fetch Linear team details', (creds) => + withLinearCredentials(creds, () => + Promise.all([ + linearClient.getTeamWorkflowStates(input.teamId), + linearClient.getTeamLabels(input.teamId), + ]).then(([states, labels]) => ({ states, labels })), + ), + ); + }), + + /** + * Fetch Linear team workflow states and labels using stored project credentials. + * Resolves the API key from stored credentials and returns states and labels for the team. + */ + linearTeamDetailsByProject: protectedProcedure + .input(z.object({ projectId: z.string(), teamId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.linearTeamDetailsByProject called', { + orgId: ctx.effectiveOrgId, + projectId: input.projectId, + teamId: input.teamId, + }); + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + const apiKey = await getIntegrationCredentialOrNull(input.projectId, 'pm', 'api_key'); + if (!apiKey) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Linear credentials not configured', + }); + } + return wrapIntegrationCall('Failed to fetch Linear team details', () => + withLinearCredentials({ apiKey }, () => + Promise.all([ + linearClient.getTeamWorkflowStates(input.teamId), + linearClient.getTeamLabels(input.teamId), + ]).then(([states, labels]) => ({ states, labels })), + ), + ); + }), }); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 6eab94b8..83f82479 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -15,6 +15,10 @@ const { mockJiraGetIssueTypesForProject, mockJiraGetFields, mockJiraCreateCustomField, + mockLinearGetMe, + mockLinearGetTeams, + mockLinearGetTeamWorkflowStates, + mockLinearGetTeamLabels, mockGetAuthenticated, mockVerifyProjectOrgAccess, mockGetIntegrationCredentialOrNull, @@ -33,6 +37,10 @@ const { mockJiraGetIssueTypesForProject: vi.fn(), mockJiraGetFields: vi.fn(), mockJiraCreateCustomField: vi.fn(), + mockLinearGetMe: vi.fn(), + mockLinearGetTeams: vi.fn(), + mockLinearGetTeamWorkflowStates: vi.fn(), + mockLinearGetTeamLabels: vi.fn(), mockGetAuthenticated: vi.fn(), mockVerifyProjectOrgAccess: vi.fn(), mockGetIntegrationCredentialOrNull: vi.fn(), @@ -70,6 +78,19 @@ vi.mock('../../../../src/jira/client.js', () => ({ }, })); +vi.mock('../../../../src/linear/client.js', () => ({ + withLinearCredentials: (...args: unknown[]) => { + const cb = args[1] as () => unknown; + return cb(); + }, + linearClient: { + getMe: mockLinearGetMe, + getTeams: mockLinearGetTeams, + getTeamWorkflowStates: mockLinearGetTeamWorkflowStates, + getTeamLabels: mockLinearGetTeamLabels, + }, +})); + vi.mock('../../../../src/utils/logging.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); @@ -182,6 +203,37 @@ describe('integrationsDiscoveryRouter', () => { 'UNAUTHORIZED', ); }); + + it('verifyLinear throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError(caller.verifyLinear({ apiKey: 'lin_api_test' }), 'UNAUTHORIZED'); + }); + + it('linearTeams throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError(caller.linearTeams({ apiKey: 'lin_api_test' }), 'UNAUTHORIZED'); + }); + + it('linearTeamsByProject throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError(caller.linearTeamsByProject({ projectId: 'proj-1' }), 'UNAUTHORIZED'); + }); + + it('linearTeamDetails throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.linearTeamDetails({ apiKey: 'lin_api_test', teamId: 'team-1' }), + 'UNAUTHORIZED', + ); + }); + + it('linearTeamDetailsByProject throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.linearTeamDetailsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + 'UNAUTHORIZED', + ); + }); }); // ── verifyTrello ───────────────────────────────────────────────────── @@ -960,6 +1012,224 @@ describe('integrationsDiscoveryRouter', () => { }); }); + // ── verifyLinear ───────────────────────────────────────────────────── + + describe('verifyLinear', () => { + const linearCredsInput = { apiKey: 'lin_api_test' }; + + it('returns id, name, and displayName on success', async () => { + mockLinearGetMe.mockResolvedValue({ + id: 'linear-user-123', + name: 'Linear User', + displayName: 'linearuser', + email: 'linear@example.com', + avatarUrl: null, + active: true, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyLinear(linearCredsInput); + + expect(result).toEqual({ + id: 'linear-user-123', + name: 'Linear User', + displayName: 'linearuser', + }); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + mockLinearGetMe.mockRejectedValue(new Error('Invalid API key')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyLinear(linearCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('rejects empty apiKey', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyLinear({ apiKey: '' })).rejects.toThrow(); + }); + }); + + // ── linearTeams ─────────────────────────────────────────────────────── + + describe('linearTeams', () => { + const linearCredsInput = { apiKey: 'lin_api_test' }; + + it('returns teams list on success', async () => { + const teams = [ + { id: 'team-1', name: 'Engineering', key: 'ENG', description: null }, + { id: 'team-2', name: 'Design', key: 'DES', description: 'Design team' }, + ]; + mockLinearGetTeams.mockResolvedValue(teams); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearTeams(linearCredsInput); + + expect(result).toEqual(teams); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + mockLinearGetTeams.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.linearTeams(linearCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── linearTeamsByProject ────────────────────────────────────────────── + + describe('linearTeamsByProject', () => { + it('returns teams using stored project credentials', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + const teams = [{ id: 'team-1', name: 'Engineering', key: 'ENG', description: null }]; + mockLinearGetTeams.mockResolvedValue(teams); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearTeamsByProject({ projectId: 'proj-1' }); + + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('proj-1', mockUser.orgId); + expect(result).toEqual(teams); + }); + + it('throws NOT_FOUND when apiKey credential is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.linearTeamsByProject({ projectId: 'proj-1' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('propagates org access denial', async () => { + const { TRPCError } = await import('@trpc/server'); + mockVerifyProjectOrgAccess.mockRejectedValue( + new TRPCError({ code: 'FORBIDDEN', message: 'Access denied' }), + ); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamsByProject({ projectId: 'other-org-proj' }), + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + + it('wraps Linear API failure in BAD_REQUEST', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + mockLinearGetTeams.mockRejectedValue(new Error('API error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.linearTeamsByProject({ projectId: 'proj-1' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── linearTeamDetails ───────────────────────────────────────────────── + + describe('linearTeamDetails', () => { + const linearCredsInput = { apiKey: 'lin_api_test' }; + + it('returns states and labels on success', async () => { + const states = [ + { id: 'state-1', name: 'Todo', type: 'unstarted', color: '#aaa' }, + { id: 'state-2', name: 'In Progress', type: 'started', color: '#bbb' }, + ]; + const labels = [ + { id: 'label-1', name: 'Bug', color: '#f00', description: null }, + { id: 'label-2', name: 'Feature', color: '#0f0', description: 'New feature' }, + ]; + mockLinearGetTeamWorkflowStates.mockResolvedValue(states); + mockLinearGetTeamLabels.mockResolvedValue(labels); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearTeamDetails({ ...linearCredsInput, teamId: 'team-1' }); + + expect(result).toEqual({ states, labels }); + expect(mockLinearGetTeamWorkflowStates).toHaveBeenCalledWith('team-1'); + expect(mockLinearGetTeamLabels).toHaveBeenCalledWith('team-1'); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + mockLinearGetTeamWorkflowStates.mockRejectedValue(new Error('Team not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamDetails({ ...linearCredsInput, teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('rejects empty teamId', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.linearTeamDetails({ ...linearCredsInput, teamId: '' })).rejects.toThrow(); + }); + }); + + // ── linearTeamDetailsByProject ──────────────────────────────────────── + + describe('linearTeamDetailsByProject', () => { + it('returns team details using stored project credentials', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + const states = [{ id: 'state-1', name: 'Todo', type: 'unstarted', color: '#aaa' }]; + const labels = [{ id: 'label-1', name: 'Bug', color: '#f00', description: null }]; + mockLinearGetTeamWorkflowStates.mockResolvedValue(states); + mockLinearGetTeamLabels.mockResolvedValue(labels); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.linearTeamDetailsByProject({ + projectId: 'proj-1', + teamId: 'team-1', + }); + + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('proj-1', mockUser.orgId); + expect(result).toEqual({ states, labels }); + expect(mockLinearGetTeamWorkflowStates).toHaveBeenCalledWith('team-1'); + expect(mockLinearGetTeamLabels).toHaveBeenCalledWith('team-1'); + }); + + it('throws NOT_FOUND when apiKey credential is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamDetailsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('propagates org access denial', async () => { + const { TRPCError } = await import('@trpc/server'); + mockVerifyProjectOrgAccess.mockRejectedValue( + new TRPCError({ code: 'FORBIDDEN', message: 'Access denied' }), + ); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamDetailsByProject({ projectId: 'other-org-proj', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('rejects empty teamId', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamDetailsByProject({ projectId: 'proj-1', teamId: '' }), + ).rejects.toThrow(); + }); + + it('wraps Linear API failure in BAD_REQUEST', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('stored-api-key'); + mockLinearGetTeamWorkflowStates.mockRejectedValue(new Error('Team not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.linearTeamDetailsByProject({ projectId: 'proj-1', teamId: 'team-1' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + // ── verifySentry ───────────────────────────────────────────────────── describe('verifySentry', () => {