"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); 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"); } } return (
{/* Title Section */}

{issue.title}

#{issue.number}
{/* Convert to Epic Button */} {issue.type !== 'epic' && ( )} {/* Edit Button */} 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 */}
{/* Right Sidebar */}

Linked Issues

{loadingCrossRepo ? (
Loading...
) : crossRepoLinks.length === 0 ? (
No linked issues
) : ( )} {canLink && (
setCrossRepoTarget(e.target.value)} placeholder="owner/repo#123" className="flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white" />
{crossRepoError && (
{crossRepoError}
)}
)}

Linked Pull Requests

{loadingLinks ? (
Loading...
) : linkedPRs.length === 0 ? (
No linked pull requests
) : ( )}
{/* Sub-tasks / Child Issues */} {(issue.type === 'epic' || (issue.children && issue.children.length > 0)) && (

{issue.type === 'epic' ? 'Child Issues' : 'Sub-tasks'}

{issue.children && issue.children.length > 0 && ( {issue.children.filter(c => c.state === 'closed').length} / {issue.children.length} )}
{/* Progress Bar */} {issue.children && issue.children.length > 0 && (
c.state === 'closed').length / issue.children.length) * 100}%` }} />
)}
{issue.children && issue.children.map(child => (
{child.state === "open" ? ( ) : ( )}
{child.title}
))} {issue.type === 'epic' && ( Add sub-task )}
)}
); }