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:
2026-05-07 17:07:16 +02:00
parent 7436679eac
commit 0310986644
11 changed files with 896 additions and 78 deletions
+15
View File
@@ -0,0 +1,15 @@
# Runtime instance data
data
repos
.repos
# Generated content
logs
tmp
cache
# User uploads
uploads
# Database
*.db
@@ -1 +1,4 @@
0967e6aea1334a0e48b72ff9347c9d474eaa7aac 5ea23c4e5fe28f150891cf6b73119bb9b13aba92 erangel1 <admin@asgardlabs.net> 1778161109 +0200 commit: Update README.md 0967e6aea1334a0e48b72ff9347c9d474eaa7aac 5ea23c4e5fe28f150891cf6b73119bb9b13aba92 erangel1 <admin@asgardlabs.net> 1778161109 +0200 commit: Update README.md
5ea23c4e5fe28f150891cf6b73119bb9b13aba92 be8d7d3467a3b1b6c31acdd01aa7c03e2b84aa5a erangel1 <erangel1@http.[::1]:53405> 1778164837 +0200 push
be8d7d3467a3b1b6c31acdd01aa7c03e2b84aa5a 3684bc48e2daf7a8de9896a1065be7d32e17732c erangel1 <erangel1@http.[::1]:53557> 1778165267 +0200 push
3684bc48e2daf7a8de9896a1065be7d32e17732c 77c5c76e8ea828995772452abf1ff2444d361cc7 erangel1 <erangel1@http.[::1]:53746> 1778165724 +0200 push
+1 -1
View File
@@ -1 +1 @@
5ea23c4e5fe28f150891cf6b73119bb9b13aba92 77c5c76e8ea828995772452abf1ff2444d361cc7
+2
View File
@@ -28,6 +28,7 @@ const BlobPage = lazy(() => import('./pages/BlobPage'))
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage')) const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage')) const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage')) const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const CreatePRPage = lazy(() => import('./pages/CreatePRPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage')) const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const CommitsPage = lazy(() => import('./pages/CommitsPage')) const CommitsPage = lazy(() => import('./pages/CommitsPage'))
const BranchesPage = lazy(() => import('./pages/BranchesPage')) 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/settings" element={<S><RepoSettingsPage /></S>} />
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></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" 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="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} /> <Route path="starred" element={<S><StarredPage /></S>} />
+58 -2
View File
@@ -3,6 +3,12 @@ import { z } from 'zod'
import { api } from '../client' import { api } from '../client'
import type { PullRequest } from '../../types/api' import type { PullRequest } from '../../types/api'
const prReviewerSchema = z.object({
userId: z.number(),
username: z.string(),
avatarUrl: z.string(),
})
const prSchema = z.object({ const prSchema = z.object({
id: z.number(), id: z.number(),
repoId: z.number(), repoId: z.number(),
@@ -14,6 +20,7 @@ const prSchema = z.object({
status: z.enum(['open', 'merged', 'closed']), status: z.enum(['open', 'merged', 'closed']),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
reviewers: z.array(prReviewerSchema).default([]),
}) })
const prsSchema = z.array(prSchema) 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() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (prId: number) => 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] }) 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'] })
}, },
}) })
} }
+178
View File
@@ -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>
)
}
+420 -53
View File
@@ -1,44 +1,194 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link, useNavigate } from 'react-router-dom'
import { usePR } from '../api/queries/prs' import { usePR, useMergePR, useClosePR, useReopenPR, useUpdatePR } from '../api/queries/prs'
import { useRepoDiff } from '../api/queries/repos' import { useRepoDiff } from '../api/queries/repos'
import { useMergeStrategies } from '../api/queries/workflow'
import { useAuth } from '../contexts/AuthContext'
import { DiffViewer } from '../components/diff/DiffViewer' import { DiffViewer } from '../components/diff/DiffViewer'
import { MobileComment } from '../components/diff/MobileComment'
import { Skeleton } from '../ui/Skeleton' import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils' 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() { export default function PRDetailPage() {
const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>() const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>()
const { data: pr, isLoading, isError } = usePR(owner, repo, parseInt(prId, 10)) const navigate = useNavigate()
const { data: diffs } = useRepoDiff( const { user } = useAuth()
owner, repo, const numId = parseInt(prId, 10)
pr?.targetBranch ?? '',
pr?.sourceBranch ?? '', const { data: pr, isLoading, isError } = usePR(owner, repo, numId)
) const { data: strategies } = useMergeStrategies(owner, repo)
const [comment, setComment] = useState<{ file: string; line: number } | null>(null) 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) { if (isLoading) {
return ( return (
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4"> <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 rounded" />
<Skeleton className="h-4 w-48" /> <Skeleton className="h-7 w-3/4 rounded" />
<Skeleton className="h-40 w-full" /> <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> </div>
) )
} }
if (isError || !pr) { 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' const isAuthor = user?.id === pr.authorId
? 'bg-[#E3FCEF] text-[#006644] border-[#79F2C0]' const canEdit = isAuthor || user?.isAdmin
: pr.status === 'merged' const isOpen = pr.status === 'open'
? 'bg-[#EAE6FF] text-[#403294] border-[#C0B6F2]' const isMerged = pr.status === 'merged'
: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)] border-[var(--c-border)]' 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 ( 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 */} {/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap"> <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> <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> <span className="text-[var(--c-text)]">#{pr.id}</span>
</div> </div>
{/* Title + status */} {/* Title row */}
<div> <div>
<div className="flex items-center gap-3 flex-wrap"> {editingTitle ? (
<h1 className="text-xl font-semibold text-[var(--c-text)]">{pr.title}</h1> <div className="flex items-center gap-2">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', statusColor)}> <input
{pr.status} value={titleDraft}
</span> 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>
<p className="text-xs text-[var(--c-muted)] mt-1"> ) : (
#{pr.id} · <span className="font-mono">{pr.sourceBranch}</span> <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>
<span className="font-mono">{pr.targetBranch}</span> <div className="flex items-center gap-2 shrink-0">
</p> <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> </div>
{/* Body */} {/* Main 2-col layout */}
{pr.body && ( <div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 items-start">
<div className="p-4 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] whitespace-pre-wrap">
{pr.body} {/* ── 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> </div>
)} )}
{/* Diff placeholder */} {/* Files changed */}
<div> <div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<h2 className="text-sm font-semibold text-[var(--c-text)] mb-3 flex items-center gap-2"> <div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"> <span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">
<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 Files changed
</h2> {diffs.length > 0 && (
<span className="ml-1.5 text-[var(--c-text)]">({diffs.length})</span>
<DiffViewer files={diffs ?? []} /> )}
</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> </div>
{/* Mobile comment sheet */} {/* ── Right sidebar ── */}
<MobileComment <div className="space-y-4">
open={!!comment}
onClose={() => setComment(null)} {/* Merge / close / reopen actions */}
filePath={comment?.file ?? ''} {isOpen && (
lineNumber={comment?.line ?? 0} <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>
</div> </div>
) )
} }
+143 -18
View File
@@ -1,38 +1,163 @@
import { useState } from 'react' 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 { 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() { 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 ( return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-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 mb-6"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-[var(--c-text)]">Pull Requests</h1> <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>
<div className="flex gap-1 mb-4 border-b border-[var(--c-border)]"> {/* Tabs */}
{(['open', 'merged', 'closed'] as PRStatus[]).map(s => ( <div className="flex gap-0 border-b border-[var(--c-border)]">
<button {tabs.map(t => (
key={s} <button key={t.id} onClick={() => setTab(t.id)}
onClick={() => setStatus(s)}
className={cn( className={cn(
'px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px transition-colors min-h-[44px]', 'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
status === s tab === t.id
? 'border-[var(--c-brand)] text-[var(--c-brand)]' ? 'border-[var(--c-brand)] text-[var(--c-brand)]'
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]', : '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> </button>
))} ))}
</div> </div>
<p className="text-sm text-[var(--c-muted)] py-6 text-center"> {/* List */}
Navigate to a repository to view its pull requests. {isLoading ? (
</p> <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> </div>
) )
} }
+7
View File
@@ -28,6 +28,12 @@ export interface Repository {
export type PRStatus = 'open' | 'merged' | 'closed' export type PRStatus = 'open' | 'merged' | 'closed'
export interface PRReviewer {
userId: number
username: string
avatarUrl: string
}
export interface PullRequest { export interface PullRequest {
id: number id: number
repoId: number repoId: number
@@ -39,6 +45,7 @@ export interface PullRequest {
status: PRStatus status: PRStatus
createdAt: string createdAt: string
updatedAt: string updatedAt: string
reviewers: PRReviewer[]
} }
export interface TreeEntry { export interface TreeEntry {
+64 -1
View File
@@ -174,7 +174,70 @@ func (h *PRHandler) Close(w http.ResponseWriter, r *http.Request) {
jsonError(w, "could not close pull request", http.StatusInternalServerError) jsonError(w, "could not close pull request", http.StatusInternalServerError)
return return
} }
jsonOK(w, pr) go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
"action": "closed",
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
})
jsonOK(w, prWithReviewers(h.db, pr))
}
func (h *PRHandler) Reopen(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
if pr.Status != models.PRStatusClosed {
jsonError(w, "pull request is not closed", http.StatusConflict)
return
}
pr.Status = models.PRStatusOpen
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not reopen pull request", http.StatusInternalServerError)
return
}
go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
"action": "reopened",
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
})
jsonOK(w, prWithReviewers(h.db, pr))
}
func (h *PRHandler) Update(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
var body struct {
Title *string `json:"title"`
Body *string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
var cols []string
if body.Title != nil {
if *body.Title == "" {
jsonError(w, "title cannot be empty", http.StatusBadRequest)
return
}
pr.Title = *body.Title
cols = append(cols, "title")
}
if body.Body != nil {
pr.Body = *body.Body
cols = append(cols, "body")
}
if len(cols) > 0 {
if _, err := h.db.ID(pr.ID).Cols(cols...).Update(pr); err != nil {
jsonError(w, "could not update pull request", http.StatusInternalServerError)
return
}
}
jsonOK(w, prWithReviewers(h.db, pr))
} }
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
+2
View File
@@ -133,8 +133,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.Get("/", prH.List) r.Get("/", prH.List)
r.With(csrf).Post("/", prH.Create) r.With(csrf).Post("/", prH.Create)
r.Get("/{prID}", prH.Get) r.Get("/{prID}", prH.Get)
r.With(csrf).Patch("/{prID}", prH.Update)
r.With(csrf).Post("/{prID}/merge", prH.Merge) r.With(csrf).Post("/{prID}/merge", prH.Merge)
r.With(csrf).Post("/{prID}/close", prH.Close) r.With(csrf).Post("/{prID}/close", prH.Close)
r.With(csrf).Post("/{prID}/reopen", prH.Reopen)
}) })
r.Route("/issues", func(r chi.Router) { r.Route("/issues", func(r chi.Router) {
r.Get("/", issueH.List) r.Get("/", issueH.List)