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
76 changes: 76 additions & 0 deletions web/src/components/projects/pm-wizard-common-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,97 @@ function CopyButton({ text }: { text: string }) {
);
}

// ============================================================================
// LinearWebhookInfoPanel
// ============================================================================

export function LinearWebhookInfoPanel({ webhookUrl }: { webhookUrl: string }) {
return (
<div className="space-y-4">
<div className="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-900/50 dark:bg-blue-900/20">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
Manual Webhook Setup Required
</p>
<p className="text-xs text-blue-600 dark:text-blue-400">
Linear webhooks must be configured manually in your Linear team settings. CASCADE
cannot create them programmatically.
</p>
</div>
</div>
</div>

<div className="space-y-2">
<Label>Your CASCADE Webhook URL</Label>
<div className="flex items-center gap-2 rounded-md border bg-muted px-3 py-2">
<span className="flex-1 font-mono text-xs break-all">{webhookUrl}</span>
<CopyButton text={webhookUrl} />
</div>
</div>

<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">Setup instructions:</p>
<ol className="list-decimal list-inside space-y-1 text-xs text-muted-foreground pl-1">
<li>
Go to{' '}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
linear.app/settings/api
</a>{' '}
and navigate to <strong>Webhooks</strong>
</li>
<li>Click &quot;New webhook&quot; and enter the URL above</li>
<li>
Enable events: <strong>Issues</strong> (created, updated, removed)
</li>
<li>Select your team and save — webhooks are team-scoped in Linear</li>
<li>
Optionally set a webhook secret and store it as{' '}
<code className="bg-muted-foreground/20 px-1 rounded">LINEAR_WEBHOOK_SECRET</code> in
project credentials
</li>
</ol>
</div>
</div>
);
}

// ============================================================================
// WebhookStep
// ============================================================================

export function WebhookStep({
state,
webhooksQuery,
activeWebhooks,
callbackBaseUrl,
createWebhookMutation,
deleteWebhookMutation,
linearWebhookUrl,
}: {
state: WizardState;
webhooksQuery: WebhooksQueryProps;
activeWebhooks: ActiveWebhook[];
callbackBaseUrl: string;
createWebhookMutation: UseMutationResult<unknown, Error, void, unknown>;
deleteWebhookMutation: UseMutationResult<unknown, Error, string, unknown>;
linearWebhookUrl?: string;
}) {
// Linear uses a display-only panel — no create/delete buttons
if (state.provider === 'linear') {
return (
<LinearWebhookInfoPanel
webhookUrl={linearWebhookUrl ?? `${callbackBaseUrl}/linear/webhook`}
/>
);
}

const isTrello = state.provider === 'trello';
const providerName = isTrello ? 'Trello' : 'JIRA';

Expand Down
148 changes: 146 additions & 2 deletions web/src/components/projects/pm-wizard-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { API_URL } from '@/lib/api.js';
import { trpc, trpcClient } from '@/lib/trpc.js';
import type { WizardAction, WizardState } from './pm-wizard-state.js';
import type {
LinearTeamDetails,
LinearTeamOption,
WizardAction,
WizardState,
} from './pm-wizard-state.js';

// ============================================================================
// Trello Discovery
Expand Down Expand Up @@ -187,6 +192,99 @@ export function useJiraDiscovery(
return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect };
}

// ============================================================================
// Linear Discovery
// ============================================================================

export function useLinearDiscovery(
state: WizardState,
dispatch: React.Dispatch<WizardAction>,
advanceToStep: (step: number) => void,
projectId: string,
) {
const linearTeamsMutation = useMutation({
mutationFn: () => {
if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) {
return trpcClient.integrationsDiscovery.linearTeamsByProject.mutate({ projectId });
}
if (!state.linearApiKey) {
throw new Error('Enter your API key before fetching teams');
}
return trpcClient.integrationsDiscovery.linearTeams.mutate({
apiKey: state.linearApiKey,
});
},
onSuccess: (teams) =>
dispatch({
type: 'SET_LINEAR_TEAMS',
teams: teams as LinearTeamOption[],
}),
});

const linearDetailsMutation = useMutation({
mutationFn: (teamId: string) => {
if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) {
return trpcClient.integrationsDiscovery.linearTeamDetailsByProject.mutate({
projectId,
teamId,
});
}
if (!state.linearApiKey) {
throw new Error('Enter your API key before fetching team details');
}
return trpcClient.integrationsDiscovery.linearTeamDetails.mutate({
apiKey: state.linearApiKey,
teamId,
});
},
onSuccess: (details) => {
dispatch({
type: 'SET_LINEAR_TEAM_DETAILS',
details: details as LinearTeamDetails,
});
advanceToStep(4);
},
});

