From 03109866443c82b6f96eeac013460427b0ffcbff Mon Sep 17 00:00:00 2001 From: erangel1 Date: Thu, 7 May 2026 17:07:16 +0200 Subject: [PATCH] Backend (prs.go): MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 15 + .repos/1/test-repo.git/logs/refs/heads/main | 3 + .repos/1/test-repo.git/refs/heads/main | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/queries/prs.ts | 60 ++- frontend/src/pages/CreatePRPage.tsx | 178 ++++++++ frontend/src/pages/PRDetailPage.tsx | 479 +++++++++++++++++--- frontend/src/pages/PRsPage.tsx | 161 ++++++- frontend/src/types/api.ts | 7 + internal/api/handlers/prs.go | 65 ++- internal/api/router.go | 2 + 11 files changed, 896 insertions(+), 78 deletions(-) create mode 100644 .gitignore create mode 100644 frontend/src/pages/CreatePRPage.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f584932 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Runtime instance data +data +repos +.repos + +# Generated content +logs +tmp +cache + +# User uploads +uploads + +# Database +*.db \ No newline at end of file diff --git a/.repos/1/test-repo.git/logs/refs/heads/main b/.repos/1/test-repo.git/logs/refs/heads/main index 7c70a9d..9ea85e1 100644 --- a/.repos/1/test-repo.git/logs/refs/heads/main +++ b/.repos/1/test-repo.git/logs/refs/heads/main @@ -1 +1,4 @@ 0967e6aea1334a0e48b72ff9347c9d474eaa7aac 5ea23c4e5fe28f150891cf6b73119bb9b13aba92 erangel1 1778161109 +0200 commit: Update README.md +5ea23c4e5fe28f150891cf6b73119bb9b13aba92 be8d7d3467a3b1b6c31acdd01aa7c03e2b84aa5a erangel1 1778164837 +0200 push +be8d7d3467a3b1b6c31acdd01aa7c03e2b84aa5a 3684bc48e2daf7a8de9896a1065be7d32e17732c erangel1 1778165267 +0200 push +3684bc48e2daf7a8de9896a1065be7d32e17732c 77c5c76e8ea828995772452abf1ff2444d361cc7 erangel1 1778165724 +0200 push diff --git a/.repos/1/test-repo.git/refs/heads/main b/.repos/1/test-repo.git/refs/heads/main index ac192ed..65d2006 100644 --- a/.repos/1/test-repo.git/refs/heads/main +++ b/.repos/1/test-repo.git/refs/heads/main @@ -1 +1 @@ -5ea23c4e5fe28f150891cf6b73119bb9b13aba92 +77c5c76e8ea828995772452abf1ff2444d361cc7 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42deec2..c16632b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/queries/prs.ts b/frontend/src/api/queries/prs.ts index b8b566a..3d90f45 100644 --- a/frontend/src/api/queries/prs.ts +++ b/frontend/src/api/queries/prs.ts @@ -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(`/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(`/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(`/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(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/close`, prSchema, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) }, }) } diff --git a/frontend/src/pages/CreatePRPage.tsx b/frontend/src/pages/CreatePRPage.tsx new file mode 100644 index 0000000..bdf8f7d --- /dev/null +++ b/frontend/src/pages/CreatePRPage.tsx @@ -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 ( +
+ + + + +
+ ) + } + + const branchOptions = branches.map(b => b.name) + const isEmpty = repoData?.isEmpty ?? false + + return ( +
+ {/* Breadcrumb */} +
+ {repoName} + / + Pull requests + / + New +
+ +
+

Open a pull request

+

Propose changes by selecting branches to merge.

+
+ + {isEmpty ? ( +
+

Repository is empty

+

Push at least one commit before opening a pull request.

+
+ ) : branchOptions.length < 2 ? ( +
+

Not enough branches

+

You need at least two branches to open a pull request.

+ + View branches → + +
+ ) : ( +
+ {/* Branch selectors */} +
+
+ + +
+ +
+ + + +
+ +
+ + +
+
+ + {/* Title */} +
+ + 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)]" + /> +
+ + {/* Body */} +
+ +