import { useState, useEffect } from 'react' import { useParams, Link, useNavigate } from 'react-router-dom' import { usePR, useMergePR, useClosePR, useReopenPR, useUpdatePR } from '../api/queries/prs' import { useRepoDiff } from '../api/queries/repos' import { useMergeStrategies } from '../api/queries/workflow' import { useAuth } from '../contexts/AuthContext' import { DiffViewer } from '../components/diff/DiffViewer' import { Skeleton } from '../ui/Skeleton' import { cn } from '../lib/utils' import type { PRReviewer } from '../types/api' function timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime() const min = Math.floor(diff / 60_000) if (min < 1) return 'just now' if (min < 60) return `${min}m ago` const hr = Math.floor(min / 60) if (hr < 24) return `${hr}h ago` const d = Math.floor(hr / 24) if (d < 30) return `${d}d ago` return `${Math.floor(d / 30)}mo ago` } function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } // ── Status badge ────────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { const styles = { open: { bg: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', dot: 'bg-[#00875A]', label: 'Open' }, merged: { bg: 'bg-[#EAE6FF] border-[#C0B6F2] text-[#403294]', dot: 'bg-[#6554C0]', label: 'Merged' }, closed: { bg: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', dot: 'bg-[var(--c-muted)]', label: 'Closed' }, }[status] ?? { bg: '', dot: '', label: status } return ( {styles.label} ) } // ── Reviewer avatar ─────────────────────────────────────────────────────────── function ReviewerAvatar({ rv }: { rv: PRReviewer }) { const color = `hsl(${(rv.username.charCodeAt(0) * 37) % 360},55%,45%)` return (
{rv.avatarUrl ? ( {rv.username} ) : (
{rv.username[0].toUpperCase()}
)} {rv.username}
) } // ── Merge strategy selector ─────────────────────────────────────────────────── const STRATEGIES = [ { value: 'merge', label: 'Merge commit', desc: 'Preserve all commits with a merge commit' }, { value: 'squash', label: 'Squash and merge', desc: 'Combine all commits into one' }, { value: 'rebase', label: 'Rebase and merge', desc: 'Rebase commits onto the target branch' }, ] as const export default function PRDetailPage() { const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>() const navigate = useNavigate() const { user } = useAuth() const numId = parseInt(prId, 10) const { data: pr, isLoading, isError } = usePR(owner, repo, numId) const { data: strategies } = useMergeStrategies(owner, repo) const { data: diffs = [] } = useRepoDiff(owner, repo, pr?.targetBranch ?? '', pr?.sourceBranch ?? '') const merge = useMergePR(owner, repo) const close = useClosePR(owner, repo) const reopen = useReopenPR(owner, repo) const update = useUpdatePR(owner, repo, numId) // Merge strategy const [strategy, setStrategy] = useState<'merge' | 'squash' | 'rebase'>('merge') const [mergeError, setMergeError] = useState('') // Inline title editing const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [editingBody, setEditingBody] = useState(false) const [bodyDraft, setBodyDraft] = useState('') useEffect(() => { if (pr) { setTitleDraft(pr.title) setBodyDraft(pr.body) // Set default strategy to first allowed one if (strategies) { if (strategies.allowMergeCommit) setStrategy('merge') else if (strategies.allowSquash) setStrategy('squash') else if (strategies.allowRebase) setStrategy('rebase') } } }, [pr, strategies]) if (isLoading) { return (
) } if (isError || !pr) { return (

404

Pull request not found.

← Back to pull requests
) } const isAuthor = user?.id === pr.authorId const canEdit = isAuthor || user?.isAdmin const isOpen = pr.status === 'open' const isMerged = pr.status === 'merged' const isClosed = pr.status === 'closed' const allowedStrategies = STRATEGIES.filter(s => { if (!strategies) return true if (s.value === 'merge') return strategies.allowMergeCommit if (s.value === 'squash') return strategies.allowSquash if (s.value === 'rebase') return strategies.allowRebase return false }) async function handleMerge() { setMergeError('') try { await merge.mutateAsync({ prId: numId, strategy }) navigate(`/repos/${owner}/${repo}/pulls`) } catch (e: any) { setMergeError(e.message ?? 'Merge failed.') } } async function saveTitle() { if (titleDraft.trim() === pr!.title) { setEditingTitle(false); return } await update.mutateAsync({ title: titleDraft.trim() }) setEditingTitle(false) } async function saveBody() { if (bodyDraft === pr!.body) { setEditingBody(false); return } await update.mutateAsync({ body: bodyDraft }) setEditingBody(false) } const statLine = (

#{pr.id} · {pr.sourceBranch} {pr.targetBranch} · opened {timeAgo(pr.createdAt)} {pr.updatedAt !== pr.createdAt && <>·updated {timeAgo(pr.updatedAt)}}

) return (
{/* Breadcrumb */}
{repo} / Pull requests / #{pr.id}
{/* Title row */}
{editingTitle ? (
setTitleDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }} className="flex-1 text-xl font-semibold border border-[var(--c-brand-focus)] rounded px-3 py-1.5 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none" autoFocus />
) : (

{pr.title}

{canEdit && isOpen && ( )}
)} {statLine}
{/* Main 2-col layout */}
{/* ── Left: description + diff ── */}
{/* Description */}
Description {canEdit && isOpen && !editingBody && ( )}
{editingBody ? (