diff --git a/src/app/(app)/projects/[projectId]/settings/import/page.tsx b/src/app/(app)/projects/[projectId]/settings/import/page.tsx new file mode 100644 index 00000000..a4981d62 --- /dev/null +++ b/src/app/(app)/projects/[projectId]/settings/import/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useParams } from 'next/navigation' +import { ImportTab } from '@/components/projects/settings/import-tab' +import { ProjectSettingsShell } from '@/components/projects/settings/project-settings-shell' +import { useProjectsStore } from '@/stores/projects-store' + +export default function ProjectSettingsImportPage() { + const params = useParams() + const projectKey = params.projectId as string + const project = useProjectsStore((s) => s.getProjectByKey(projectKey)) + const projectId = project?.id || projectKey + + return ( + + + + ) +} diff --git a/src/app/api/projects/[projectId]/import/route.ts b/src/app/api/projects/[projectId]/import/route.ts new file mode 100644 index 00000000..ef3c96a2 --- /dev/null +++ b/src/app/api/projects/[projectId]/import/route.ts @@ -0,0 +1,226 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { badRequestError, handleApiError, validationError } from '@/lib/api-utils' +import { requireAuth, requirePermission, requireProjectByKey } from '@/lib/auth-helpers' +import { db } from '@/lib/db' +import { projectEvents } from '@/lib/events' +import { PERMISSIONS } from '@/lib/permissions' + +const parsedTicketSchema = z.object({ + externalKey: z.string(), + title: z.string().min(1), + description: z.string().nullable(), + type: z.enum(['epic', 'story', 'task', 'bug', 'subtask']), + priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest', 'critical']), + storyPoints: z.number().nullable(), + labels: z.array(z.string()), + originalStatus: z.string().nullable(), + originalPriority: z.string().nullable(), + originalType: z.string().nullable(), + isResolved: z.boolean(), + resolution: z.string().nullable(), +}) + +const importSchema = z.object({ + tickets: z.array(parsedTicketSchema).min(1, 'At least one ticket is required'), + columnId: z.string().min(1), + sprintId: z.string().nullable().optional(), + createMissingLabels: z.boolean().default(true), +}) + +/** + * POST /api/projects/[projectId]/import - Import tickets from external sources + * Requires ticket creation permission + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ projectId: string }> }, +) { + try { + const user = await requireAuth() + const { projectId: projectKey } = await params + const projectId = await requireProjectByKey(projectKey) + + // Check ticket creation permission + await requirePermission(user.id, projectId, PERMISSIONS.TICKETS_CREATE) + + const body = await request.json() + const parsed = importSchema.safeParse(body) + + if (!parsed.success) { + return validationError(parsed) + } + + const { tickets, columnId, sprintId, createMissingLabels } = parsed.data + + // Verify column belongs to project + const column = await db.column.findFirst({ + where: { id: columnId, projectId }, + }) + + if (!column) { + return badRequestError('Column not found or does not belong to project') + } + + // Verify sprint belongs to project (if provided) + if (sprintId) { + const sprint = await db.sprint.findFirst({ + where: { id: sprintId, projectId }, + }) + if (!sprint) { + return badRequestError('Sprint not found or does not belong to project') + } + } + + // Import tickets in a transaction + const result = await db.$transaction(async (tx) => { + const warnings: string[] = [] + let labelsCreated = 0 + + // Get existing labels for this project + const existingLabels = await tx.label.findMany({ + where: { projectId }, + select: { id: true, name: true }, + }) + const labelMap = new Map(existingLabels.map((l) => [l.name.toLowerCase(), l.id])) + + // Collect all unique label names from tickets + const allLabelNames = new Set() + for (const ticket of tickets) { + for (const label of ticket.labels) { + allLabelNames.add(label) + } + } + + // Create missing labels if requested + if (createMissingLabels) { + const defaultColors = [ + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#06b6d4', + '#3b82f6', + '#8b5cf6', + '#ec4899', + '#6b7280', + '#14b8a6', + ] + let colorIdx = 0 + + for (const labelName of allLabelNames) { + if (!labelMap.has(labelName.toLowerCase())) { + const color = defaultColors[colorIdx % defaultColors.length] + colorIdx++ + try { + const newLabel = await tx.label.create({ + data: { + name: labelName, + color, + projectId, + }, + }) + labelMap.set(labelName.toLowerCase(), newLabel.id) + labelsCreated++ + } catch { + // Label might already exist due to race condition; try to find it + const existing = await tx.label.findFirst({ + where: { projectId, name: labelName }, + }) + if (existing) { + labelMap.set(labelName.toLowerCase(), existing.id) + } else { + warnings.push(`Failed to create label "${labelName}"`) + } + } + } + } + } + + // Get max ticket number for this project + const maxResult = await tx.ticket.aggregate({ + where: { projectId }, + _max: { number: true }, + }) + let nextNumber = (maxResult._max.number ?? 0) + 1 + + // Get max order in target column + const maxOrderResult = await tx.ticket.aggregate({ + where: { columnId }, + _max: { order: true }, + }) + let nextOrder = (maxOrderResult._max.order ?? -1) + 1 + + // Create each ticket + let imported = 0 + for (const ticketData of tickets) { + // Resolve label IDs + const labelIds: string[] = [] + for (const labelName of ticketData.labels) { + const labelId = labelMap.get(labelName.toLowerCase()) + if (labelId) { + labelIds.push(labelId) + } else if (!createMissingLabels) { + warnings.push(`Label "${labelName}" not found for ticket "${ticketData.externalKey}"`) + } + } + + try { + const newTicket = await tx.ticket.create({ + data: { + number: nextNumber, + title: ticketData.title, + description: ticketData.description, + type: ticketData.type, + priority: ticketData.priority, + storyPoints: ticketData.storyPoints, + order: nextOrder, + columnId, + projectId, + creatorId: user.id, + sprintId: sprintId ?? undefined, + resolution: ticketData.isResolved ? (ticketData.resolution ?? 'Done') : null, + resolvedAt: ticketData.isResolved ? new Date() : null, + labels: labelIds.length > 0 ? { connect: labelIds.map((id) => ({ id })) } : undefined, + }, + }) + + // Create sprint history entry if assigned to a sprint + if (sprintId) { + await tx.ticketSprintHistory.create({ + data: { + ticketId: newTicket.id, + sprintId, + entryType: 'added', + }, + }) + } + + nextNumber++ + nextOrder++ + imported++ + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error' + warnings.push(`Failed to import "${ticketData.externalKey}": ${msg}`) + } + } + + return { imported, labelsCreated, warnings } + }) + + // Emit real-time events so other tabs/users see the imported tickets + const tabId = request.headers.get('X-Tab-Id') || undefined + projectEvents.emitTicketEvent({ + type: 'ticket.created', + projectId, + ticketId: 'batch-import', + userId: user.id, + tabId, + timestamp: Date.now(), + }) + + return NextResponse.json(result, { status: 201 }) + } catch (error) { + return handleApiError(error, 'import tickets') + } +} diff --git a/src/components/projects/settings/import-tab.tsx b/src/components/projects/settings/import-tab.tsx new file mode 100644 index 00000000..5d1d45c9 --- /dev/null +++ b/src/components/projects/settings/import-tab.tsx @@ -0,0 +1,680 @@ +'use client' + +import { + AlertCircle, + AlertTriangle, + ArrowRight, + CheckCircle2, + FileJson, + FileSpreadsheet, + FileUp, + Loader2, + Trash2, + Upload, +} from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Switch } from '@/components/ui/switch' +import { getTabId } from '@/hooks/use-realtime' +import { autoDetectAndParse, parseJiraCsv } from '@/lib/import' +import type { ImportResult, ParsedTicket, ParseResult } from '@/lib/import/types' +import { showToast } from '@/lib/toast' +import { useBoardStore } from '@/stores/board-store' +import { useProjectsStore } from '@/stores/projects-store' + +interface ImportTabProps { + projectId: string +} + +type ImportStage = 'upload' | 'preview' | 'importing' | 'complete' + +const TYPE_COLORS: Record = { + epic: 'bg-purple-500/20 text-purple-400', + story: 'bg-green-500/20 text-green-400', + task: 'bg-blue-500/20 text-blue-400', + bug: 'bg-red-500/20 text-red-400', + subtask: 'bg-zinc-500/20 text-zinc-400', +} + +const PRIORITY_COLORS: Record = { + critical: 'text-red-400', + highest: 'text-orange-400', + high: 'text-amber-400', + medium: 'text-yellow-400', + low: 'text-blue-400', + lowest: 'text-zinc-400', +} + +export function ImportTab({ projectId }: ImportTabProps) { + const [stage, setStage] = useState('upload') + const [parseResult, setParseResult] = useState(null) + const [selectedTickets, setSelectedTickets] = useState>(new Set()) + const [columnId, setColumnId] = useState('') + const [sprintId, setSprintId] = useState(null) + const [createMissingLabels, setCreateMissingLabels] = useState(true) + const [importResult, setImportResult] = useState(null) + const [fileName, setFileName] = useState('') + const [, setIsImporting] = useState(false) + const fileInputRef = useRef(null) + + // Get columns and sprints from stores + const columns = useBoardStore((s) => s.getColumns(projectId)) + const { getProjectByKey, getProject } = useProjectsStore() + const project = getProject(projectId) ?? getProjectByKey(projectId) + const projectKey = project?.key ?? projectId + + // Get sprints from the project data (use board store columns for column list) + // We'll fetch sprints from the API in a simpler way + + // Set default column to the first column if not set + const firstColumnId = columns[0]?.id ?? '' + + const handleFileSelect = useCallback( + async (file: File) => { + setFileName(file.name) + const isCSV = file.name.toLowerCase().endsWith('.csv') || file.type === 'text/csv' + + try { + const text = await file.text() + + let result: ParseResult + + if (isCSV) { + result = parseJiraCsv(text) + } else { + // Try to parse as JSON + let data: unknown + try { + data = JSON.parse(text) + } catch { + showToast.error('Invalid file format. Please upload a JSON or CSV file.') + return + } + result = autoDetectAndParse(data) + } + + if (result.tickets.length === 0) { + showToast.error( + result.warnings.length > 0 ? result.warnings[0] : 'No tickets found in the file', + ) + return + } + + setParseResult(result) + // Select all tickets by default + setSelectedTickets(new Set(result.tickets.map((_, i) => i))) + setColumnId(firstColumnId) + setStage('preview') + + if (result.warnings.length > 0) { + showToast.warning( + `Parsed ${result.tickets.length} tickets with ${result.warnings.length} warning(s)`, + ) + } else { + showToast.success( + `Found ${result.tickets.length} tickets from ${result.source === 'jira' ? 'Jira' : 'GitHub'}`, + ) + } + } catch { + showToast.error('Failed to read the file') + } + }, + [firstColumnId], + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + if (file) handleFileSelect(file) + }, + [handleFileSelect], + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handleFileSelect(file) + // Reset input so the same file can be selected again + e.target.value = '' + }, + [handleFileSelect], + ) + + const toggleTicket = useCallback((index: number) => { + setSelectedTickets((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + }, []) + + const selectAll = useCallback(() => { + if (!parseResult) return + setSelectedTickets(new Set(parseResult.tickets.map((_, i) => i))) + }, [parseResult]) + + const selectNone = useCallback(() => { + setSelectedTickets(new Set()) + }, []) + + const handleImport = useCallback(async () => { + if (!parseResult || selectedTickets.size === 0 || !columnId) return + + const ticketsToImport = parseResult.tickets.filter((_, i) => selectedTickets.has(i)) + + setIsImporting(true) + setStage('importing') + + try { + const response = await fetch(`/api/projects/${projectKey}/import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tab-Id': getTabId(), + }, + body: JSON.stringify({ + tickets: ticketsToImport, + columnId, + sprintId: sprintId || null, + createMissingLabels, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Import failed' })) + throw new Error(error.error ?? 'Import failed') + } + + const result: ImportResult = await response.json() + setImportResult(result) + setStage('complete') + showToast.success(`Successfully imported ${result.imported} ticket(s)`) + } catch (error) { + const message = error instanceof Error ? error.message : 'Import failed' + showToast.error(message) + setStage('preview') + } finally { + setIsImporting(false) + } + }, [parseResult, selectedTickets, columnId, sprintId, createMissingLabels, projectKey]) + + const handleReset = useCallback(() => { + setStage('upload') + setParseResult(null) + setSelectedTickets(new Set()) + setImportResult(null) + setFileName('') + setColumnId(firstColumnId) + setSprintId(null) + }, [firstColumnId]) + + return ( +
+ {stage === 'upload' && ( + + )} + + {stage === 'preview' && parseResult && ( + + )} + + {stage === 'importing' && ( + + + +

+ Importing {selectedTickets.size} ticket(s)... +

+
+
+ )} + + {stage === 'complete' && importResult && ( + + )} +
+ ) +} + +// ============================================================================ +// Upload Stage +// ============================================================================ + +function UploadStage({ + fileInputRef, + onDrop, + onDragOver, + onFileInput, +}: { + fileInputRef: React.RefObject + onDrop: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onFileInput: (e: React.ChangeEvent) => void +}) { + return ( + <> + + + Import Tickets + + Import tickets from Jira or GitHub Issues. Upload a JSON or CSV file to get started. + + + +
fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click() + }} + tabIndex={0} + role="button" + aria-label="Upload file" + > + +

