From 814092964c036e2f41b6be46a9f0348aa642bf04 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 15 Apr 2026 22:13:49 +0000 Subject: [PATCH 1/2] fix(linear): send personal API keys without Bearer prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear personal API keys (lin_api_*) are sent bare in the Authorization header — the `Bearer` prefix is OAuth-only, and using it with personal keys triggers HTTP 400. The router's two Linear API call sites (platformClients/linear.ts, bot-identity-resolvers.ts) had diverged from the canonical client (src/linear/client.ts) and used the OAuth pattern, breaking acknowledgment-comment posting and silently disabling the Linear bot-identity self-loop check. Also: - Fix the misleading docblock at src/linear/client.ts:8 that documented `Bearer ` while the code correctly used a bare key — future maintainers would have copied the doc and reintroduced the bug. - Improve linearGraphQL error messages in both the canonical and the router-side helpers to include the response body. Without it the failure surface was just an HTTP status, which made this very bug invisible until source-comparison. Test coverage: - Asserts each of postComment / deleteComment / updateComment sends bare API key, no Bearer prefix. - Asserts the GraphQL mutation body and variables. - Asserts the warning on HTTP failure includes the response body so diagnostics aren't lost again. Out of scope (separate follow-up): linearLabels are stored as free-text names but Linear's issueUpdate.labelIds requires UUIDs, so the `cascade-processing` label isn't being applied. Same shape as the status-mapping bug — needs a wizard UX change to fetch + present a label dropdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/linear/client.ts | 6 +- src/router/bot-identity-resolvers.ts | 3 +- src/router/platformClients/linear.ts | 7 +- tests/unit/router/platformClients.test.ts | 126 ++++++++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/linear/client.ts b/src/linear/client.ts index e95366fe..144d53d4 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -5,7 +5,8 @@ * are scoped per-request via withLinearCredentials(). * * API endpoint: https://api.linear.app/graphql - * Auth: Authorization: Bearer + * Auth: Authorization: (personal API keys are sent bare; `Bearer` + * is OAuth-only and triggers HTTP 400 with personal keys.) */ import { AsyncLocalStorage } from 'node:async_hooks'; @@ -68,7 +69,8 @@ async function linearGraphQL(query: string, variables?: Record ''); + throw new Error(`Linear API HTTP error ${response.status}: ${body}`); } const json = (await response.json()) as GraphQLResponse; diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts index 68f04cd2..bc425e2b 100644 --- a/src/router/bot-identity-resolvers.ts +++ b/src/router/bot-identity-resolvers.ts @@ -95,7 +95,8 @@ export async function resolveLinearBotUserId(projectId: string): Promise ''); + throw new Error(`Linear API HTTP error ${response.status}: ${body}`); } const json = (await response.json()) as { diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index ecaad36b..d37ac107 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -31,6 +31,7 @@ vi.mock('../../../src/utils/logging.js', () => ({ import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; import { + LinearPlatformClient, resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, @@ -54,6 +55,27 @@ const MOCK_CREDENTIALS: Record = { '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 } | 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', @@ -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/); + }); + }); +}); From 078ac9c3e8d8afb3e6b7d814547d8393fb438748 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Wed, 15 Apr 2026 22:15:55 +0000 Subject: [PATCH 2/2] test(linear): add text() to makeHttpErrorResponse factory Required because linearGraphQL now reads response.text() to include the body in HTTP-error messages. The factory was incomplete (only mocked ok/status/json) and would have failed for any real Response.text() call. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/pm/linear/client.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/pm/linear/client.test.ts b/tests/unit/pm/linear/client.test.ts index d4b9a420..264c0e9d 100644 --- a/tests/unit/pm/linear/client.test.ts +++ b/tests/unit/pm/linear/client.test.ts @@ -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), }; }