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
61 changes: 45 additions & 16 deletions src/linear/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/linear/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});
});
Loading