+ Drop a file here or click to browse +

+

Supports JSON and CSV files

+ +
+
+
+ +
+ + +
+ + Jira Import +
+
+ +

Accepts Jira JSON export or CSV export.

+

Mapped fields:

+
    +
  • Summary, Description, Priority
  • +
  • Issue Type, Status, Resolution
  • +
  • Labels, Components, Story Points
  • +
+
+
+ + + +
+ + GitHub Issues Import +
+
+ +

+ Accepts GitHub Issues JSON from the REST API or{' '} + + gh issue list --json + + . +

+

Mapped fields:

+
    +
  • Title, Body, Labels, State
  • +
  • Type/priority inferred from labels
  • +
  • Pull requests are excluded
  • +
+
+
+
+ + ) +} + +// ============================================================================ +// Preview Stage +// ============================================================================ + +function PreviewStage({ + parseResult, + selectedTickets, + fileName, + columnId, + sprintId: _sprintId, + createMissingLabels, + columns, + onToggleTicket, + onSelectAll, + onSelectNone, + onColumnChange, + onSprintChange: _onSprintChange, + onCreateMissingLabelsChange, + onImport, + onCancel, +}: { + parseResult: ParseResult + selectedTickets: Set + fileName: string + columnId: string + sprintId: string | null + createMissingLabels: boolean + columns: { id: string; name: string; icon?: string | null }[] + onToggleTicket: (index: number) => void + onSelectAll: () => void + onSelectNone: () => void + onColumnChange: (id: string) => void + onSprintChange: (id: string | null) => void + onCreateMissingLabelsChange: (v: boolean) => void + onImport: () => void + onCancel: () => void +}) { + const { tickets, warnings, source } = parseResult + const allLabels = new Set() + for (const t of tickets) { + for (const l of t.labels) allLabels.add(l) + } + + return ( + <> + {/* File info and source badge */} +
+
+ + {fileName} + + {source === 'jira' ? 'Jira' : 'GitHub'} + +
+ +
+ + {/* Warnings */} + {warnings.length > 0 && ( + + +
+ +
+ {warnings.map((w, i) => ( +

+ {w} +

+ ))} +
+
+
+
+ )} + + {/* Import settings */} + + + Import Settings + + +
+
+ + +
+
+ +
+ + +
+
+
+ + {/* Ticket preview */} + + +
+ + Preview ({selectedTickets.size} of {tickets.length} selected) + +
+ + +
+
+
+ +
+ {tickets.map((ticket, index) => ( + + ))} +
+
+
+ + {/* Import action */} +
+

