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
70 changes: 70 additions & 0 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,4 +711,74 @@ export const integrationsDiscoveryRouter = router({
withLinearCredentials({ apiKey }, () => linearClient.getTeamProjects(input.teamId)),
);
}),

createLinearLabel: protectedProcedure
.input(
linearCredsInput.extend({
teamId: z.string().min(1),
name: z.string().min(1).max(100),
color: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.createLinearLabel called', {
orgId: ctx.effectiveOrgId,
teamId: input.teamId,
name: input.name,
});
return withLinearCreds(input, 'Failed to create Linear label', (creds) =>
withLinearCredentials(creds, () =>
linearClient.createLabel(input.teamId, input.name, input.color),
),
);
}),

createLinearLabels: protectedProcedure
.input(
linearCredsInput.extend({
teamId: z.string().min(1),
labels: z
.array(
z.object({
name: z.string().min(1).max(100),
color: z.string().optional(),
}),
)
.min(1)
.max(10),
}),
)
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.createLinearLabels called', {
orgId: ctx.effectiveOrgId,
teamId: input.teamId,
count: input.labels.length,
});
const creds = { apiKey: input.apiKey };

const results = await Promise.allSettled(
input.labels.map((label) =>
withLinearCredentials(creds, () =>
linearClient.createLabel(input.teamId, label.name, label.color),
),
),
);

const successes: Array<{ id: string; name: string; color: string }> = [];
const errors: Array<{ name: string; error: string }> = [];

for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
successes.push(result.value);
} else {
errors.push({
name: input.labels[i].name,
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
});
}
}

return { successes, errors };
}),
});
33 changes: 33 additions & 0 deletions src/linear/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,39 @@ export const linearClient = {
return linearClient.updateIssue(issueId, { labelIds: updatedLabelIds });
},

