diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index ce2ef823..1da2266c 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -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 }; + }), }); diff --git a/src/linear/client.ts b/src/linear/client.ts index 144d53d4..3d798b14 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -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 { diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index c271edfc..9b65baff 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -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 | 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 ?? '', + teamId: this.config.teamId, + }, + ); + return null; + } + async getWorkItem(id: string): Promise { const issue = await linearClient.getIssue(id); return { @@ -88,8 +113,8 @@ export class LinearPMProvider implements PMProvider { ...(config.labels?.length ? { labelIds: config.labels - .map((name) => (this.config.labels as Record | undefined)?.[name]) - .filter((id): id is string => !!id), + .map((name) => this.resolveLabelId(name)) + .filter((id): id is string => id !== null), } : {}), }); @@ -152,15 +177,14 @@ export class LinearPMProvider implements PMProvider { } async addLabel(id: string, labelIdOrName: string): Promise { - // Resolve name → ID via config if possible - const labelId = - (this.config.labels as Record | undefined)?.[labelIdOrName] ?? labelIdOrName; + const labelId = this.resolveLabelId(labelIdOrName); + if (!labelId) return; await linearClient.addLabel(id, labelId); } async removeLabel(id: string, labelIdOrName: string): Promise { - const labelId = - (this.config.labels as Record | undefined)?.[labelIdOrName] ?? labelIdOrName; + const labelId = this.resolveLabelId(labelIdOrName); + if (!labelId) return; await linearClient.removeLabel(id, labelId); } diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts index 552d2af2..0c8239db 100644 --- a/tests/unit/pm/linear/adapter.test.ts +++ b/tests/unit/pm/linear/adapter.test.ts @@ -53,7 +53,7 @@ const defaultConfig = { cancelled: 'state-cancelled', }, labels: { - processing: 'label-processing-id', + processing: '11111111-1111-4111-8111-111111111111', }, }; @@ -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(); }); }); @@ -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', + ); }); }); diff --git a/tests/unit/pm/linear/client.test.ts b/tests/unit/pm/linear/client.test.ts index 264c0e9d..a6c2608b 100644 --- a/tests/unit/pm/linear/client.test.ts +++ b/tests/unit/pm/linear/client.test.ts @@ -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'); + }); + }); }); diff --git a/tests/unit/web/linear-field-mapping-step.test.ts b/tests/unit/web/linear-field-mapping-step.test.ts index b5d98d66..4159e9f4 100644 --- a/tests/unit/web/linear-field-mapping-step.test.ts +++ b/tests/unit/web/linear-field-mapping-step.test.ts @@ -113,3 +113,76 @@ describe('LinearFieldMappingStep — status slots', () => { } }); }); + +describe('LinearFieldMappingStep — label slots', () => { + function renderWithLabels( + labels: Array<{ id: string; name: string; color: string }>, + persisted: Record = {}, + onCreateLabel?: (slot: string) => void, + onCreateAllMissingLabels?: () => void, + ): string { + const state = makeState({ + linearTeamDetails: { + states: [], + labels, + }, + linearLabels: persisted, + }); + return renderToStaticMarkup( + createElement(LinearFieldMappingStep, { + state, + dispatch: () => {}, + onCreateLabel, + onCreateAllMissingLabels, + }), + ); + } + + it('renders label dropdowns sourced from linearTeamDetails.labels (ID-backed options)', () => { + const html = renderWithLabels([ + { id: 'lbl-proc-uuid', name: 'cascade-processing', color: '#2563EB' }, + { id: 'lbl-done-uuid', name: 'cascade-processed', color: '#16A34A' }, + ]); + // The label dropdown must expose each Linear label's UUID as an option value. + expect(html).toContain('value="lbl-proc-uuid"'); + expect(html).toContain('value="lbl-done-uuid"'); + // Display names should NOT appear as option values (they can still be in the label text). + expect(html).not.toContain('value="cascade-processing"'); + }); + + it('shows the "Create" affordance for slots with no mapping and no existing matching label', () => { + const html = renderWithLabels( + [], + {}, + () => {}, + () => {}, + ); + // A dedicated create button per slot — look for the batch button text too. + expect(html).toMatch(/Create All Missing/); + }); + + it('hides the per-slot Create button when the default label already exists on the team', () => { + const html = renderWithLabels( + [ + { id: 'lbl-ready', name: 'cascade-ready', color: '#0284C7' }, + { id: 'lbl-proc', name: 'cascade-processing', color: '#2563EB' }, + { id: 'lbl-procd', name: 'cascade-processed', color: '#16A34A' }, + { id: 'lbl-err', name: 'cascade-error', color: '#DC2626' }, + { id: 'lbl-auto', name: 'cascade-auto', color: '#9333EA' }, + ], + {}, + () => {}, + () => {}, + ); + // With every default present, there's nothing left to create → batch button hidden. + expect(html).not.toMatch(/Create All Missing/); + }); + + it('reflects persisted label mappings as selected dropdown values', () => { + const html = renderWithLabels( + [{ id: 'lbl-proc-uuid', name: 'cascade-processing', color: '#2563EB' }], + { processing: 'lbl-proc-uuid' }, + ); + expect(html).toContain('value="lbl-proc-uuid"'); + }); +}); diff --git a/tests/unit/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts index 8101e054..f7feeb17 100644 --- a/tests/unit/web/pm-wizard-state.test.ts +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -837,9 +837,13 @@ describe('buildLinearIntegrationConfig — save payload', () => { const bare = buildLinearIntegrationConfig(seed()); expect(bare).not.toHaveProperty('labels'); const withLabels = buildLinearIntegrationConfig( - seed({ linearLabels: { processing: 'cascade-processing' } }), + // Linear labels are stored as UUIDs (the Linear API rejects names for + // issueUpdate.labelIds). Wizard dropdowns populate from the team's labels. + seed({ linearLabels: { processing: '11111111-1111-4111-8111-111111111111' } }), ); - expect(withLabels).toHaveProperty('labels', { processing: 'cascade-processing' }); + expect(withLabels).toHaveProperty('labels', { + processing: '11111111-1111-4111-8111-111111111111', + }); }); }); diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 714ae643..21ee4b81 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -735,3 +735,65 @@ export function useSaveMutation(projectId: string, state: WizardState) { return { saveMutation }; } + +// ============================================================================ +// Linear Label Creation +// ============================================================================ + +export function useLinearLabelCreation(state: WizardState, dispatch: React.Dispatch) { + const createLabelMutation = useMutation({ + mutationFn: (vars: { name: string; color?: string; slot: string }) => { + if (!state.linearApiKey || !state.linearTeamId) { + throw new Error('Missing credentials or team selection'); + } + return trpcClient.integrationsDiscovery.createLinearLabel.mutate({ + apiKey: state.linearApiKey, + teamId: state.linearTeamId, + name: vars.name, + color: vars.color, + }); + }, + onSuccess: (label, vars) => { + dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); + dispatch({ type: 'SET_LINEAR_LABEL', key: vars.slot, value: label.id }); + }, + onError: (error) => { + console.error('Failed to create Linear label:', error); + alert(`Failed to create label: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + const createMissingLabelsMutation = useMutation({ + mutationFn: (labelsToCreate: Array<{ slot: string; name: string; color?: string }>) => { + if (!state.linearApiKey || !state.linearTeamId) { + throw new Error('Missing credentials or team selection'); + } + return trpcClient.integrationsDiscovery.createLinearLabels.mutate({ + apiKey: state.linearApiKey, + teamId: state.linearTeamId, + labels: labelsToCreate.map(({ name, color }) => ({ name, color })), + }); + }, + onSuccess: (result, labelsToCreate) => { + for (const label of result.successes) { + const slot = labelsToCreate.find((l) => l.name === label.name)?.slot; + if (slot) { + dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); + dispatch({ type: 'SET_LINEAR_LABEL', key: slot, value: label.id }); + } + } + if (result.errors.length > 0) { + const errorMsg = result.errors.map((e) => `${e.name}: ${e.error}`).join('\n'); + alert( + `Some labels failed to create:\n${errorMsg}\n\n${result.successes.length} label(s) created successfully.`, + ); + } + }, + onError: (error) => { + console.error('Failed to create Linear labels:', error); + alert(`Failed to create labels: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + return { createLabelMutation, createMissingLabelsMutation }; +} diff --git a/web/src/components/projects/pm-wizard-linear-steps.tsx b/web/src/components/projects/pm-wizard-linear-steps.tsx index 4729d23f..fc96e6c3 100644 --- a/web/src/components/projects/pm-wizard-linear-steps.tsx +++ b/web/src/components/projects/pm-wizard-linear-steps.tsx @@ -3,7 +3,8 @@ */ import type { UseMutationResult } from '@tanstack/react-query'; -import { CheckCircle2, Loader2 } from 'lucide-react'; +import { CheckCircle2, Loader2, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import type { WizardAction, WizardState } from './pm-wizard-state.js'; @@ -26,6 +27,19 @@ const LINEAR_STATUS_SLOTS = [ const LINEAR_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess', 'auto']; +/** + * Default CASCADE label names + hex colors used when the operator clicks + * "Create" on an unmapped slot. Linear expects hex color strings on + * issueLabelCreate; picked to roughly match the Trello named-color palette. + */ +export const LINEAR_LABEL_DEFAULTS: Record = { + readyToProcess: { name: 'cascade-ready', color: '#0284C7' }, + processing: { name: 'cascade-processing', color: '#2563EB' }, + processed: { name: 'cascade-processed', color: '#16A34A' }, + error: { name: 'cascade-error', color: '#DC2626' }, + auto: { name: 'cascade-auto', color: '#9333EA' }, +}; + // ============================================================================ // LinearCredentialsStep // ============================================================================ @@ -155,10 +169,26 @@ export function LinearTeamStep({ export function LinearFieldMappingStep({ state, dispatch, + onCreateLabel, + onCreateAllMissingLabels, + creatingSlot, }: { state: WizardState; dispatch: React.Dispatch; + onCreateLabel?: (slot: string) => void; + onCreateAllMissingLabels?: () => void; + creatingSlot?: string | null; }) { + const existingLabelNames = new Set( + (state.linearTeamDetails?.labels ?? []).map((l) => l.name.toLowerCase()), + ); + + const missingSlots = LINEAR_LABEL_SLOTS.filter((slot) => { + if (state.linearLabels[slot]) return false; + const defaultName = LINEAR_LABEL_DEFAULTS[slot]?.name ?? ''; + return !existingLabelNames.has(defaultName.toLowerCase()); + }); + return (
{/* Status mappings */} @@ -199,29 +229,91 @@ export function LinearFieldMappingStep({ )}
- {/* Labels */} + {/* Label mappings */}
- +
+ + {state.linearTeamDetails && missingSlots.length > 0 && onCreateAllMissingLabels && ( + + )} +

- CASCADE label names used in Linear. These are created automatically by CASCADE. + Map each CASCADE label to a Linear label on the team. Click "Create" to add missing ones.

- {LINEAR_LABEL_SLOTS.map((slot) => ( -
- {slot} - - dispatch({ - type: 'SET_LINEAR_LABEL', - key: slot, - value: e.target.value, - }) - } - placeholder={`Linear label for ${slot}`} - className="flex-1" - /> -
- ))} + {state.linearTeamDetails ? ( + LINEAR_LABEL_SLOTS.map((slot) => { + const isMapped = !!state.linearLabels[slot]; + const defaultInfo = LINEAR_LABEL_DEFAULTS[slot]; + const alreadyExists = + defaultInfo && existingLabelNames.has(defaultInfo.name.toLowerCase()); + const showCreateButton = !isMapped && !alreadyExists && onCreateLabel && defaultInfo; + + return ( +
+
+ l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + })) ?? [] + } + value={state.linearLabels[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_LINEAR_LABEL', + key: slot, + value: v, + }) + } + manualFallback + /> +
+ {showCreateButton && ( + + )} +
+ ); + }) + ) : ( +

+ Select a team first to populate label options. +

+ )}
); diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 791501bb..99685951 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -133,6 +133,7 @@ export type WizardAction = | { type: 'SET_LINEAR_LABEL'; key: string; value: string } | { type: 'INIT_EDIT'; state: Partial } | { type: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } } + | { type: 'ADD_LINEAR_TEAM_LABEL'; label: { id: string; name: string; color: string } } | { type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD'; customField: { id: string; name: string; type: string }; @@ -151,13 +152,13 @@ export const INITIAL_JIRA_LABELS: Record = { auto: 'cascade-auto', }; -export const INITIAL_LINEAR_LABELS: Record = { - processing: 'cascade-processing', - processed: 'cascade-processed', - error: 'cascade-error', - readyToProcess: 'cascade-ready', - auto: 'cascade-auto', -}; +/** + * Linear label mappings store workflow-label **UUIDs**, not names, because + * Linear's GraphQL API rejects names for issueUpdate.labelIds. The wizard + * populates these from the team's existing labels or via the create-label + * button. Initial state is therefore empty — operators pick or create. + */ +export const INITIAL_LINEAR_LABELS: Record = {}; export function createInitialState(): WizardState { return { @@ -349,6 +350,15 @@ export const wizardReducer: Reducer = (state, action) labels: [...state.trelloBoardDetails.labels, action.label], }, }; + case 'ADD_LINEAR_TEAM_LABEL': + if (!state.linearTeamDetails) return state; + return { + ...state, + linearTeamDetails: { + ...state.linearTeamDetails, + labels: [...state.linearTeamDetails.labels, action.label], + }, + }; case 'ADD_TRELLO_BOARD_CUSTOM_FIELD': if (!state.trelloBoardDetails) return state; return { diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 986da19d..2988f139 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -8,6 +8,7 @@ import { useJiraCustomFieldCreation, useJiraDiscovery, useLinearDiscovery, + useLinearLabelCreation, useLinearWebhookInfo, useSaveMutation, useTrelloCustomFieldCreation, @@ -22,6 +23,7 @@ import { JiraProjectStep, } from './pm-wizard-jira-steps.js'; import { + LINEAR_LABEL_DEFAULTS, LinearCredentialsStep, LinearFieldMappingStep, LinearTeamStep, @@ -151,6 +153,10 @@ export function PMWizard({ state, dispatch, ); + const { + createLabelMutation: createLinearLabelMutation, + createMissingLabelsMutation: createMissingLinearLabelsMutation, + } = useLinearLabelCreation(state, dispatch); const { createCustomFieldMutation } = useTrelloCustomFieldCreation(state, dispatch); const { createJiraCustomFieldMutation } = useJiraCustomFieldCreation(state, dispatch); const webhookManagement = useWebhookManagement(projectId, state); @@ -207,6 +213,34 @@ export function PMWizard({ } }; + const handleCreateLinearLabel = (slot: string) => { + const defaults = LINEAR_LABEL_DEFAULTS[slot]; + if (!defaults) return; + setCreatingSlot(slot); + createLinearLabelMutation.mutate( + { name: defaults.name, color: defaults.color, slot }, + { onSettled: () => setCreatingSlot(null) }, + ); + }; + + const handleCreateAllMissingLinearLabels = () => { + const existingLabelNames = new Set( + (state.linearTeamDetails?.labels ?? []).map((l) => l.name.toLowerCase()), + ); + const labelsToCreate = Object.entries(LINEAR_LABEL_DEFAULTS) + .filter(([slot, { name }]) => { + if (state.linearLabels[slot]) return false; + return !existingLabelNames.has(name.toLowerCase()); + }) + .map(([slot, { name, color }]) => ({ slot, name, color })); + if (labelsToCreate.length > 0) { + setCreatingSlot('__batch__'); + createMissingLinearLabelsMutation.mutate(labelsToCreate, { + onSettled: () => setCreatingSlot(null), + }); + } + }; + // ---- Step status ---- const credsReady = areCredentialsReady(state); @@ -361,7 +395,13 @@ export function PMWizard({ creatingCostField={creatingCostField} /> ) : state.provider === 'linear' ? ( - + ) : (