Backend (prs.go):
Update — PATCH /{prID} edits title and/or body, validates title non-empty, returns prWithReviewers
Reopen — POST /{prID}/reopen transitions closed → open, fires webhook
Close now returns prWithReviewers and fires a webhook
Merge already existed; no changes needed
Frontend — PRDetailPage.tsx full rewrite:
Inline title editing — pencil icon (visible to author/admin when open), Enter to save, Esc to cancel
Inline body editing — same pattern in the description panel
Merge sidebar — radio buttons for allowed strategies (fetched from repo's merge strategy settings), "Merge pull request" button in Bitbucket purple, "Close without merging" below it
Status banner — merged (purple) or closed (grey) with the date, shown below the description
File list — scrollable +N −N table above the diff viewer showing all changed files with addition/deletion counts
Reopen button — appears in the sidebar when the PR is closed
Reviewers panel — lists assigned reviewers with avatars/initials
Details panel — from/into branches, opened date, last updated
Quick links — back to all PRs, open new PR
PRsPage.tsx — now shows real data:
Two tabs: "My pull requests" and "Awaiting my review" (with live counts from dashboard)
Per-repo quick links at the bottom showing open PR count badges
This commit is contained in:
@@ -28,6 +28,7 @@ const BlobPage = lazy(() => import('./pages/BlobPage'))
|
||||
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
|
||||
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
|
||||
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||
const CreatePRPage = lazy(() => import('./pages/CreatePRPage'))
|
||||
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
|
||||
const CommitsPage = lazy(() => import('./pages/CommitsPage'))
|
||||
const BranchesPage = lazy(() => import('./pages/BranchesPage'))
|
||||
@@ -79,6 +80,7 @@ export default function App() {
|
||||
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/new" element={<S><CreatePRPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
|
||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||
|
||||
@@ -3,6 +3,12 @@ import { z } from 'zod'
|
||||
import { api } from '../client'
|
||||
import type { PullRequest } from '../../types/api'
|
||||
|
||||
const prReviewerSchema = z.object({
|
||||
userId: z.number(),
|
||||
username: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
})
|
||||
|
||||
const prSchema = z.object({
|
||||
id: z.number(),
|
||||
repoId: z.number(),
|
||||
@@ -14,6 +20,7 @@ const prSchema = z.object({
|
||||
status: z.enum(['open', 'merged', 'closed']),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
reviewers: z.array(prReviewerSchema).default([]),
|
||||
})
|
||||
|
||||
const prsSchema = z.array(prSchema)
|
||||
@@ -38,13 +45,62 @@ export function usePR(owner: string, repo: string, prId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useMergePR(owner: string, repo: string) {
|
||||
export function useUpdatePR(owner: string, repo: string, prId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: { title?: string; body?: string }) =>
|
||||
api.patch<PullRequest>(`/api/v1/repos/${owner}/${repo}/pulls/${prId}`, prSchema, data),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['repos', owner, repo, 'pulls', prId], updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReopenPR(owner: string, repo: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (prId: number) =>
|
||||
api.post(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/merge`, mergeResponseSchema, {}),
|
||||
api.post<PullRequest>(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/reopen`, prSchema, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreatePR(owner: string, repo: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: { title: string; body: string; sourceBranch: string; targetBranch: string }) =>
|
||||
api.post<PullRequest>(`/api/v1/repos/${owner}/${repo}/pulls`, prSchema, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMergePR(owner: string, repo: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ prId, strategy }: { prId: number; strategy: string }) =>
|
||||
api.post(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/merge`, mergeResponseSchema, { strategy }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useClosePR(owner: string, repo: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (prId: number) =>
|
||||
api.post<PullRequest>(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/close`, prSchema, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useRepo, useRepoBranches } from '../api/queries/repos'
|
||||
import { useCreatePR } from '../api/queries/prs'
|
||||
import { useDefaultDescription } from '../api/queries/prs'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
|
||||
export default function CreatePRPage() {
|
||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: repoData, isLoading: repoLoading } = useRepo(owner, repoName)
|
||||
const { data: branches = [], isLoading: branchesLoading } = useRepoBranches(owner, repoName)
|
||||
const { data: descData } = useDefaultDescription(owner, repoName)
|
||||
const createPR = useCreatePR(owner, repoName)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [sourceBranch, setSourceBranch] = useState('')
|
||||
const [targetBranch, setTargetBranch] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Pre-fill target branch from repo default and body from template
|
||||
useEffect(() => {
|
||||
if (repoData && !targetBranch) setTargetBranch(repoData.defaultBranch)
|
||||
}, [repoData])
|
||||
|
||||
useEffect(() => {
|
||||
if (descData?.template && !body) setBody(descData.template)
|
||||
}, [descData])
|
||||
|
||||
const isLoading = repoLoading || branchesLoading
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!title.trim()) { setError('Title is required.'); return }
|
||||
if (!sourceBranch) { setError('Source branch is required.'); return }
|
||||
if (!targetBranch) { setError('Target branch is required.'); return }
|
||||
if (sourceBranch === targetBranch) { setError('Source and target branch must be different.'); return }
|
||||
|
||||
try {
|
||||
const pr = await createPR.mutateAsync({
|
||||
title: title.trim(),
|
||||
body,
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
navigate(`/repos/${owner}/${repoName}/pulls/${pr.id}`)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to create pull request.')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||
<Skeleton className="h-5 w-48 rounded" />
|
||||
<Skeleton className="h-10 rounded" />
|
||||
<Skeleton className="h-10 rounded" />
|
||||
<Skeleton className="h-32 rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const branchOptions = branches.map(b => b.name)
|
||||
const isEmpty = repoData?.isEmpty ?? false
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Link to={`/repos/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
|
||||
<span className="text-[var(--c-muted)]">/</span>
|
||||
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-[var(--c-brand)] hover:underline">Pull requests</Link>
|
||||
<span className="text-[var(--c-muted)]">/</span>
|
||||
<span className="font-semibold text-[var(--c-text)]">New</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Open a pull request</h1>
|
||||
<p className="text-sm text-[var(--c-muted)] mt-1">Propose changes by selecting branches to merge.</p>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="rounded-lg border border-[var(--c-border)] p-8 text-center space-y-2">
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">Repository is empty</p>
|
||||
<p className="text-xs text-[var(--c-muted)]">Push at least one commit before opening a pull request.</p>
|
||||
</div>
|
||||
) : branchOptions.length < 2 ? (
|
||||
<div className="rounded-lg border border-[var(--c-border)] p-8 text-center space-y-2">
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">Not enough branches</p>
|
||||
<p className="text-xs text-[var(--c-muted)]">You need at least two branches to open a pull request.</p>
|
||||
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-xs text-[var(--c-brand)] hover:underline">
|
||||
View branches →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Branch selectors */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Source branch</label>
|
||||
<select value={sourceBranch} onChange={e => setSourceBranch(e.target.value)} required
|
||||
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]">
|
||||
<option value="">Select branch…</option>
|
||||
{branchOptions.filter(b => b !== targetBranch).map(b => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pt-5 text-[var(--c-muted)]">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Target branch</label>
|
||||
<select value={targetBranch} onChange={e => setTargetBranch(e.target.value)} required
|
||||
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]">
|
||||
<option value="">Select branch…</option>
|
||||
{branchOptions.filter(b => b !== sourceBranch).map(b => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">
|
||||
Title <span className="text-[var(--c-danger)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Brief description of the change"
|
||||
maxLength={255}
|
||||
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Description</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={10}
|
||||
placeholder="Describe what changed and why…"
|
||||
className="w-full border border-[var(--c-border)] rounded px-3 py-2.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-[var(--c-danger)] bg-[var(--c-danger)]/5 border border-[var(--c-danger)]/20 rounded px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="submit" disabled={createPR.isPending}
|
||||
className="px-5 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
||||
{createPR.isPending ? 'Opening…' : 'Open pull request'}
|
||||
</button>
|
||||
<Link to={`/repos/${owner}/${repoName}/pulls`}
|
||||
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,194 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { usePR } from '../api/queries/prs'
|
||||
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 { MobileComment } from '../components/diff/MobileComment'
|
||||
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 (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border', styles.bg)}>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full', styles.dot)} />
|
||||
{styles.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Reviewer avatar ───────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewerAvatar({ rv }: { rv: PRReviewer }) {
|
||||
const color = `hsl(${(rv.username.charCodeAt(0) * 37) % 360},55%,45%)`
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{rv.avatarUrl ? (
|
||||
<img src={rv.avatarUrl} alt={rv.username} className="w-6 h-6 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-bold shrink-0" style={{ backgroundColor: color }}>
|
||||
{rv.username[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-[var(--c-text)]">{rv.username}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 { data: pr, isLoading, isError } = usePR(owner, repo, parseInt(prId, 10))
|
||||
const { data: diffs } = useRepoDiff(
|
||||
owner, repo,
|
||||
pr?.targetBranch ?? '',
|
||||
pr?.sourceBranch ?? '',
|
||||
)
|
||||
const [comment, setComment] = useState<{ file: string; line: number } | null>(null)
|
||||
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 (
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-4 w-48 rounded" />
|
||||
<Skeleton className="h-7 w-3/4 rounded" />
|
||||
<Skeleton className="h-4 w-56 rounded" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-28 rounded" />
|
||||
<Skeleton className="h-64 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-32 rounded" />
|
||||
<Skeleton className="h-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !pr) {
|
||||
return <div className="p-6 text-sm text-[var(--c-danger)]">Pull request not found.</div>
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 text-center">
|
||||
<p className="text-4xl font-bold text-[var(--c-border)] mb-3">404</p>
|
||||
<p className="text-sm text-[var(--c-muted)] mb-4">Pull request not found.</p>
|
||||
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-sm text-[var(--c-brand)] hover:underline">
|
||||
← Back to pull requests
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColor = pr.status === 'open'
|
||||
? 'bg-[#E3FCEF] text-[#006644] border-[#79F2C0]'
|
||||
: pr.status === 'merged'
|
||||
? 'bg-[#EAE6FF] text-[#403294] border-[#C0B6F2]'
|
||||
: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)] border-[var(--c-border)]'
|
||||
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 = (
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>#{pr.id}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.sourceBranch}</span>
|
||||
<span className="text-[var(--c-subtle)]">→</span>
|
||||
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.targetBranch}</span>
|
||||
<span>·</span>
|
||||
<span>opened {timeAgo(pr.createdAt)}</span>
|
||||
{pr.updatedAt !== pr.createdAt && <><span>·</span><span>updated {timeAgo(pr.updatedAt)}</span></>}
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-5">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<Link to={`/repos/${owner}/${repo}`} className="text-[var(--c-brand)] hover:underline">{repo}</Link>
|
||||
@@ -48,48 +198,265 @@ export default function PRDetailPage() {
|
||||
<span className="text-[var(--c-text)]">#{pr.id}</span>
|
||||
</div>
|
||||
|
||||
{/* Title + status */}
|
||||
{/* Title row */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">{pr.title}</h1>
|
||||
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', statusColor)}>
|
||||
{pr.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1">
|
||||
#{pr.id} · <span className="font-mono">{pr.sourceBranch}</span>
|
||||
{' → '}
|
||||
<span className="font-mono">{pr.targetBranch}</span>
|
||||
</p>
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={e => 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
|
||||
/>
|
||||
<button onClick={saveTitle} disabled={update.isPending}
|
||||
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => setEditingTitle(false)}
|
||||
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)] flex-1 min-w-0">{pr.title}</h1>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={pr.status} />
|
||||
{canEdit && isOpen && (
|
||||
<button onClick={() => setEditingTitle(true)}
|
||||
className="p-1.5 rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors"
|
||||
title="Edit title">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{statLine}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{pr.body && (
|
||||
<div className="p-4 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] whitespace-pre-wrap">
|
||||
{pr.body}
|
||||
{/* Main 2-col layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 items-start">
|
||||
|
||||
{/* ── Left: description + diff ── */}
|
||||
<div className="space-y-5 min-w-0">
|
||||
|
||||
{/* Description */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Description</span>
|
||||
{canEdit && isOpen && !editingBody && (
|
||||
<button onClick={() => setEditingBody(true)}
|
||||
className="flex items-center gap-1 text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editingBody ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<textarea value={bodyDraft} onChange={e => setBodyDraft(e.target.value)}
|
||||
rows={8} className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={saveBody} disabled={update.isPending}
|
||||
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={() => { setBodyDraft(pr.body); setEditingBody(false) }}
|
||||
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3">
|
||||
{pr.body ? (
|
||||
<p className="text-sm text-[var(--c-text)] whitespace-pre-wrap leading-relaxed">{pr.body}</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--c-muted)] italic">No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status banner for non-open PRs */}
|
||||
{(isMerged || isClosed) && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg border text-sm font-medium',
|
||||
isMerged ? 'border-[#C0B6F2] bg-[#EAE6FF] text-[#403294]' : 'border-[var(--c-border)] bg-[var(--c-surface-muted)] text-[var(--c-muted)]',
|
||||
)}>
|
||||
{isMerged ? (
|
||||
<>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
Merged on {formatDate(pr.updatedAt)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Closed on {formatDate(pr.updatedAt)} without merging
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files changed */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">
|
||||
Files changed
|
||||
{diffs.length > 0 && (
|
||||
<span className="ml-1.5 text-[var(--c-text)]">({diffs.length})</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{diffs.length > 0 && (
|
||||
<>
|
||||
<span className="text-[#006644]">+{diffs.reduce((s, f) => s + f.additions, 0)}</span>
|
||||
<span className="text-[var(--c-danger)]">−{diffs.reduce((s, f) => s + f.deletions, 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{diffs.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
|
||||
{pr.sourceBranch && pr.targetBranch
|
||||
? 'No differences detected between branches.'
|
||||
: 'Diff unavailable — branches may not exist locally.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* File list */}
|
||||
<ul className="border-b border-[var(--c-border)] divide-y divide-[var(--c-border)] max-h-40 overflow-y-auto">
|
||||
{diffs.map(f => (
|
||||
<li key={f.path} className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono hover:bg-[var(--c-surface-muted)]">
|
||||
<span className="text-[#006644] tabular-nums w-8 text-right shrink-0">+{f.additions}</span>
|
||||
<span className="text-[var(--c-danger)] tabular-nums w-8 text-right shrink-0">−{f.deletions}</span>
|
||||
<span className="text-[var(--c-text)] truncate">{f.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DiffViewer files={diffs} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff placeholder */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--c-text)] mb-3 flex items-center gap-2">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
Files changed
|
||||
</h2>
|
||||
{/* ── Right sidebar ── */}
|
||||
<div className="space-y-4">
|
||||
|
||||
<DiffViewer files={diffs ?? []} />
|
||||
{/* Merge / close / reopen actions */}
|
||||
{isOpen && (
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Merge pull request</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{allowedStrategies.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{allowedStrategies.map(s => (
|
||||
<label key={s.value} className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input type="radio" name="strategy" value={s.value}
|
||||
checked={strategy === s.value}
|
||||
onChange={() => setStrategy(s.value)}
|
||||
className="mt-0.5 accent-[var(--c-brand)]" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors">{s.label}</span>
|
||||
<p className="text-xs text-[var(--c-muted)]">{s.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{mergeError && <p className="text-xs text-[var(--c-danger)]">{mergeError}</p>}
|
||||
<button onClick={handleMerge} disabled={merge.isPending}
|
||||
className="w-full py-2 rounded bg-[#6554C0] text-white text-sm font-medium hover:bg-[#5243AA] disabled:opacity-50 transition-colors">
|
||||
{merge.isPending ? 'Merging…' : 'Merge pull request'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--c-muted)]">No merge strategies are enabled for this repository.</p>
|
||||
)}
|
||||
<button onClick={() => close.mutate(numId)} disabled={close.isPending}
|
||||
className="w-full py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-muted)] hover:text-[var(--c-danger)] hover:border-[var(--c-danger)] disabled:opacity-50 transition-colors">
|
||||
{close.isPending ? 'Closing…' : 'Close without merging'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="p-4">
|
||||
<button onClick={() => reopen.mutate(numId)} disabled={reopen.isPending}
|
||||
className="w-full py-2 rounded border border-[var(--c-brand)] text-[var(--c-brand)] text-sm font-medium hover:bg-[var(--c-brand-tint)] disabled:opacity-50 transition-colors">
|
||||
{reopen.isPending ? 'Reopening…' : 'Reopen pull request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reviewers */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Reviewers</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
{pr.reviewers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--c-muted)]">No reviewers assigned.</p>
|
||||
) : (
|
||||
pr.reviewers.map(rv => <ReviewerAvatar key={rv.userId} rv={rv} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
|
||||
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Details</span>
|
||||
</div>
|
||||
<dl className="p-3 space-y-2.5 text-xs">
|
||||
{[
|
||||
{ label: 'From', value: <span className="font-mono text-[var(--c-text)]">{pr.sourceBranch}</span> },
|
||||
{ label: 'Into', value: <span className="font-mono text-[var(--c-text)]">{pr.targetBranch}</span> },
|
||||
{ label: 'Opened', value: <span className="text-[var(--c-text)]" title={pr.createdAt}>{formatDate(pr.createdAt)}</span> },
|
||||
{ label: 'Updated', value: <span className="text-[var(--c-text)]" title={pr.updatedAt}>{timeAgo(pr.updatedAt)}</span> },
|
||||
].map(row => (
|
||||
<div key={row.label} className="flex items-center justify-between gap-2">
|
||||
<dt className="text-[var(--c-muted)] shrink-0">{row.label}</dt>
|
||||
<dd className="truncate">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link to={`/repos/${owner}/${repo}/pulls`}
|
||||
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
All pull requests
|
||||
</Link>
|
||||
<Link to={`/repos/${owner}/${repo}/pulls/new`}
|
||||
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New pull request
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile comment sheet */}
|
||||
<MobileComment
|
||||
open={!!comment}
|
||||
onClose={() => setComment(null)}
|
||||
filePath={comment?.file ?? ''}
|
||||
lineNumber={comment?.line ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+143
-18
@@ -1,38 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDashboard } from '../api/queries/dashboard'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { PRStatus } from '../types/api'
|
||||
import type { DashPR } from '../api/queries/dashboard'
|
||||
|
||||
type Tab = 'review' | 'mine'
|
||||
|
||||
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 PRRow({ pr, badge }: { pr: DashPR; badge?: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/repos/${pr.ownerName}/${pr.repoName}/pulls/${pr.id}`}
|
||||
className="flex items-start gap-3 p-4 border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-muted)] transition-colors group"
|
||||
>
|
||||
<svg className="mt-0.5 shrink-0 text-[#00875A]" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors truncate">{pr.title}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||
<Link to={`/repos/${pr.ownerName}/${pr.repoName}/pulls`} onClick={e => e.stopPropagation()}
|
||||
className="font-mono hover:underline">{pr.ownerName}/{pr.repoName}</Link>
|
||||
{' · '}
|
||||
<span className="font-mono">{pr.sourceBranch}</span>
|
||||
{' → '}
|
||||
<span className="font-mono">{pr.targetBranch}</span>
|
||||
{' · '}
|
||||
{timeAgo(pr.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<svg className="shrink-0 text-[var(--c-border)] group-hover:text-[var(--c-brand)] transition-colors mt-0.5" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ tab }: { tab: Tab }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center gap-3">
|
||||
<svg width="36" height="36" fill="none" stroke="var(--c-border)" strokeWidth="1.2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">
|
||||
{tab === 'review' ? 'No pull requests awaiting your review' : 'No open pull requests'}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1">
|
||||
{tab === 'review' ? "You're all caught up." : 'Open a PR from any repository to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Global PR view — shows PRs across a known repo (MVP: uses first repo)
|
||||
// In the full build this would aggregate across all user repos
|
||||
export default function PRsPage() {
|
||||
const [status, setStatus] = useState<PRStatus>('open')
|
||||
const [tab, setTab] = useState<Tab>('mine')
|
||||
const { data: dash, isLoading } = useDashboard()
|
||||
|
||||
const tabs: { id: Tab; label: string; count?: number }[] = [
|
||||
{ id: 'mine', label: 'My pull requests', count: dash?.myOpenPRs.length },
|
||||
{ id: 'review', label: 'Awaiting my review', count: dash?.reviewQueue.length },
|
||||
]
|
||||
|
||||
const rows = tab === 'mine' ? (dash?.myOpenPRs ?? []) : (dash?.reviewQueue ?? [])
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-[var(--c-text)]">Pull Requests</h1>
|
||||
<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline">
|
||||
Browse repos →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4 border-b border-[var(--c-border)]">
|
||||
{(['open', 'merged', 'closed'] as PRStatus[]).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0 border-b border-[var(--c-border)]">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px transition-colors min-h-[44px]',
|
||||
status === s
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.id
|
||||
? 'border-[var(--c-brand)] text-[var(--c-brand)]'
|
||||
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
|
||||
)}>
|
||||
{t.label}
|
||||
{t.count !== undefined && t.count > 0 && (
|
||||
<span className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded-full',
|
||||
tab === t.id ? 'bg-[var(--c-brand)] text-white' : 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]',
|
||||
)}>
|
||||
{t.count}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--c-muted)] py-6 text-center">
|
||||
Navigate to a repository to view its pull requests.
|
||||
</p>
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="border border-[var(--c-border)] rounded-lg divide-y divide-[var(--c-border)]">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-4">
|
||||
<Skeleton className="w-4 h-4 rounded-full mt-0.5 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
<Skeleton className="h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : (
|
||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
||||
{rows.map(pr => (
|
||||
<PRRow key={`${pr.repoId}-${pr.id}`} pr={pr} badge={
|
||||
tab === 'review' ? (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-px rounded-full bg-[var(--c-warning)]/15 text-[var(--c-warning)] border border-[var(--c-warning)]/30 shrink-0">
|
||||
Review requested
|
||||
</span>
|
||||
) : undefined
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-repo links */}
|
||||
{!isLoading && dash && dash.repos.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<p className="text-xs text-[var(--c-muted)] mb-2">Browse PRs by repository:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dash.repos.slice(0, 8).map(r => (
|
||||
<Link key={r.id} to={`/repos/${r.ownerName}/${r.name}/pulls`}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--c-border)] rounded-md bg-[var(--c-surface)] text-[var(--c-text)] hover:border-[var(--c-brand-focus)] hover:text-[var(--c-brand)] transition-colors">
|
||||
{r.name}
|
||||
{r.openPrCount > 0 && (
|
||||
<span className="text-[10px] font-bold text-white bg-[var(--c-brand)] rounded-full px-1.5 py-px">{r.openPrCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface Repository {
|
||||
|
||||
export type PRStatus = 'open' | 'merged' | 'closed'
|
||||
|
||||
export interface PRReviewer {
|
||||
userId: number
|
||||
username: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
export interface PullRequest {
|
||||
id: number
|
||||
repoId: number
|
||||
@@ -39,6 +45,7 @@ export interface PullRequest {
|
||||
status: PRStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
reviewers: PRReviewer[]
|
||||
}
|
||||
|
||||
export interface TreeEntry {
|
||||
|
||||
Reference in New Issue
Block a user