From da3867058779411adee967aeaa3494d8c4c143c0 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 21:38:33 +0000 Subject: [PATCH] feat(linear): add getTeams, getTeamWorkflowStates, getTeamLabels discovery methods --- src/linear/client.ts | 70 ++++++ tests/unit/pm/linear/client.test.ts | 324 ++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 tests/unit/pm/linear/client.test.ts diff --git a/src/linear/client.ts b/src/linear/client.ts index e4a3a429..36469d47 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -18,8 +18,10 @@ import type { LinearIssue, LinearLabel, LinearReaction, + LinearTeam, LinearUpdateIssueInput, LinearUser, + LinearWorkflowState, } from './types.js'; const LINEAR_API_URL = 'https://api.linear.app/graphql'; @@ -580,6 +582,74 @@ export const linearClient = { }; }, + // ===== Discovery ===== + + async getTeams(): Promise { + logger.debug('Fetching Linear teams'); + const data = await linearGraphQL<{ teams: { nodes: unknown[] } }>( + `query GetTeams { + teams { + nodes { + ${TEAM_FIELDS} + } + } + }`, + ); + return (data.teams.nodes as RawIssue['team'][]).map(mapTeam); + }, + + async getTeamWorkflowStates(teamId: string): Promise { + logger.debug('Fetching Linear team workflow states', { teamId }); + const data = await linearGraphQL<{ + team: { states: { nodes: unknown[] } }; + }>( + `query GetTeamWorkflowStates($id: String!) { + team(id: $id) { + states { + nodes { + ${STATE_FIELDS} + } + } + } + }`, + { id: teamId }, + ); + return ( + data.team.states.nodes as Array<{ + id?: string; + name?: string; + type?: string; + color?: string; + }> + ).map(mapState); + }, + + async getTeamLabels(teamId: string): Promise { + logger.debug('Fetching Linear team labels', { teamId }); + const data = await linearGraphQL<{ + team: { labels: { nodes: unknown[] } }; + }>( + `query GetTeamLabels($id: String!) { + team(id: $id) { + labels { + nodes { + ${LABEL_FIELDS} + } + } + } + }`, + { id: teamId }, + ); + return ( + data.team.labels.nodes as Array<{ + id?: string; + name?: string; + color?: string; + description?: string | null; + }> + ).map(mapLabel); + }, + // ===== User ===== async getMe(): Promise { diff --git a/tests/unit/pm/linear/client.test.ts b/tests/unit/pm/linear/client.test.ts new file mode 100644 index 00000000..e481c911 --- /dev/null +++ b/tests/unit/pm/linear/client.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock fetch globally +// --------------------------------------------------------------------------- + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeGraphQLResponse(data: unknown) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ data }), + }; +} + +function makeGraphQLErrorResponse(message: string) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ errors: [{ message }] }), + }; +} + +function makeHttpErrorResponse(status: number) { + return { + ok: false, + status, + json: vi.fn().mockResolvedValue({}), + }; +} + +// --------------------------------------------------------------------------- +// Import the client under test +// --------------------------------------------------------------------------- + +import { linearClient, withLinearCredentials } from '../../../../src/linear/client.js'; + +const TEST_CREDS = { apiKey: 'test-api-key' }; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('linearClient discovery methods', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ========================================================================= + // getTeams + // ========================================================================= + describe('getTeams', () => { + it('returns an array of LinearTeam objects', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + teams: { + nodes: [ + { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Main team' }, + { id: 'team-2', name: 'Design', key: 'DES', description: null }, + ], + }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'team-1', + name: 'Engineering', + key: 'ENG', + description: 'Main team', + }); + expect(result[1]).toEqual({ + id: 'team-2', + name: 'Design', + key: 'DES', + description: null, + }); + }); + + it('returns an empty array when no teams are available', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + teams: { nodes: [] }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()); + + expect(result).toEqual([]); + }); + + it('uses defaults for missing fields in team nodes', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + teams: { + nodes: [{}], + }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()); + + expect(result[0]).toEqual({ id: '', name: '', key: '', description: null }); + }); + + it('throws on GraphQL errors', async () => { + mockFetch.mockResolvedValue(makeGraphQLErrorResponse('Unauthorized')); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()), + ).rejects.toThrow('Linear API error: Unauthorized'); + }); + + it('throws on HTTP errors', async () => { + mockFetch.mockResolvedValue(makeHttpErrorResponse(401)); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()), + ).rejects.toThrow('Linear API HTTP error 401'); + }); + + it('sends the correct Authorization header', async () => { + mockFetch.mockResolvedValue(makeGraphQLResponse({ teams: { nodes: [] } })); + + await withLinearCredentials(TEST_CREDS, () => linearClient.getTeams()); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.linear.app/graphql', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-api-key', + }), + }), + ); + }); + }); + + // ========================================================================= + // getTeamWorkflowStates + // ========================================================================= + describe('getTeamWorkflowStates', () => { + it('returns an array of LinearWorkflowState objects for the given team', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { + states: { + nodes: [ + { id: 'state-1', name: 'Backlog', type: 'backlog', color: '#aaa' }, + { id: 'state-2', name: 'In Progress', type: 'started', color: '#00f' }, + { id: 'state-3', name: 'Done', type: 'completed', color: '#0f0' }, + ], + }, + }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamWorkflowStates('team-1'), + ); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: 'state-1', name: 'Backlog', type: 'backlog', color: '#aaa' }); + expect(result[1]).toEqual({ + id: 'state-2', + name: 'In Progress', + type: 'started', + color: '#00f', + }); + expect(result[2]).toEqual({ id: 'state-3', name: 'Done', type: 'completed', color: '#0f0' }); + }); + + it('returns an empty array when team has no workflow states', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { states: { nodes: [] } }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamWorkflowStates('team-1'), + ); + + expect(result).toEqual([]); + }); + + it('uses defaults for missing fields in state nodes', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { states: { nodes: [{}] } }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamWorkflowStates('team-1'), + ); + + expect(result[0]).toEqual({ id: '', name: '', type: '', color: '' }); + }); + + it('passes the teamId variable in the GraphQL request', async () => { + mockFetch.mockResolvedValue(makeGraphQLResponse({ team: { states: { nodes: [] } } })); + + await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamWorkflowStates('my-team-id'), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(body.variables).toEqual({ id: 'my-team-id' }); + }); + + it('throws on GraphQL errors', async () => { + mockFetch.mockResolvedValue(makeGraphQLErrorResponse('Team not found')); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeamWorkflowStates('bad-id')), + ).rejects.toThrow('Linear API error: Team not found'); + }); + + it('throws on HTTP errors', async () => { + mockFetch.mockResolvedValue(makeHttpErrorResponse(500)); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeamWorkflowStates('team-1')), + ).rejects.toThrow('Linear API HTTP error 500'); + }); + }); + + // ========================================================================= + // getTeamLabels + // ========================================================================= + describe('getTeamLabels', () => { + it('returns an array of LinearLabel objects for the given team', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { + labels: { + nodes: [ + { id: 'label-1', name: 'Bug', color: '#f00', description: 'A bug' }, + { id: 'label-2', name: 'Feature', color: '#0f0', description: null }, + ], + }, + }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamLabels('team-1'), + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'label-1', + name: 'Bug', + color: '#f00', + description: 'A bug', + }); + expect(result[1]).toEqual({ + id: 'label-2', + name: 'Feature', + color: '#0f0', + description: null, + }); + }); + + it('returns an empty array when team has no labels', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { labels: { nodes: [] } }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamLabels('team-1'), + ); + + expect(result).toEqual([]); + }); + + it('uses defaults for missing fields in label nodes', async () => { + mockFetch.mockResolvedValue( + makeGraphQLResponse({ + team: { labels: { nodes: [{}] } }, + }), + ); + + const result = await withLinearCredentials(TEST_CREDS, () => + linearClient.getTeamLabels('team-1'), + ); + + expect(result[0]).toEqual({ id: '', name: '', color: '', description: null }); + }); + + it('passes the teamId variable in the GraphQL request', async () => { + mockFetch.mockResolvedValue(makeGraphQLResponse({ team: { labels: { nodes: [] } } })); + + await withLinearCredentials(TEST_CREDS, () => linearClient.getTeamLabels('my-team-id')); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(body.variables).toEqual({ id: 'my-team-id' }); + }); + + it('throws on GraphQL errors', async () => { + mockFetch.mockResolvedValue(makeGraphQLErrorResponse('Permission denied')); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeamLabels('bad-id')), + ).rejects.toThrow('Linear API error: Permission denied'); + }); + + it('throws on HTTP errors', async () => { + mockFetch.mockResolvedValue(makeHttpErrorResponse(403)); + + await expect( + withLinearCredentials(TEST_CREDS, () => linearClient.getTeamLabels('team-1')), + ).rejects.toThrow('Linear API HTTP error 403'); + }); + }); +});