"use client"; import { useEffect, useState } from "react"; import { Button } from "@kandev/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { useToast } from "@/components/toast-provider"; import { useAppStoreApi } from "@/components/state-provider"; import { getJiraTicket } from "@/lib/api/domains/jira-api"; import { getLinearIssue } from "@/lib/api/domains/linear-api"; import { getSentryIssue } from "@/lib/api/domains/sentry-api"; import { updateTask } from "@/lib/api/domains/kanban-api"; import { JIRA_KEY_RE } from "@/components/jira/jira-ticket-common"; import { LINEAR_KEY_RE } from "@/components/linear/linear-issue-common"; import { extractSentryShortId } from "@/components/sentry/sentry-issue-common"; import { useSentryInstances } from "@/hooks/domains/sentry/use-sentry-availability"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import type { SentryIssue } from "@/lib/types/sentry"; import { buildLinkedIssueTitle } from "./task-external-link-utils"; export type ExternalLinkProvider = "jira" | "linear" | "sentry"; type TaskExternalLinkTarget = { id: string; title: string; }; type TaskExternalLinkDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; provider: ExternalLinkProvider; task: TaskExternalLinkTarget; workspaceId: string; }; type ProviderConfig = { title: string; description: string; inputLabel: string; placeholder: string; validationHint: string; successLabel: string; extractKey: (raw: string) => string | null; fetch: (key: string, workspaceId: string, instanceId?: string) => Promise; resolveLinkedKey?: (requestedKey: string, result: unknown) => string; // requiresInstance gates providers (Sentry) whose fetch must target a chosen // instance within the workspace before it can run. requiresInstance?: boolean; }; const SENTRY_NUMERIC_ISSUE_URL_RE = /\/issues\/(\d+)(?:[/?#]|$)/i; function extractSentryIssueKey(raw: string): string | null { return extractSentryShortId(raw) ?? raw.match(SENTRY_NUMERIC_ISSUE_URL_RE)?.[1] ?? null; } function isSentryIssue(result: unknown): result is SentryIssue { return ( typeof result === "object" && result !== null && typeof (result as Partial).shortId === "string" ); } const PROVIDERS: Record = { jira: { title: "Link Jira ticket", description: "Use a Jira ticket key or URL for this task.", inputLabel: "Ticket", placeholder: "PROJ-123 or paste ticket URL", validationHint: "Paste a Jira ticket URL or key (PROJ-123).", successLabel: "Jira ticket linked", extractKey: (raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null, fetch: (key, workspaceId) => getJiraTicket(key, { workspaceId }), }, linear: { title: "Link Linear issue", description: "Use a Linear issue identifier or URL for this task.", inputLabel: "Issue", placeholder: "ENG-123 or paste issue URL", validationHint: "Paste a Linear issue URL or identifier (ENG-123).", successLabel: "Linear issue linked", extractKey: (raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null, fetch: (key, workspaceId) => getLinearIssue(key, { workspaceId }), }, sentry: { title: "Link Sentry issue", description: "Use a Sentry short ID or URL for this task.", inputLabel: "Issue", placeholder: "PROJ-123 or paste issue URL", validationHint: "Paste a Sentry issue URL or short ID (PROJ-123).", successLabel: "Sentry issue linked", extractKey: extractSentryIssueKey, fetch: (key, workspaceId, instanceId) => getSentryIssue(workspaceId, instanceId ?? "", key), resolveLinkedKey: (requestedKey, result) => isSentryIssue(result) && result.shortId ? result.shortId : requestedKey, requiresInstance: true, }, }; // SentryLinkInstanceField resolves which Sentry instance a link targets: it // auto-selects the sole healthy instance, prompts with a picker when several // are healthy, and explains when none is usable. Owns the instances hook so the // parent dialog stays provider-agnostic. function SentryLinkInstanceField({ workspaceId, instanceId, onChange, }: { workspaceId: string; instanceId: string; onChange: (id: string) => void; }) { const sentry = useSentryInstances(workspaceId); // Depend on a by-value signature of the healthy instance IDs rather than the // `healthy` array identity: useSentryInstances rebuilds that array on every // poll (new reference, usually identical contents), which would otherwise // re-fire this effect needlessly. The signature still changes when the sole // healthy instance is swapped (state stays "single") or a selected instance // drops out of a "multi" set. const healthySignature = sentry.healthy .map((instance) => instance.id) .sort() .join("\n"); useEffect(() => { const healthyIds = healthySignature ? healthySignature.split("\n") : []; if (sentry.state === "single") { onChange(healthyIds[0]); } else if ( sentry.state === "empty" || sentry.state === "unhealthy" || (instanceId !== "" && !healthyIds.includes(instanceId)) ) { onChange(""); } }, [sentry.state, healthySignature, instanceId, onChange]); if (sentry.state === "empty" || sentry.state === "unhealthy") { return (

Connect a healthy Sentry instance in Settings → Integrations → Sentry to link issues.

); } if (sentry.state !== "multi") return null; return (
); } // useExternalLinkForm holds the dialog's transient state and the submit action // (resolve key → fetch the external issue → rename the task). Extracted so the // component body stays within the max-lines lint budget. function useExternalLinkForm( config: ProviderConfig, task: TaskExternalLinkTarget, workspaceId: string, open: boolean, onOpenChange: (open: boolean) => void, ) { const { toast } = useToast(); const store = useAppStoreApi(); const [input, setInput] = useState(""); const [instanceId, setInstanceId] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (open) { setInput(""); setError(null); setInstanceId(""); } }, [open]); const submit = async () => { const key = config.extractKey(input); if (!key) { setError(config.validationHint); return; } if (config.requiresInstance && !instanceId) { setError("Select a Sentry instance to link against."); return; } setSubmitting(true); setError(null); try { const result = await config.fetch(key, workspaceId, instanceId || undefined); const state = store.getState(); const latestTask = findTaskInSnapshots( task.id, state.kanbanMulti.snapshots, state.kanban.tasks, ); const linkedKey = config.resolveLinkedKey?.(key, result) ?? key; await updateTask(task.id, { title: buildLinkedIssueTitle(latestTask?.title ?? task.title, linkedKey), }); toast({ description: config.successLabel, variant: "success" }); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : `Failed to link ${config.inputLabel}.`); } finally { setSubmitting(false); } }; return { input, setInput, instanceId, setInstanceId, submitting, error, setError, submit }; } export function TaskExternalLinkDialog({ open, onOpenChange, provider, task, workspaceId, }: TaskExternalLinkDialogProps) { const config = PROVIDERS[provider]; const { input, setInput, instanceId, setInstanceId, submitting, error, setError, submit } = useExternalLinkForm(config, task, workspaceId, open, onOpenChange); return ( {config.title} {config.description} {provider === "sentry" && ( )}
{ setInput(event.target.value); if (error) setError(null); }} placeholder={config.placeholder} disabled={submitting} /> {error && (

{error}

)}
); }