const handleTeamSelect = (teamId: string) => {
dispatch({ type: 'SET_LINEAR_TEAM_ID', id: teamId });
if (teamId) {
linearDetailsMutation.mutate(teamId);
}
};

// Auto-fetch teams when verification result changes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change
useEffect(() => {
if (!state.verificationResult || state.provider !== 'linear') return;
if (state.linearTeams.length === 0 && !linearTeamsMutation.isPending) {
linearTeamsMutation.mutate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.verificationResult]);

// In edit mode, auto-fetch team list and details
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds
useEffect(() => {
if (!state.isEditing || state.provider !== 'linear') return;
const canFetch = state.linearApiKey ? true : state.hasStoredCredentials;
if (canFetch && state.linearTeams.length === 0 && !linearTeamsMutation.isPending) {
linearTeamsMutation.mutate();
}
if (
state.linearTeamId &&
!state.linearTeamDetails &&
canFetch &&
!linearDetailsMutation.isPending
) {
linearDetailsMutation.mutate(state.linearTeamId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.isEditing, state.linearTeamId, state.hasStoredCredentials]);

return { linearTeamsMutation, linearDetailsMutation, handleTeamSelect };
}

// ============================================================================
// Verification
// ============================================================================
Expand All @@ -209,6 +307,15 @@ export function useVerification(
});
return { provider: 'trello' as const, result };
}
if (provider === 'linear') {
if (!state.linearApiKey) {
throw new Error('Enter your API key before verifying');
}
const result = await trpcClient.integrationsDiscovery.verifyLinear.mutate({
apiKey: state.linearApiKey,
});
return { provider: 'linear' as const, result };
}
if (!state.jiraEmail || !state.jiraApiToken) {
throw new Error('Enter both credentials before verifying');
}
Expand All @@ -228,6 +335,12 @@ export function useVerification(
type: 'SET_VERIFICATION',
result: { provider: 'trello', display: `@${r.username} (${r.fullName})` },
});
} else if (provider === 'linear') {
const r = result as { name: string; displayName: string };
dispatch({
type: 'SET_VERIFICATION',
result: { provider: 'linear', display: r.displayName || r.name },
});
} else {
const r = result as { displayName: string; emailAddress: string };
dispatch({
Expand Down Expand Up @@ -296,6 +409,22 @@ export function useWebhookManagement(projectId: string, state: WizardState) {
};
}

// ============================================================================
// Linear Webhook Info (display-only)
// ============================================================================

export function useLinearWebhookInfo() {
const callbackBaseUrl =
API_URL ||
(typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : '');

const webhookUrl = callbackBaseUrl
? `${callbackBaseUrl}/linear/webhook`
: '<YOUR_CASCADE_HOST>/linear/webhook';

return { webhookUrl };
}

// ============================================================================
// Trello Label Creation
// ============================================================================
Expand Down Expand Up @@ -454,7 +583,7 @@ export function useSaveMutation(projectId: string, state: WizardState) {
const queryClient = useQueryClient();

const saveMutation = useMutation({
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential persisting
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles three provider types + credential persisting
mutationFn: async () => {
let config: Record<string, unknown>;
if (state.provider === 'trello') {
Expand All @@ -464,6 +593,12 @@ export function useSaveMutation(projectId: string, state: WizardState) {
labels: state.trelloLabelMappings,
...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}),
};
} else if (state.provider === 'linear') {
config = {
teamId: state.linearTeamId,
statuses: state.linearStatusMappings,
...(Object.keys(state.linearLabels).length > 0 ? { labels: state.linearLabels } : {}),
};
} else {
config = {
projectKey: state.jiraProjectKey,
Expand Down Expand Up @@ -502,6 +637,15 @@ export function useSaveMutation(projectId: string, state: WizardState) {
name: 'Trello Token',
});
}
} else if (state.provider === 'linear') {
if (state.linearApiKey) {
await trpcClient.projects.credentials.set.mutate({
projectId,
envVarKey: 'LINEAR_API_KEY',
value: state.linearApiKey,
name: 'Linear API Key',
});
}
} else {
if (state.jiraEmail) {
await trpcClient.projects.credentials.set.mutate({
Expand Down
Loading
Loading