"use client"; import { motion, AnimatePresence } from "framer-motion"; import { GitPullRequest, GitMerge, XCircle, MessageSquare, Search, Tag, Calendar, User, ArrowUpDown, Plus, GitBranch, CheckCircle2, Link2, Loader2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; interface PullRequest { id: string; number: number; title: string; state: "open" | "closed" | "merged"; createdAt: string; commentCount: number; sourceBranch: string; targetBranch: string; author: { username: string; avatarUrl?: string; }; labels?: Array<{ name: string; color: string; }>; isDraft?: boolean; } interface Props { pullRequests: PullRequest[]; openCount: number; closedCount: number; repoOwner: string; repoName: string; } interface DependencyGraphResponse { nodes: Array<{ prId: string; prNumber: number; title: string; dependsOn: string[]; blockedBy: string[]; dependencyType: "branch" | "files" | "manual"; }>; edges: Array<{ from: string; to: string; type: string; }>; } interface StackOrderSuggestion { order: string[]; cycles: string[][]; } 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`; } function getPRIcon(state: string, isDraft?: boolean) { if (isDraft) { return { icon: GitPullRequest, color: "text-muted-foreground", bg: "bg-gray-500/10" }; } switch (state) { case "open": return { icon: GitPullRequest, color: "text-green-400", bg: "bg-green-500/10" }; case "merged": return { icon: GitMerge, color: "text-purple-400", bg: "bg-purple-500/10" }; case "closed": return { icon: XCircle, color: "text-red-400", bg: "bg-red-500/10" }; default: return { icon: GitPullRequest, color: "text-muted-foreground", bg: "bg-gray-500/10" }; } } export default function PullRequestsList({ pullRequests, openCount, closedCount, repoOwner, repoName }: Props) { const [filter, setFilter] = useState<"open" | "closed" | "all">("open"); const [searchQuery, setSearchQuery] = useState(""); const [selectedPrIds, setSelectedPrIds] = useState([]); const [graph, setGraph] = useState(null); const [suggestion, setSuggestion] = useState(null); const [workflowMsg, setWorkflowMsg] = useState(""); const [isAnalyzing, setIsAnalyzing] = useState(false); const [isSuggesting, setIsSuggesting] = useState(false); const [isApplying, setIsApplying] = useState(false); const [isBulkMerging, setIsBulkMerging] = useState(false); const [bulkMergeMethod, setBulkMergeMethod] = useState<"merge" | "squash" | "rebase">("merge"); const prById = new Map(pullRequests.map((pr) => [pr.id, pr])); const filteredPRs = pullRequests.filter(pr => { const matchesFilter = filter === "all" || (filter === "open" && pr.state === "open") || (filter === "closed" && (pr.state === "closed" || pr.state === "merged")); const matchesSearch = pr.title.toLowerCase().includes(searchQuery.toLowerCase()); return matchesFilter && matchesSearch; }); const toggleSelectPr = (prId: string) => { setSelectedPrIds((prev) => prev.includes(prId) ? prev.filter((id) => id !== prId) : [...prev, prId] ); }; const selectAllVisibleOpen = () => { const ids = filteredPRs.filter((pr) => pr.state === "open").map((pr) => pr.id); setSelectedPrIds(ids); }; const analyzeDependencies = async () => { setIsAnalyzing(true); setWorkflowMsg(""); try { const response = await fetch( `/api/repos/${repoOwner}/${repoName}/pulls/dependencies?includeFiles=true` ); const payload = await response.json(); if (!response.ok) { throw new Error(payload?.error?.message || "Failed to analyze dependencies"); } setGraph(payload?.data?.graph || null); const nodes = payload?.data?.graph?.nodes?.length || 0; const edges = payload?.data?.graph?.edges?.length || 0; setWorkflowMsg(`Dependency graph updated (${nodes} PRs, ${edges} edges).`); toast.success(`Found ${nodes} PRs with ${edges} dependencies`); } catch (error: any) { setWorkflowMsg(error?.message || "Failed to analyze dependencies"); toast.error(error?.message || "Failed to analyze dependencies"); } finally { setIsAnalyzing(false); } }; const suggestStackOrder = async () => { if (selectedPrIds.length < 2) { setWorkflowMsg("Select at least 2 open PRs to generate a stack suggestion."); return; } setIsSuggesting(true); setWorkflowMsg(""); try { const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/stack-order`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prIds: selectedPrIds }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload?.error?.message || "Failed to suggest stack order"); } const data = payload?.data as StackOrderSuggestion; setSuggestion(data); if (data.cycles.length > 0) { setWorkflowMsg("Cycles detected. Resolve dependencies before applying a stack order."); } else { setWorkflowMsg(`Suggested order generated for ${data.order.length} PRs.`); } } catch (error: any) { setWorkflowMsg(error?.message || "Failed to suggest stack order"); } finally { setIsSuggesting(false); } }; const applySuggestedOrder = async () => { if (!suggestion || suggestion.order.length < 2) { setWorkflowMsg("Generate a valid suggestion before applying."); return; } if (suggestion.cycles.length > 0) { setWorkflowMsg("Cannot apply stack order while dependency cycles exist."); return; } setIsApplying(true); setWorkflowMsg(""); try { const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/stack-order`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ prIds: suggestion.order, name: `Dependency stack (${suggestion.order.length} PRs)`, }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload?.error?.message || "Failed to apply stack order"); } setWorkflowMsg(`Created stack ${payload?.data?.stackId}.`); toast.success("Stack created successfully"); window.location.reload(); } catch (error: any) { setWorkflowMsg(error?.message || "Failed to apply stack order"); toast.error(error?.message || "Failed to apply stack order"); } finally { setIsApplying(false); } }; const bulkMergeSelected = async () => { if (selectedPrIds.length === 0) { setWorkflowMsg("Select at least one open PR to bulk merge."); return; } setIsBulkMerging(true); setWorkflowMsg(""); try { const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/bulk-merge`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prIds: selectedPrIds, mergeMethod: bulkMergeMethod, }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload?.error?.message || "Failed to bulk merge selected PRs"); } const merged = payload?.data?.merged?.length || 0; const failed = payload?.data?.failed?.length || 0; const skipped = payload?.data?.skipped?.length || 0; setWorkflowMsg(`Bulk merge complete: merged ${merged}, failed ${failed}, skipped ${skipped}.`); if (merged > 0) toast.success(`Merged ${merged} PR(s)`); if (failed > 0) toast.error(`Failed to merge ${failed} PR(s)`); setSelectedPrIds([]); setTimeout(() => window.location.reload(), 1000); } catch (error: any) { setWorkflowMsg(error?.message || "Failed to bulk merge selected PRs"); toast.error(error?.message || "Failed to bulk merge"); } finally { setIsBulkMerging(false); } }; return (
{/* Header */} {/* Search Input */}
setSearchQuery(e.target.value)} className="w-full rounded-lg border border-border bg-card pl-10 pr-4 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" />
{/* Action Buttons */}
Labels Milestones New Pull Request
{/* PRs Container */} {/* Dependency Workflow */}

Dependency Workflow

Detect cross-PR dependencies, suggest stack order, and apply it directly.

{graph && (
Graph: {graph.nodes.length} PRs, {graph.edges.length} edges ( {graph.edges.filter((edge) => edge.type === "files").length} file conflicts)
)} {suggestion && (
Suggested:{" "} {suggestion.order.map((id) => { const pr = prById.get(id); return pr ? `#${pr.number}` : id; }).join(" -> ")}
)} {workflowMsg && (
{workflowMsg}
)}
{/* Filter Header */}
{/* PRs List */}
{filteredPRs.length > 0 ? ( filteredPRs.map((pr, index) => { const { icon: Icon, color, bg } = getPRIcon(pr.state, pr.isDraft); return ( {pr.state === "open" && (
); }