async createLabel(
teamId: string,
name: string,
color?: string,
): Promise<{ id: string; name: string; color: string }> {
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
}
}
}`,
{ input },
);
if (!data.issueLabelCreate.success || !data.issueLabelCreate.issueLabel) {
throw new Error('Linear issueLabelCreate returned success=false');
}
const label = data.issueLabelCreate.issueLabel;
return { id: label.id, name: label.name, color: label.color };
},

// ===== Attachments =====

async getAttachments(issueId: string): Promise<LinearAttachment[]> {
Expand Down
38 changes: 31 additions & 7 deletions src/pm/linear/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,36 @@ import type {
WorkItemLabel,
} from '../types.js';

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export class LinearPMProvider implements PMProvider {
readonly type = 'linear' as const;

constructor(private config: LinearConfig) {}

/**
* Resolve a label slot name or raw ID to a Linear label UUID.
*
* Linear's GraphQL API requires UUIDs for issueUpdate.labelIds and
* issueLabelCreate lookups. Returning a non-UUID string would silently
* fail server-side, so we short-circuit misconfigurations here with a
* diagnostic. Returns null when the input cannot be resolved to a UUID.
*/
private resolveLabelId(slotOrId: string): string | null {
const mapped = (this.config.labels as Record<string, string> | undefined)?.[slotOrId];
const candidate = mapped ?? slotOrId;
if (UUID_PATTERN.test(candidate)) return candidate;
logger.warn(
'[Linear] Label value is not a UUID — skipping (check PM wizard → Label Mappings)',
{
input: slotOrId,
resolved: mapped ?? '<no mapping>',
teamId: this.config.teamId,
},
);
return null;
}

async getWorkItem(id: string): Promise<WorkItem> {
const issue = await linearClient.getIssue(id);
return {
Expand Down Expand Up @@ -88,8 +113,8 @@ export class LinearPMProvider implements PMProvider {
...(config.labels?.length
? {
labelIds: config.labels
.map((name) => (this.config.labels as Record<string, string> | undefined)?.[name])
.filter((id): id is string => !!id),
.map((name) => this.resolveLabelId(name))
.filter((id): id is string => id !== null),
}
: {}),
});
Expand Down Expand Up @@ -152,15 +177,14 @@ export class LinearPMProvider implements PMProvider {
}

async addLabel(id: string, labelIdOrName: string): Promise<void> {
// Resolve name → ID via config if possible
const labelId =
(this.config.labels as Record<string, string> | undefined)?.[labelIdOrName] ?? labelIdOrName;
const labelId = this.resolveLabelId(labelIdOrName);
if (!labelId) return;
await linearClient.addLabel(id, labelId);
}

async removeLabel(id: string, labelIdOrName: string): Promise<void> {
const labelId =
(this.config.labels as Record<string, string> | undefined)?.[labelIdOrName] ?? labelIdOrName;
const labelId = this.resolveLabelId(labelIdOrName);
if (!labelId) return;
await linearClient.removeLabel(id, labelId);
}

Expand Down
29 changes: 23 additions & 6 deletions tests/unit/pm/linear/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const defaultConfig = {
cancelled: 'state-cancelled',
},
labels: {
processing: 'label-processing-id',
processing: '11111111-1111-4111-8111-111111111111',
},
};

Expand Down Expand Up @@ -302,15 +302,29 @@ describe('LinearPMProvider', () => {

await provider.addLabel('issue-uuid', 'processing');

expect(mockAddLabel).toHaveBeenCalledWith('issue-uuid', 'label-processing-id');
expect(mockAddLabel).toHaveBeenCalledWith(
'issue-uuid',
'11111111-1111-4111-8111-111111111111',
);
});

it('passes label ID directly when not in config', async () => {
it('passes a UUID-shaped value through when not in config', async () => {
mockAddLabel.mockResolvedValue(makeIssue());

await provider.addLabel('issue-uuid', 'raw-label-id');
await provider.addLabel('issue-uuid', '550e8400-e29b-41d4-a716-446655440000');

expect(mockAddLabel).toHaveBeenCalledWith('issue-uuid', 'raw-label-id');
expect(mockAddLabel).toHaveBeenCalledWith(
'issue-uuid',
'550e8400-e29b-41d4-a716-446655440000',
);
});

it('skips the API call and warns when the value is neither a mapped slot nor a UUID', async () => {
// Linear API rejects non-UUID labelIds; rather than silently fail we
// short-circuit and emit a diagnostic so the misconfiguration is visible.
await provider.addLabel('issue-uuid', 'unmapped-slot');

expect(mockAddLabel).not.toHaveBeenCalled();
});
});

Expand All @@ -320,7 +334,10 @@ describe('LinearPMProvider', () => {

await provider.removeLabel('issue-uuid', 'processing');

expect(mockRemoveLabel).toHaveBeenCalledWith('issue-uuid', 'label-processing-id');
expect(mockRemoveLabel).toHaveBeenCalledWith(
'issue-uuid',
'11111111-1111-4111-8111-111111111111',
);
});
});

Expand Down
105 changes: 105 additions & 0 deletions tests/unit/pm/linear/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,109 @@ describe('linearClient discovery methods', () => {
);
});
});

// =========================================================================
// createLabel
// =========================================================================
describe('createLabel', () => {
it('returns the created label with id, name, and color', async () => {
mockFetch.mockResolvedValue(
makeGraphQLResponse({
issueLabelCreate: {
success: true,
issueLabel: {
id: 'new-label-uuid',
name: 'cascade-processing',
color: '#0F7938',
},
},
}),
);

const result = await withLinearCredentials(TEST_CREDS, () =>
linearClient.createLabel('team-1', 'cascade-processing', '#0F7938'),
);

expect(result).toEqual({
id: 'new-label-uuid',
name: 'cascade-processing',
color: '#0F7938',
});
});

it('omits color when not provided (Linear auto-assigns)', async () => {
mockFetch.mockResolvedValue(
makeGraphQLResponse({
issueLabelCreate: {
success: true,
issueLabel: { id: 'l1', name: 'cascade-auto', color: '#555' },
},
}),
);

await withLinearCredentials(TEST_CREDS, () =>
linearClient.createLabel('team-1', 'cascade-auto'),
);

const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(body.variables.input.teamId).toBe('team-1');
expect(body.variables.input.name).toBe('cascade-auto');
expect(body.variables.input).not.toHaveProperty('color');
});

it('passes teamId, name, and color in the input variable', async () => {
mockFetch.mockResolvedValue(
makeGraphQLResponse({
issueLabelCreate: {
success: true,
issueLabel: { id: 'l1', name: 'cascade-error', color: '#E11D48' },
},
}),
);

await withLinearCredentials(TEST_CREDS, () =>
linearClient.createLabel('team-xyz', 'cascade-error', '#E11D48'),
);

const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(body.query).toContain('issueLabelCreate');
expect(body.variables.input).toEqual({
teamId: 'team-xyz',
name: 'cascade-error',
color: '#E11D48',
});
});

it('throws when issueLabelCreate returns success: false', async () => {
mockFetch.mockResolvedValue(
makeGraphQLResponse({
issueLabelCreate: { success: false, issueLabel: null },
}),
);

await expect(
withLinearCredentials(TEST_CREDS, () => linearClient.createLabel('team-1', 'x')),
).rejects.toThrow('Linear issueLabelCreate returned success=false');
});

it('throws on GraphQL errors (e.g. duplicate label name)', async () => {
mockFetch.mockResolvedValue(
makeGraphQLErrorResponse('Label with name "cascade-processing" already exists'),
);

await expect(
withLinearCredentials(TEST_CREDS, () =>
linearClient.createLabel('team-1', 'cascade-processing'),
),
).rejects.toThrow(/already exists/);
});

it('throws on HTTP errors', async () => {
mockFetch.mockResolvedValue(makeHttpErrorResponse(401, 'bad token'));

await expect(
withLinearCredentials(TEST_CREDS, () => linearClient.createLabel('team-1', 'x')),
).rejects.toThrow('Linear API HTTP error 401');
});
});
});
Loading
Loading