From fd25a66c0bc7f7a0278ceba190ed9cca62ef8708 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Thu, 23 Apr 2026 09:25:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(linear):=20idempotent=20createLabel=20?= =?UTF-8?q?=E2=80=94=20fall=20back=20to=20existing=20on=20duplicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear returns a GraphQL error "duplicate label name" when a label with the same name already exists in a team. Previously this surfaced as a 500 to the dashboard user. Now createLabel catches that specific error, fetches the team's labels, and returns the existing one by name — making the operation idempotent and the PM-wizard bulk-create banner safe to retry. Co-Authored-By: Claude Sonnet 4.6 --- src/linear/client.ts | 61 +++++++++++++++------ tests/unit/linear/client.test.ts | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/src/linear/client.ts b/src/linear/client.ts index 8f087338..32d80abd 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -483,24 +483,53 @@ export const linearClient = { logger.debug('Creating Linear issue label', { teamId, name, color }); const input: { teamId: string; name: string; color?: string } = { teamId, name }; if (color) input.color = color; - const data = await linearGraphQL<{ - issueLabelCreate: { - success: boolean; - issueLabel: { id: string; name: string; color: string } | null; - }; - }>( - `mutation CreateIssueLabel($input: IssueLabelCreateInput!) { - issueLabelCreate(input: $input) { - success - issueLabel { - id - name - color + + let data: + | { + issueLabelCreate: { + success: boolean; + issueLabel: { id: string; name: string; color: string } | null; + }; + } + | undefined; + + try { + data = await linearGraphQL<{ + issueLabelCreate: { + success: boolean; + issueLabel: { id: string; name: string; color: string } | null; + }; + }>( + `mutation CreateIssueLabel($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { + id + name + color + } } + }`, + { input }, + ); + } catch (err) { + // Linear rejects the mutation when a label with the same name already + // exists in the team. Treat this as an idempotent create: fetch the + // team's labels and return the existing one by name. + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('duplicate label name')) { + const existing = await linearClient.getTeamLabels(teamId); + const found = existing.find((l) => l.name.toLowerCase() === name.toLowerCase()); + if (!found) { + throw new Error( + `Linear duplicate label name but label "${name}" not found in team ${teamId}`, + ); } - }`, - { input }, - ); + return { id: found.id, name: found.name, color: found.color ?? '' }; + } + throw err; + } + if (!data.issueLabelCreate.success || !data.issueLabelCreate.issueLabel) { throw new Error('Linear issueLabelCreate returned success=false'); } diff --git a/tests/unit/linear/client.test.ts b/tests/unit/linear/client.test.ts index 925fa81d..621df750 100644 --- a/tests/unit/linear/client.test.ts +++ b/tests/unit/linear/client.test.ts @@ -28,6 +28,26 @@ function stubFetch(responseData: unknown): { calls: CapturedRequest[] } { return { calls }; } +// Stubs fetch to return different raw GraphQL response envelopes per call. +// Unlike stubFetch, responses are returned verbatim (no { data: ... } wrapping) +// so callers can mix { data: ... } success responses with { errors: [...] } error responses. +function stubFetchSequence(responses: unknown[]): { calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + let i = 0; + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(init?.body as string) as CapturedRequest['body']; + calls.push({ url: String(url), body }); + const response = responses[i++] ?? responses[responses.length - 1]; + return new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + // biome-ignore lint/suspicious/noExplicitAny: test stub + globalThis.fetch = fetchMock as any; + return { calls }; +} + const ISSUE_NODE = { id: 'i1', identifier: 'TEAM-1', @@ -194,3 +214,75 @@ describe('linearClient.getTeamProjects', () => { expect(calls[0].body.variables).toEqual({ id: 'T1', first: 50 }); }); }); + +describe('linearClient.createLabel — duplicate idempotency', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('returns the new label when Linear accepts the create', async () => { + stubFetch({ + issueLabelCreate: { + success: true, + issueLabel: { id: 'L1', name: 'cascade-ready', color: '#0284C7' }, + }, + }); + const label = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.createLabel('T1', 'cascade-ready', '#0284C7'), + ); + expect(label).toEqual({ id: 'L1', name: 'cascade-ready', color: '#0284C7' }); + }); + + it('falls back to existing label when Linear returns duplicate label name error', async () => { + const { calls } = stubFetchSequence([ + // first call: create → duplicate error (top-level GraphQL errors) + { errors: [{ message: 'duplicate label name' }] }, + // second call: getTeamLabels → success + { + data: { + team: { + labels: { + nodes: [ + { id: 'L99', name: 'cascade-ready', color: '#0284C7' }, + { id: 'L100', name: 'cascade-error', color: '#DC2626' }, + ], + }, + }, + }, + }, + ]); + const label = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.createLabel('T1', 'cascade-ready', '#0284C7'), + ); + expect(label).toEqual({ id: 'L99', name: 'cascade-ready', color: '#0284C7' }); + expect(calls).toHaveLength(2); + }); + + it('throws when duplicate error occurs but label is not found in team labels', async () => { + stubFetchSequence([ + { errors: [{ message: 'duplicate label name' }] }, + { + data: { + team: { + labels: { nodes: [{ id: 'L100', name: 'other-label', color: '#000' }] }, + }, + }, + }, + ]); + await expect( + withLinearCredentials({ apiKey: 'k' }, () => + linearClient.createLabel('T1', 'cascade-ready', '#0284C7'), + ), + ).rejects.toThrow('cascade-ready'); + }); + + it('re-throws non-duplicate Linear errors without falling back', async () => { + stubFetchSequence([{ errors: [{ message: 'team not found' }] }]); + await expect( + withLinearCredentials({ apiKey: 'k' }, () => linearClient.createLabel('T1', 'cascade-ready')), + ).rejects.toThrow('team not found'); + }); +});