/** * MergeReviewDrawer — Full merge request review interface. * Shows: diff summary, conflict cards per conflict class, resolution controls. * Supports approve / reject / request-changes decisions. */ import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { X, GitMerge, Plus, Edit2, ArrowUpDown, Trash2, AlertTriangle, CheckCircle2, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { MergeRequest, ConflictRecord } from '../../types/canvas'; interface MergeReviewDrawerProps { mergeRequest: MergeRequest | null; isOpen: boolean; onClose: () => void; onReview: (decision: 'approve' | 'reject' | 'changes_requested', comment?: string) => Promise; } const CONFLICT_CLASS_CONFIG: Record = { safe_append: { icon: Plus, label: 'Safe Append', color: '#34d399', bg: 'rgba(52,211,153,0.1)', description: 'New component added in source, not present in target. Will be appended.', severity: 'none', }, safe_reorder: { icon: ArrowUpDown, label: 'Safe Reorder', color: '#60a5fa', bg: 'rgba(96,165,250,0.1)', description: 'Component order differs between branches. Will be merged using longest-common-subsequence.', severity: 'none', }, component_content_conflict: { icon: Edit2, label: 'Content Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)', description: 'Both branches edited the same component content. Manual resolution required.', severity: 'high', }, query_descriptor_conflict: { icon: AlertTriangle, label: 'Query Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)', description: 'Data source or filter parameters conflict. Requires reviewer decision.', severity: 'high', }, layout_slot_conflict: { icon: ArrowUpDown, label: 'Layout Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)', description: 'Same layout slot claimed by different components.', severity: 'medium', }, access_policy_conflict: { icon: AlertTriangle, label: 'Policy Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)', description: 'Access control policies diverge. Stricter policy will prevail.', severity: 'high', }, delete_edit_conflict: { icon: Trash2, label: 'Delete-Edit Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)', description: 'Component deleted in one branch, edited in another.', severity: 'high', }, }; function ConflictCard({ conflict, resolution, onResolve }: { conflict: ConflictRecord; resolution?: string; onResolve: (id: string, decision: string) => void; }) { const [expanded, setExpanded] = useState(false); const cfg = CONFLICT_CLASS_CONFIG[conflict.conflictClass] ?? CONFLICT_CLASS_CONFIG.component_content_conflict; const Icon = cfg.icon; const isResolved = !!resolution; return (
{expanded && (

{cfg.description}

{conflict.description && (

"{conflict.description}"

)} {/* Show values if present */} {(conflict.sourceValue !== undefined || conflict.targetValue !== undefined) && (
{conflict.sourceValue !== undefined && (

Source

{JSON.stringify(conflict.sourceValue).slice(0, 80)}

)} {conflict.targetValue !== undefined && (

Target

{JSON.stringify(conflict.targetValue).slice(0, 80)}

)}
)} {/* Resolution buttons */} {cfg.severity !== 'none' && !isResolved && (
)} {isResolved && (
Resolved: {resolution}
)}
)}
); } export function MergeReviewDrawer({ mergeRequest, isOpen, onClose, onReview }: MergeReviewDrawerProps) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const [comment, setComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [resolutions, setResolutions] = useState>({}); const [success, setSuccess] = useState(null); const conflicts: ConflictRecord[] = mergeRequest?.conflicts ?? []; const diff = mergeRequest?.diffSummary; const highSeverityConflicts = conflicts.filter( (c) => (CONFLICT_CLASS_CONFIG[c.conflictClass]?.severity ?? 'none') === 'high' ); const unresolvedHigh = highSeverityConflicts.filter((c) => !resolutions[c.conflictId]); const canApprove = unresolvedHigh.length === 0; const handleReview = async (decision: 'approve' | 'reject' | 'changes_requested') => { setSubmitting(true); try { await onReview(decision, comment); setSuccess(decision); setTimeout(() => { setSuccess(null); onClose(); }, 2000); } finally { setSubmitting(false); } }; const content = ( {isOpen && ( <>
{/* Header */}

{mergeRequest?.title ?? 'Merge Request'}

{mergeRequest?.sourceBranchId ?? 'fork'} → {mergeRequest?.targetBranchId ?? 'main'}

{success ? (

{success === 'approve' ? 'Merge approved!' : success === 'reject' ? 'Review rejected' : 'Changes requested'}

) : ( <>
{/* Diff summary */} {diff && (

Changes

{[ { label: 'Added', val: diff.componentsAdded, color: '#34d399', icon: Plus }, { label: 'Edited', val: diff.componentsEdited, color: '#60a5fa', icon: Edit2 }, { label: 'Reordered', val: diff.componentsReordered, color: '#a78bfa', icon: ArrowUpDown }, { label: 'Deleted', val: diff.componentsDeleted, color: '#f87171', icon: Trash2 }, ].map(({ label, val, color, icon: Icon }) => (

{val}

{label}

))}
)} {/* Conflicts */} {conflicts.length > 0 && (

Conflicts ({conflicts.length})

{unresolvedHigh.length > 0 && ( {unresolvedHigh.length} require resolution )}
{conflicts.map((c) => ( setResolutions((p) => ({ ...p, [id]: decision })) } /> ))}
)} {conflicts.length === 0 && (

No conflicts — clean merge

)} {/* Reviewer comment */}