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
6 changes: 4 additions & 2 deletions src/linear/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* are scoped per-request via withLinearCredentials().
*
* API endpoint: https://api.linear.app/graphql
* Auth: Authorization: Bearer <api_key>
* Auth: Authorization: <api_key> (personal API keys are sent bare; `Bearer`
* is OAuth-only and triggers HTTP 400 with personal keys.)
*/

import { AsyncLocalStorage } from 'node:async_hooks';
Expand Down Expand Up @@ -68,7 +69,8 @@ async function linearGraphQL<T>(query: string, variables?: Record<string, unknow
});

if (!response.ok) {
throw new Error(`Linear API HTTP error ${response.status}`);
const body = await response.text().catch(() => '<no body>');
throw new Error(`Linear API HTTP error ${response.status}: ${body}`);
}

const json = (await response.json()) as GraphQLResponse<T>;
Expand Down
3 changes: 2 additions & 1 deletion src/router/bot-identity-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ export async function resolveLinearBotUserId(projectId: string): Promise<string
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${creds.apiKey}`,
// Linear personal API keys are sent bare; `Bearer` is OAuth-only.
Authorization: creds.apiKey,
},
body: JSON.stringify({ query: '{ viewer { id } }' }),
});
Expand Down
7 changes: 5 additions & 2 deletions src/router/platformClients/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ async function linearGraphQL(
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
// Linear personal API keys (lin_api_*) are sent bare; the `Bearer` prefix
// is only valid for OAuth tokens and triggers HTTP 400 with personal keys.
Authorization: apiKey,
},
body: JSON.stringify({ query, variables }),
});

if (!response.ok) {
throw new Error(`Linear API HTTP error ${response.status}`);
const body = await response.text().catch(() => '<no body>');
throw new Error(`Linear API HTTP error ${response.status}: ${body}`);
}

const json = (await response.json()) as {
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/pm/linear/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ function makeGraphQLErrorResponse(message: string) {
};
}

function makeHttpErrorResponse(status: number) {
function makeHttpErrorResponse(status: number, body = '') {
return {
ok: false,
status,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(body),
};
}

Expand Down
126 changes: 126 additions & 0 deletions tests/unit/router/platformClients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('../../../src/utils/logging.js', () => ({

import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js';
import {
LinearPlatformClient,
resolveGitHubHeaders,
resolveJiraCredentials,
resolveTrelloCredentials,
Expand All @@ -54,6 +55,27 @@ const MOCK_CREDENTIALS: Record<string, string> = {
'pm/api_token': 'jira-api-token',
};

const LINEAR_API_KEY = 'lin_api_test123';

function mockLinearApiKey() {
mockGetIntegrationCredential.mockImplementation(async (_projectId, category, _provider, role) => {
if (category === 'pm' && role === 'api_key') return LINEAR_API_KEY;
throw new Error(`Credential '${category}/${role}' not found`);
});
}

function lastFetchAuth(): unknown {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
const init = call?.[1] as { headers?: Record<string, string> } | undefined;
return init?.headers?.Authorization;
}

function lastFetchBody(): { query?: string; variables?: unknown } {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
const init = call?.[1] as { body?: string } | undefined;
return init?.body ? JSON.parse(init.body) : {};
}

const MOCK_PROJECT_WITH_JIRA = {
id: 'proj1',
name: 'Test',
Expand Down Expand Up @@ -300,3 +322,107 @@ describe('TrelloPlatformClient', () => {
});
});
});

// ---------------------------------------------------------------------------
// LinearPlatformClient
// ---------------------------------------------------------------------------

describe('LinearPlatformClient', () => {
beforeEach(() => {
mockLinearApiKey();
});

describe('postComment', () => {
it('sends bare API key (no Bearer prefix) — Linear personal API keys are not OAuth tokens', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { commentCreate: { success: true, comment: { id: 'c-new' } } },
}),
});

const client = new LinearPlatformClient('proj1');
const id = await client.postComment('issue-uuid-1', 'hello');

expect(id).toBe('c-new');
expect(lastFetchAuth()).toBe(LINEAR_API_KEY);
expect(lastFetchAuth()).not.toMatch(/^Bearer\s/);
});

it('posts the commentCreate mutation with issueId and body variables', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { commentCreate: { success: true, comment: { id: 'c-1' } } },
}),
});

const client = new LinearPlatformClient('proj1');
await client.postComment('issue-uuid-2', 'Processing this issue');

const body = lastFetchBody();
expect(body.query).toContain('commentCreate');
expect(body.variables).toEqual({
issueId: 'issue-uuid-2',
body: 'Processing this issue',
});
});

it('logs the response body when Linear returns an HTTP error so the failure is diagnosable', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: async () => '{"error":"bad token"}',
});

const client = new LinearPlatformClient('proj1');
const id = await client.postComment('issue-uuid-3', 'msg');

expect(id).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to post Linear comment'),
expect.stringContaining('bad token'),
);
});

it('returns null without calling fetch when credentials are missing', async () => {
mockGetIntegrationCredential.mockRejectedValue(new Error('not found'));

const client = new LinearPlatformClient('proj1');
const id = await client.postComment('issue-uuid-4', 'msg');

expect(id).toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('deleteComment', () => {
it('sends bare API key for delete', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { commentDelete: { success: true } } }),
});

const client = new LinearPlatformClient('proj1');
await client.deleteComment('issue-uuid-1', 'comment-abc');

expect(lastFetchAuth()).toBe(LINEAR_API_KEY);
expect(lastFetchAuth()).not.toMatch(/^Bearer\s/);
});
});

describe('updateComment', () => {
it('sends bare API key for update', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { commentUpdate: { success: true } } }),
});

const client = new LinearPlatformClient('proj1');
await client.updateComment('comment-abc', 'edited');

expect(lastFetchAuth()).toBe(LINEAR_API_KEY);
expect(lastFetchAuth()).not.toMatch(/^Bearer\s/);
});
});
});
Loading