"use client"; import { motion } from "framer-motion"; import { AlertCircle, CheckCircle2, MessageSquare, User, Tag, Calendar, Clock, Link2, GitBranch, MoreHorizontal, Edit3, Pin, Lock, Trash2, Layers, CheckSquare, ListTodo } from "lucide-react"; import { useEffect, useState } from "react"; import { marked } from "marked"; import DOMPurify from "isomorphic-dompurify"; import { formatDistanceToNow } from "date-fns"; interface Issue { id: string; number: number; title: string; body: string; state: "open" | "closed"; status?: { id: string; name: string; color: string; type: string; }; createdAt: string; author: { username: string; avatarUrl?: string; }; assignees?: Array<{ username: string; avatarUrl?: string; }>; labels?: Array<{ name: string; color: string; }>; type: string; parent?: { number: number; title: string; state: "open" | "closed"; }; children?: Array<{ number: number; title: string; state: "open" | "closed"; }>; } interface Props { issue: Issue; bodyHtml: string; repoOwner: string; repoName: string; canLink?: boolean; } function timeAgo(date: string): string { const now = new Date(); const past = new Date(date); const diffMs = now.getTime() - past.getTime(); const diffMins = Math.floor(diffMs / 60000); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins} minutes ago`; const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) return `${diffHours} hours ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 30) return `${diffDays} days ago`; const diffMonths = Math.floor(diffDays / 30); return `${diffMonths} months ago`; } export default function IssueDetail({ issue, bodyHtml, repoOwner, repoName, canLink }: Props) { const [showMenu, setShowMenu] = useState(false); const [linkedPRs, setLinkedPRs] = useState>([]); const [loadingLinks, setLoadingLinks] = useState(true); const [crossRepoLinks, setCrossRepoLinks] = useState>([]); const [loadingCrossRepo, setLoadingCrossRepo] = useState(true); const [crossRepoTarget, setCrossRepoTarget] = useState(""); const [crossRepoType, setCrossRepoType] = useState("relates"); const [crossRepoError, setCrossRepoError] = useState(null); const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(issue.title); const [editBody, setEditBody] = useState(issue.body); const [saving, setSaving] = useState(false); async function convertToEpic() { if (!confirm("Are you sure you want to convert this issue to an Epic?")) return; try { const res = await fetch(`/api/repos/${repoOwner}/${repoName}/issues/${issue.number}`, { method: "PATCH", body: JSON.stringify({ type: "epic" }), headers: { "Content-Type": "application/json" } }); if (res.ok) { window.location.reload(); } else { alert("Failed to convert to epic"); } } catch (e) { console.error(e); alert("Failed to convert to epic"); } } useEffect(() => { let isMounted = true; async function loadLinks() { try { const res = await fetch(`/api/repos/${repoOwner}/${repoName}/issues/${issue.number}/linked-prs`); if (!res.ok) throw new Error("Failed to load linked PRs"); const data = await res.json(); if (isMounted) { setLinkedPRs(data.links || []); } } catch (e) { if (isMounted) { setLinkedPRs([]); } } finally { if (isMounted) { setLoadingLinks(false); } } } loadLinks(); return () => { isMounted = false; }; }, [repoOwner, repoName, issue.number]); useEffect(() => { let isMounted = true; async function loadCrossRepoLinks() { try { const res = await fetch(`/api/repos/${repoOwner}/${repoName}/issues/${issue.number}/cross-repo-links`); if (!res.ok) throw new Error("Failed to load linked issues"); const data = await res.json(); if (isMounted) { setCrossRepoLinks(data.links || []); } } catch (e) { if (isMounted) { setCrossRepoLinks([]); } } finally { if (isMounted) { setLoadingCrossRepo(false); } } } loadCrossRepoLinks(); return () => { isMounted = false; }; }, [repoOwner, repoName, issue.number]); async function handleCrossRepoLink() { if (!crossRepoTarget.trim()) return; setCrossRepoError(null); try { const res = await fetch(`/api/repos/${repoOwner}/${repoName}/issues/${issue.number}/cross-repo-links`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: crossRepoTarget.trim(), linkType: crossRepoType }) }); if (!res.ok) { const data = await res.json(); throw new Error(data.error?.message || "Failed to link issue"); } const data = await res.json(); setCrossRepoLinks((prev) => [data.link, ...prev]); setCrossRepoTarget(""); } catch (e: any) { setCrossRepoError(e.message || "Failed to link issue"); } } async function handleSave() { setSaving(true); try { const res = await fetch(`/api/repos/${repoOwner}/${repoName}/issues/${issue.number}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editTitle, description: editBody }), }); if (!res.ok) throw new Error("Failed to update issue"); setIsEditing(false); window.location.reload(); } catch (e) { alert("Failed to save changes"); } finally { setSaving(false); } } return (
{/* Title Section */}
{isEditing ? ( setEditTitle(e.target.value)} className="flex-1 text-2xl md:text-3xl font-bold text-foreground bg-background border border-border rounded-lg px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring" /> ) : (

{issue.title}

)} #{issue.number}
{/* Convert to Epic Button */} {issue.type !== 'epic' && ( )} {/* Edit Button */} {isEditing ? (
) : ( setIsEditing(true)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border bg-secondary text-sm text-muted-foreground hover:bg-secondary/80 transition-all" > Edit )}
{/* Status & Type Badges */}
{issue.status ? (
{issue.status.name} ) : ( {issue.state === "open" ? ( ) : ( )} {issue.state === "open" ? "Open" : "Closed"} )}
{issue.type === 'epic' && } {issue.type === 'task' && } {issue.type === 'issue' && } {issue.type}
{issue.author.username} {" "}opened this issue {timeAgo(issue.createdAt)}
{/* Main Content Grid */}
{/* Left Column - Issue Body & Comments */}
{isEditing ? (