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[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 ? (
) : (
{pr.body ? (
{pr.body}
) : (
No description provided.
)}
)}
{/* Status banner for non-open PRs */}
{(isMerged || isClosed) && (
{isMerged ? (
<>
Merged on {formatDate(pr.updatedAt)}
>
) : (
<>
Closed on {formatDate(pr.updatedAt)} without merging
>
)}
)}
{/* Files changed */}
Files changed
{diffs.length > 0 && (
({diffs.length})
)}
{diffs.length > 0 && (
<>
+{diffs.reduce((s, f) => s + f.additions, 0)}
−{diffs.reduce((s, f) => s + f.deletions, 0)}
>
)}
{diffs.length === 0 ? (
{pr.sourceBranch && pr.targetBranch
? 'No differences detected between branches.'
: 'Diff unavailable — branches may not exist locally.'}
) : (
<>
{/* File list */}
{diffs.map(f => (
-
+{f.additions}
−{f.deletions}
{f.path}
))}
>
)}
{/* ── Right sidebar ── */}
{/* Merge / close / reopen actions */}
{isOpen && (
Merge pull request
{allowedStrategies.length > 0 ? (
<>
{mergeError &&
{mergeError}
}
>
) : (
No merge strategies are enabled for this repository.
)}
)}
{isClosed && (
)}
{/* Reviewers */}
Reviewers
{pr.reviewers.length === 0 ? (
No reviewers assigned.
) : (
pr.reviewers.map(rv =>
)
)}
{/* Metadata */}
Details
{[
{ label: 'From', value: {pr.sourceBranch} },
{ label: 'Into', value: {pr.targetBranch} },
{ label: 'Opened', value: {formatDate(pr.createdAt)} },
{ label: 'Updated', value: {timeAgo(pr.updatedAt)} },
].map(row => (
- {row.label}
- {row.value}
))}
{/* Quick links */}
All pull requests
New pull request
)
}