+ {selectedTickets.size} ticket{selectedTickets.size !== 1 ? 's' : ''} will be imported into + the selected column. +

+
+ + +
+
+ + ) +} + +// ============================================================================ +// Ticket Preview Row +// ============================================================================ + +function TicketPreviewRow({ + ticket, + index, + selected, + onToggle, +}: { + ticket: ParsedTicket + index: number + selected: boolean + onToggle: (index: number) => void +}) { + return ( + + ) +} + +// ============================================================================ +// Complete Stage +// ============================================================================ + +function CompleteStage({ result, onReset }: { result: ImportResult; onReset: () => void }) { + return ( + + + +
+

Import Complete

+

+ Successfully imported {result.imported} ticket{result.imported !== 1 ? 's' : ''} + {result.labelsCreated > 0 && ( + <> + {' '} + and created {result.labelsCreated} new label{result.labelsCreated !== 1 ? 's' : ''} + + )} +

+
+ + {result.warnings.length > 0 && ( +
+ +
+
+ +

+ {result.warnings.length} warning{result.warnings.length !== 1 ? 's' : ''} +

+
+
+ {result.warnings.map((w, i) => ( +

+ {w} +

+ ))} +
+
+
+ )} + + +
+
+ ) +} diff --git a/src/components/projects/settings/project-settings-shell.tsx b/src/components/projects/settings/project-settings-shell.tsx index 8b5ff007..6670a983 100644 --- a/src/components/projects/settings/project-settings-shell.tsx +++ b/src/components/projects/settings/project-settings-shell.tsx @@ -4,6 +4,7 @@ import { Bot, CalendarClock, GitBranch, + Import, Loader2, Settings, Shield, @@ -98,6 +99,16 @@ export function ProjectSettingsShell({ tab, children }: ProjectSettingsShellProp }, ] : []), + ...(canViewSettings + ? [ + { + value: 'import', + label: 'Import', + href: `${basePath}/import`, + icon: , + }, + ] + : []), ...(canManageLabels ? [ { diff --git a/src/lib/import/github-parser.ts b/src/lib/import/github-parser.ts new file mode 100644 index 00000000..9c7f798b --- /dev/null +++ b/src/lib/import/github-parser.ts @@ -0,0 +1,210 @@ +/** + * GitHub Issues Import Parser + * + * Parses GitHub Issues JSON from: + * - GitHub REST API response (GET /repos/{owner}/{repo}/issues) + * - `gh issue list --json` CLI output + * - Manual JSON export + */ + +import type { IssueType, Priority } from '@/types' +import type { ParsedTicket, ParseResult } from './types' + +/** + * Well-known GitHub labels that map to PUNT ticket types. + */ +const TYPE_LABEL_MAP: Record = { + bug: 'bug', + 'type: bug': 'bug', + 'type:bug': 'bug', + defect: 'bug', + epic: 'epic', + 'type: epic': 'epic', + story: 'story', + 'type: story': 'story', + 'user story': 'story', + feature: 'story', + 'type: feature': 'story', + enhancement: 'story', + task: 'task', + 'type: task': 'task', + chore: 'task', + subtask: 'subtask', + 'sub-task': 'subtask', +} + +/** + * Well-known GitHub labels that map to PUNT priorities. + */ +const PRIORITY_LABEL_MAP: Record = { + critical: 'critical', + 'priority: critical': 'critical', + p0: 'critical', + 'priority: highest': 'highest', + p1: 'highest', + 'priority: high': 'high', + p2: 'high', + high: 'high', + 'priority: medium': 'medium', + p3: 'medium', + medium: 'medium', + 'priority: low': 'low', + p4: 'low', + low: 'low', + 'priority: lowest': 'lowest', + p5: 'lowest', + trivial: 'lowest', +} + +/** + * Extract type from GitHub labels. Returns the first matching type label found. + */ +function extractType(labels: string[]): { type: IssueType; matchedLabel: string | null } { + for (const label of labels) { + const normalized = label.toLowerCase().trim() + if (normalized in TYPE_LABEL_MAP) { + return { type: TYPE_LABEL_MAP[normalized], matchedLabel: label } + } + } + return { type: 'task', matchedLabel: null } +} + +/** + * Extract priority from GitHub labels. Returns the first matching priority label found. + */ +function extractPriority(labels: string[]): { priority: Priority; matchedLabel: string | null } { + for (const label of labels) { + const normalized = label.toLowerCase().trim() + if (normalized in PRIORITY_LABEL_MAP) { + return { priority: PRIORITY_LABEL_MAP[normalized], matchedLabel: label } + } + } + return { priority: 'medium', matchedLabel: null } +} + +/** + * Parse a single GitHub issue object. + */ +function parseGitHubIssue(issue: Record, warnings: string[]): ParsedTicket | null { + // Handle both REST API format and gh CLI format + const title = (issue.title as string) ?? '' + if (!title) { + const number = issue.number ?? 'unknown' + warnings.push(`Skipping issue #${number}: no title found`) + return null + } + + // Skip pull requests (GitHub API includes PRs in issue endpoints) + if (issue.pull_request || issue.pullRequest) { + return null + } + + const number = issue.number as number | undefined + const externalKey = number ? `#${number}` : '' + + // Description from body + const description = (issue.body as string) ?? null + + // Labels - handle both string arrays and object arrays + const rawLabels = issue.labels as unknown[] | undefined + const labelNames: string[] = [] + if (Array.isArray(rawLabels)) { + for (const label of rawLabels) { + if (typeof label === 'string') { + labelNames.push(label) + } else if (label && typeof label === 'object') { + const name = (label as Record).name as string | undefined + if (name) labelNames.push(name) + } + } + } + + // Extract type and priority from labels + const { type, matchedLabel: typeLabel } = extractType(labelNames) + const { priority, matchedLabel: priorityLabel } = extractPriority(labelNames) + + // Filter out type/priority labels from the label list (they're now encoded as fields) + const filteredLabels = labelNames.filter((l) => { + const normalized = l.toLowerCase().trim() + return normalized !== typeLabel?.toLowerCase() && normalized !== priorityLabel?.toLowerCase() + }) + + // Status from state + const state = (issue.state as string) ?? null + const stateReason = (issue.state_reason as string) ?? (issue.stateReason as string) ?? null + const isResolved = state === 'closed' + + // Map GitHub close reason to resolution + let resolution: string | null = null + if (isResolved) { + if (stateReason === 'not_planned') { + resolution = "Won't Do" + } else { + resolution = 'Done' + } + } + + return { + externalKey, + title, + description, + type, + priority, + storyPoints: null, + labels: filteredLabels, + originalStatus: state, + originalPriority: null, + originalType: null, + isResolved, + resolution, + } +} + +/** + * Parse GitHub Issues JSON data. + * + * Supports multiple formats: + * - Array of issues: [{ title, body, labels, ... }, ...] + * - Single issue: { title, body, labels, ... } + * - GitHub API paginated response: { items: [...] } (from search endpoint) + */ +export function parseGitHubJson(data: unknown): ParseResult { + const warnings: string[] = [] + const tickets: ParsedTicket[] = [] + + let issues: Record[] + + if (Array.isArray(data)) { + issues = data + } else if (data && typeof data === 'object') { + const obj = data as Record + if (Array.isArray(obj.items)) { + // GitHub search API format + issues = obj.items as Record[] + } else if (obj.title) { + // Single issue + issues = [obj] + } else { + return { + tickets: [], + warnings: ['Could not find issues in the GitHub JSON data'], + source: 'github', + } + } + } else { + return { tickets: [], warnings: ['Invalid GitHub JSON data'], source: 'github' } + } + + for (const issue of issues) { + const parsed = parseGitHubIssue(issue, warnings) + if (parsed) { + tickets.push(parsed) + } + } + + if (tickets.length === 0 && issues.length > 0) { + warnings.push('No valid issues found in the GitHub export (pull requests are excluded)') + } + + return { tickets, warnings, source: 'github' } +} diff --git a/src/lib/import/index.ts b/src/lib/import/index.ts new file mode 100644 index 00000000..4b33fd9e --- /dev/null +++ b/src/lib/import/index.ts @@ -0,0 +1,58 @@ +/** + * Ticket Import Module + * + * Provides parsers for importing tickets from Jira and GitHub Issues. + */ + +import { parseGitHubJson } from './github-parser' +import { parseJiraJson } from './jira-parser' +import type { ParseResult } from './types' + +export { parseGitHubJson } from './github-parser' +export { parseJiraCsv, parseJiraJson } from './jira-parser' +export type { ImportRequest, ImportResult, ParsedTicket, ParseResult } from './types' + +/** + * Auto-detect the source format from JSON data and parse accordingly. + * + * Detection heuristics: + * - Jira: issues have `key` field (e.g., "PROJ-123"), `fields` object, or `issuetype` + * - GitHub: issues have `number`, `state`, `body` fields, or `pull_request` + */ +export function autoDetectAndParse(data: unknown): ParseResult { + if (Array.isArray(data) && data.length > 0) { + const sample = data[0] + if (typeof sample === 'object' && sample !== null) { + // Jira indicators: key field like "PROJ-123", fields object, issuetype + if ('key' in sample || 'fields' in sample) { + return parseJiraJson(data) + } + // GitHub indicators: number, state, body, html_url with github.com + if ('state' in sample || ('number' in sample && 'body' in sample)) { + return parseGitHubJson(data) + } + if ( + 'html_url' in sample && + String((sample as Record).html_url).includes('github.com') + ) { + return parseGitHubJson(data) + } + } + } else if (data && typeof data === 'object') { + const obj = data as Record + // Jira: { issues: [...] } or single issue with key/fields + if ('issues' in obj || 'key' in obj || 'fields' in obj) { + return parseJiraJson(data) + } + // GitHub: { items: [...] } (search API) or single issue + if ('items' in obj || 'state' in obj) { + return parseGitHubJson(data) + } + } + + // Default: try Jira first, then GitHub + const jiraResult = parseJiraJson(data) + if (jiraResult.tickets.length > 0) return jiraResult + + return parseGitHubJson(data) +} diff --git a/src/lib/import/jira-parser.ts b/src/lib/import/jira-parser.ts new file mode 100644 index 00000000..77e5e8f3 --- /dev/null +++ b/src/lib/import/jira-parser.ts @@ -0,0 +1,423 @@ +/** + * Jira Import Parser + * + * Parses Jira Cloud/Server JSON export format into PUNT tickets. + * Handles the standard Jira REST API response format as well as + * the JSON export from Jira's built-in export feature. + */ + +import type { IssueType, Priority } from '@/types' +import type { ParsedTicket, ParseResult } from './types' + +/** + * Map Jira issue type names to PUNT types. + * Jira has many possible issue types; we map them to our five types. + */ +function mapJiraType(jiraType: string | undefined | null): IssueType { + if (!jiraType) return 'task' + const normalized = jiraType.toLowerCase().trim() + + const typeMap: Record = { + epic: 'epic', + story: 'story', + task: 'task', + bug: 'bug', + subtask: 'subtask', + 'sub-task': 'subtask', + 'sub task': 'subtask', + 'technical task': 'task', + improvement: 'story', + 'new feature': 'story', + feature: 'story', + } + + return typeMap[normalized] ?? 'task' +} + +/** + * Map Jira priority names to PUNT priorities. + */ +function mapJiraPriority(jiraPriority: string | undefined | null): Priority { + if (!jiraPriority) return 'medium' + const normalized = jiraPriority.toLowerCase().trim() + + const priorityMap: Record = { + blocker: 'critical', + critical: 'critical', + highest: 'highest', + high: 'high', + medium: 'medium', + low: 'low', + lowest: 'lowest', + minor: 'low', + trivial: 'lowest', + } + + return priorityMap[normalized] ?? 'medium' +} + +/** + * Check if a Jira status indicates the issue is resolved. + */ +function isJiraResolved( + status: string | undefined | null, + resolution: string | undefined | null, +): boolean { + if (resolution) return true + if (!status) return false + const normalized = status.toLowerCase().trim() + return ['done', 'closed', 'resolved', 'complete', 'completed'].includes(normalized) +} + +/** + * Map Jira resolution to PUNT resolution. + */ +function mapJiraResolution(resolution: string | undefined | null): string | null { + if (!resolution) return null + const normalized = resolution.toLowerCase().trim() + + const resolutionMap: Record = { + done: 'Done', + fixed: 'Done', + complete: 'Done', + "won't fix": "Won't Fix", + wontfix: "Won't Fix", + "won't do": "Won't Do", + duplicate: 'Duplicate', + 'cannot reproduce': 'Cannot Reproduce', + incomplete: 'Incomplete', + unresolved: null as unknown as string, + } + + return resolutionMap[normalized] ?? 'Done' +} + +/** + * Parse a single Jira issue object (from REST API format). + * Handles both nested fields format and flat format. + */ +function parseJiraIssue(issue: Record, warnings: string[]): ParsedTicket | null { + // Handle both REST API format (fields nested) and flat format + const fields = (issue.fields as Record) ?? issue + const key = (issue.key as string) ?? (fields.key as string) ?? '' + + const title = (fields.summary as string) ?? (fields.title as string) ?? '' + if (!title) { + warnings.push(`Skipping issue ${key || 'unknown'}: no summary/title found`) + return null + } + + // Description can be a string or an Atlassian Document Format (ADF) object + let description: string | null = null + if (typeof fields.description === 'string') { + description = fields.description + } else if (fields.description && typeof fields.description === 'object') { + // ADF format - extract text content + description = extractAdfText(fields.description as Record) + } + + // Type + const issueType = fields.issuetype as Record | undefined + const typeStr = (issueType?.name as string) ?? (fields.type as string) ?? null + + // Priority + const priority = fields.priority as Record | undefined + const priorityStr = (priority?.name as string) ?? (fields.priority as string) ?? null + + // Story points - Jira uses customfield_10028 or story_points or customfield_10016 + let storyPoints: number | null = null + const spCandidates = [ + fields.story_points, + fields.storyPoints, + fields.customfield_10028, + fields.customfield_10016, + fields.customfield_10014, + fields.story_point_estimate, + ] + for (const sp of spCandidates) { + if (typeof sp === 'number') { + storyPoints = sp + break + } + } + + // Labels + const labels: string[] = [] + if (Array.isArray(fields.labels)) { + for (const label of fields.labels) { + if (typeof label === 'string') labels.push(label) + } + } + + // Components can also be treated as labels + if (Array.isArray(fields.components)) { + for (const comp of fields.components) { + const name = typeof comp === 'string' ? comp : (comp as Record)?.name + if (typeof name === 'string') labels.push(name) + } + } + + // Status + const status = fields.status as Record | undefined + const statusStr = (status?.name as string) ?? (fields.status as string) ?? null + + // Resolution + const resolution = fields.resolution as Record | string | undefined | null + const resolutionStr = + typeof resolution === 'string' + ? resolution + : (((resolution as Record)?.name as string | undefined) ?? null) + + return { + externalKey: key, + title, + description, + type: mapJiraType(typeStr), + priority: mapJiraPriority(typeof priorityStr === 'string' ? priorityStr : null), + storyPoints, + labels, + originalStatus: statusStr, + originalPriority: typeof priorityStr === 'string' ? priorityStr : null, + originalType: typeStr, + isResolved: isJiraResolved(statusStr, resolutionStr), + resolution: mapJiraResolution(resolutionStr), + } +} + +/** + * Extract plain text from Atlassian Document Format (ADF). + * This is a simplified extraction - it won't preserve rich formatting + * but will capture all text content. + */ +function extractAdfText(node: Record): string { + if (node.type === 'text' && typeof node.text === 'string') { + return node.text + } + + const content = node.content as Record[] | undefined + if (!Array.isArray(content)) return '' + + return content + .map((child) => { + const text = extractAdfText(child) + // Add newlines after block-level elements + const blockTypes = [ + 'paragraph', + 'heading', + 'bulletList', + 'orderedList', + 'listItem', + 'codeBlock', + 'blockquote', + ] + if (blockTypes.includes(child.type as string)) { + return `${text}\n` + } + return text + }) + .join('') + .trim() +} + +/** + * Parse Jira JSON export data. + * + * Supports multiple formats: + * - Array of issues: [{ key, fields: { ... } }, ...] + * - Object with issues array: { issues: [{ key, fields: { ... } }, ...] } + * - Single issue: { key, fields: { ... } } + */ +export function parseJiraJson(data: unknown): ParseResult { + const warnings: string[] = [] + const tickets: ParsedTicket[] = [] + + let issues: Record[] + + if (Array.isArray(data)) { + issues = data + } else if (data && typeof data === 'object') { + const obj = data as Record + if (Array.isArray(obj.issues)) { + issues = obj.issues as Record[] + } else if (obj.key || obj.fields || obj.summary) { + // Single issue + issues = [obj] + } else { + return { + tickets: [], + warnings: ['Could not find issues in the Jira JSON data'], + source: 'jira', + } + } + } else { + return { tickets: [], warnings: ['Invalid Jira JSON data'], source: 'jira' } + } + + for (const issue of issues) { + const parsed = parseJiraIssue(issue, warnings) + if (parsed) { + tickets.push(parsed) + } + } + + if (tickets.length === 0 && issues.length > 0) { + warnings.push('No valid tickets found in the Jira export') + } + + return { tickets, warnings, source: 'jira' } +} + +/** + * Parse Jira CSV export data. + * + * Expected columns (case-insensitive): + * Summary, Issue key, Issue Type, Priority, Status, Description, Labels, Story Points, Resolution + */ +export function parseJiraCsv(csvText: string): ParseResult { + const warnings: string[] = [] + const tickets: ParsedTicket[] = [] + + const lines = parseCsvLines(csvText) + if (lines.length < 2) { + return { + tickets: [], + warnings: ['CSV file appears to be empty or has no data rows'], + source: 'jira', + } + } + + const headers = lines[0].map((h) => h.toLowerCase().trim()) + + // Map column indices + const colIndex = (names: string[]): number => { + for (const name of names) { + const idx = headers.indexOf(name.toLowerCase()) + if (idx >= 0) return idx + } + return -1 + } + + const summaryIdx = colIndex(['summary', 'title']) + const keyIdx = colIndex(['issue key', 'key', 'issue_key']) + const typeIdx = colIndex(['issue type', 'type', 'issue_type', 'issuetype']) + const priorityIdx = colIndex(['priority']) + const statusIdx = colIndex(['status']) + const descriptionIdx = colIndex(['description']) + const labelsIdx = colIndex(['labels', 'label']) + const storyPointsIdx = colIndex([ + 'story points', + 'story_points', + 'storypoints', + 'custom field (story points)', + ]) + const resolutionIdx = colIndex(['resolution']) + + if (summaryIdx === -1) { + return { tickets: [], warnings: ['Could not find Summary/Title column in CSV'], source: 'jira' } + } + + for (let i = 1; i < lines.length; i++) { + const row = lines[i] + const title = row[summaryIdx]?.trim() + if (!title) { + warnings.push(`Skipping row ${i + 1}: empty summary`) + continue + } + + const key = keyIdx >= 0 ? (row[keyIdx]?.trim() ?? '') : `ROW-${i}` + const typeStr = typeIdx >= 0 ? (row[typeIdx]?.trim() ?? null) : null + const priorityStr = priorityIdx >= 0 ? (row[priorityIdx]?.trim() ?? null) : null + const statusStr = statusIdx >= 0 ? (row[statusIdx]?.trim() ?? null) : null + const description = descriptionIdx >= 0 ? row[descriptionIdx]?.trim() || null : null + const resolutionStr = resolutionIdx >= 0 ? row[resolutionIdx]?.trim() || null : null + + let storyPoints: number | null = null + if (storyPointsIdx >= 0 && row[storyPointsIdx]) { + const parsed = Number(row[storyPointsIdx].trim()) + if (!Number.isNaN(parsed)) storyPoints = parsed + } + + const labels: string[] = [] + if (labelsIdx >= 0 && row[labelsIdx]) { + const labelStr = row[labelsIdx].trim() + if (labelStr) { + // Labels may be comma-separated or space-separated + for (const l of labelStr.split(/[,;]/)) { + const trimmed = l.trim() + if (trimmed) labels.push(trimmed) + } + } + } + + tickets.push({ + externalKey: key, + title, + description, + type: mapJiraType(typeStr), + priority: mapJiraPriority(priorityStr), + storyPoints, + labels, + originalStatus: statusStr, + originalPriority: priorityStr, + originalType: typeStr, + isResolved: isJiraResolved(statusStr, resolutionStr), + resolution: mapJiraResolution(resolutionStr), + }) + } + + return { tickets, warnings, source: 'jira' } +} + +/** + * Simple CSV parser that handles quoted fields with commas and newlines. + */ +function parseCsvLines(text: string): string[][] { + const rows: string[][] = [] + let currentRow: string[] = [] + let currentField = '' + let inQuotes = false + + for (let i = 0; i < text.length; i++) { + const char = text[i] + const nextChar = text[i + 1] + + if (inQuotes) { + if (char === '"' && nextChar === '"') { + // Escaped quote + currentField += '"' + i++ + } else if (char === '"') { + inQuotes = false + } else { + currentField += char + } + } else { + if (char === '"') { + inQuotes = true + } else if (char === ',') { + currentRow.push(currentField) + currentField = '' + } else if (char === '\n' || (char === '\r' && nextChar === '\n')) { + currentRow.push(currentField) + currentField = '' + if (currentRow.some((f) => f.trim() !== '')) { + rows.push(currentRow) + } + currentRow = [] + if (char === '\r') i++ // Skip \n in \r\n + } else { + currentField += char + } + } + } + + // Handle last field/row + if (currentField || currentRow.length > 0) { + currentRow.push(currentField) + if (currentRow.some((f) => f.trim() !== '')) { + rows.push(currentRow) + } + } + + return rows +} diff --git a/src/lib/import/types.ts b/src/lib/import/types.ts new file mode 100644 index 00000000..ceaa1d9b --- /dev/null +++ b/src/lib/import/types.ts @@ -0,0 +1,67 @@ +/** + * Shared types for the ticket import system. + * These types are used by both parsers and the API route. + */ + +import type { IssueType, Priority } from '@/types' + +/** + * A single ticket parsed from an external source, ready for preview and import. + */ +export interface ParsedTicket { + /** Original external ID/key (e.g., "PROJ-123" for Jira, "#42" for GitHub) */ + externalKey: string + title: string + description: string | null + type: IssueType + priority: Priority + storyPoints: number | null + labels: string[] + /** Original status string from the source system */ + originalStatus: string | null + /** Original priority string from the source system */ + originalPriority: string | null + /** Original type string from the source system */ + originalType: string | null + /** Whether this ticket appears to be resolved/closed */ + isResolved: boolean + /** Resolution value if resolved */ + resolution: string | null +} + +/** + * Result from parsing an import file. + */ +export interface ParseResult { + /** Successfully parsed tickets */ + tickets: ParsedTicket[] + /** Warnings encountered during parsing (non-fatal) */ + warnings: string[] + /** The detected source format */ + source: 'jira' | 'github' +} + +/** + * Import request body sent to the API. + */ +export interface ImportRequest { + tickets: ParsedTicket[] + /** Column ID to place imported tickets into */ + columnId: string + /** Optional sprint ID to assign tickets to */ + sprintId?: string | null + /** Whether to create labels that don't exist yet */ + createMissingLabels: boolean +} + +/** + * Result from the import API. + */ +export interface ImportResult { + /** Number of tickets successfully imported */ + imported: number + /** Number of labels created during import */ + labelsCreated: number + /** Warnings from the import process */ + warnings: string[] +}