import { useCallback, useEffect, useMemo, useState } from "react"; import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; import type { LinearIssue } from "./linear-client.js"; import "./LinearImportView.css"; type StatusState = "loading" | "authenticated" | "missing" | "error"; interface RouteResponse { ok?: boolean; error?: string; code?: string; authenticated?: boolean; configured?: boolean; issues?: LinearIssue[]; issue?: LinearIssue; imported?: boolean | number; duplicate?: boolean; duplicates?: number; taskId?: string; results?: Array<{ imported: boolean; duplicate: boolean; taskId?: string; issue: { title: string } }>; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; value?: T; } const PLUGIN_BASE = "/api/plugins/fusion-plugin-linear-import"; function projectPayload(context?: PluginDashboardViewContext): Record { return context?.projectId ? { projectId: context.projectId } : {}; } async function postPluginRoute(path: string, body: Record, context?: PluginDashboardViewContext): Promise> { const response = await fetch(`${PLUGIN_BASE}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...projectPayload(context), ...body }), }); const json = await response.json().catch(() => ({})) as RouteResponse; if (!response.ok || json.ok === false) { throw new Error(json.error ?? `Linear Import request failed with status ${response.status}.`); } return json; } async function getStatus(context?: PluginDashboardViewContext): Promise> { const params = new URLSearchParams(projectPayload(context)); const suffix = params.toString() ? `?${params.toString()}` : ""; const response = await fetch(`${PLUGIN_BASE}/status${suffix}`); const json = await response.json().catch(() => ({})) as RouteResponse; if (!response.ok || json.ok === false) { throw new Error(json.error ?? `Linear status failed with status ${response.status}.`); } return json; } function StatusBadge({ state, message }: { state: StatusState; message?: string }) { const className = state === "authenticated" ? "auth" : state === "missing" ? "warning" : state === "error" ? "error" : "info"; const Icon = state === "authenticated" ? CheckCircle2 : state === "loading" ? Loader2 : AlertCircle; return ( ); } function issueSummary(issue: LinearIssue): string { return [issue.team?.key, issue.state?.name, issue.assignee?.name].filter(Boolean).join(" · ") || "No Linear metadata"; } export function LinearImportView({ context }: { context?: PluginDashboardViewContext }) { const [status, setStatus] = useState("loading"); const [statusMessage, setStatusMessage] = useState(); const [query, setQuery] = useState(""); const [teamKey, setTeamKey] = useState(""); const [state, setState] = useState("active"); const [assigneeId, setAssigneeId] = useState(""); const [issues, setIssues] = useState([]); const [selectedIds, setSelectedIds] = useState>(new Set()); const [previewId, setPreviewId] = useState(); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: "error" | "success" | "info"; text: string }>(); useEffect(() => { let cancelled = false; setStatus("loading"); getStatus(context) .then((result) => { if (cancelled) return; if (result.authenticated) { setStatus("authenticated"); setStatusMessage("Linear connected"); } else { setStatus("missing"); setStatusMessage("Add a Linear API key in Plugin Manager settings"); } }) .catch((error) => { if (cancelled) return; setStatus("error"); setStatusMessage(error instanceof Error ? error.message : "Linear status check failed"); }); return () => { cancelled = true; }; }, [context?.projectId]); const canSearch = status === "authenticated" && !loading; const selectedIssues = useMemo(() => issues.filter((issue) => selectedIds.has(issue.id)), [issues, selectedIds]); const previewIssue = useMemo(() => issues.find((issue) => issue.id === previewId) ?? selectedIssues[0] ?? issues[0], [issues, previewId, selectedIssues]); const browse = useCallback(async () => { if (!canSearch) return; setLoading(true); setMessage(undefined); try { const result = await postPluginRoute("/issues", { query, teamKey, state, assigneeId, limit: 50 }, context); const nextIssues = result.issues ?? []; setIssues(nextIssues); setSelectedIds(new Set()); setPreviewId(nextIssues[0]?.id); setMessage(nextIssues.length === 0 ? { type: "info", text: "No Linear issues matched the filters." } : undefined); } catch (error) { setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to browse Linear issues." }); } finally { setLoading(false); } }, [assigneeId, canSearch, context?.projectId, query, state, teamKey]); const importOne = useCallback(async (issue: LinearIssue | undefined) => { if (!issue || status !== "authenticated") return; setLoading(true); setMessage(undefined); try { const result = await postPluginRoute("/issues/import", { issueId: issue.id }, context); const text = result.duplicate ? `Skipped duplicate ${issue.identifier}; existing task ${result.taskId}.` : `Imported ${issue.identifier} as task ${result.taskId}.`; setMessage({ type: result.duplicate ? "info" : "success", text }); } catch (error) { setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to import Linear issue." }); } finally { setLoading(false); } }, [context?.projectId, status]); const importSelected = useCallback(async () => { if (selectedIssues.length === 0 || status !== "authenticated") return; setLoading(true); setMessage(undefined); try { const result = await postPluginRoute("/issues/import-batch", { issueIds: selectedIssues.map((issue) => issue.id) }, context); setMessage({ type: "success", text: `Batch import complete: ${result.imported ?? 0} imported, ${result.duplicates ?? 0} duplicates skipped.` }); } catch (error) { setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to import selected Linear issues." }); } finally { setLoading(false); } }, [context?.projectId, selectedIssues, status]); const toggleIssue = (issueId: string) => { setSelectedIds((current) => { const next = new Set(current); if (next.has(issueId)) next.delete(issueId); else next.add(issueId); return next; }); }; return (

Bundled plugin

Linear Import

Browse Linear issues and import selected issues into Fusion triage.

{status === "missing" ? (
Configure the Linear Import plugin in Plugin Manager settings with a Linear API key, then return here to browse issues.
) : null}
{ event.preventDefault(); void browse(); }}>
{message ?
{message.text}
: null}

Issues

{loading ?

: null} {!loading && issues.length === 0 ?

No Linear issues loaded yet. Browse to see matching issues.

: null}
{issues.map((issue) => (
toggleIssue(issue.id)} />

{issue.identifier}{issue.title}

{issueSummary(issue)}

{issue.team?.key ? {issue.team.key} : null} {issue.state?.name ? {issue.state.name} : null}
))}
); } export default LinearImportView;