From fc89f4a797a5167df01a7b8118ce3c7365e681a6 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:04:29 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs(features):=20repo=20feature=20status?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md index e56fa60d..ce4288e2 100644 --- a/frontend/src/features/CLAUDE.md +++ b/frontend/src/features/CLAUDE.md @@ -78,7 +78,7 @@ features/{X} → pages/*, app/* ✗ |---------|------|---------| | `auth` | GitHub OAuth, 토큰 관리, 로그아웃, 동의 | US-01, US-02, US-03, US-04 | | `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 | -| `repo` (계획) | GitHub 레포 가져오기/등록/삭제 | US-07, US-08 | +| `repo` | GitHub 레포 가져오기/등록/삭제 | US-07, US-08 | | `analysis` (계획) | 분석 상태 표시, 재분석 | US-11, US-12 | | `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 | | `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 | From 75cac020840d433964a380d5881fb713ef05ebb0 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:05:56 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(repo):=20Repository=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/model/types.ts | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 frontend/src/features/repo/model/types.ts diff --git a/frontend/src/features/repo/model/types.ts b/frontend/src/features/repo/model/types.ts new file mode 100644 index 00000000..8b73811f --- /dev/null +++ b/frontend/src/features/repo/model/types.ts @@ -0,0 +1,46 @@ +//중복되는 타입일 수 있으나 정의하는 상태가 달라서 일단 분리 X +export type RepositoryStatus = + | 'PENDING' + | 'ANALYZING' + | 'ANALYZED' + | 'FAILED' + +export type CandidateRepositoryResponse = { + githubRepoId: number + name: string + fullName: string + htmlUrl: string + defaultBranch: string + private: boolean + description: string | null + alreadyRegistered: boolean +} + +export type CandidateRepositoryPageResponse = { + content: CandidateRepositoryResponse[] + page: number + perPage: number + hasNext: boolean +} + +export type RegisteredRepositoryResponse = { + id: number + githubRepoId: number + repoName: string + repoFullName: string + repoUrl: string + defaultBranch: string + status: RepositoryStatus + lastSyncedAt: string | null + createdAt: string +} + +export type PageResponse = { + content: T[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean +} From acd53741ba8d7307feda59dfa757c05debb9b045 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:06:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(repo):=20repo=20API=20client=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/api/repo.ts | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/features/repo/api/repo.ts diff --git a/frontend/src/features/repo/api/repo.ts b/frontend/src/features/repo/api/repo.ts new file mode 100644 index 00000000..91183aa7 --- /dev/null +++ b/frontend/src/features/repo/api/repo.ts @@ -0,0 +1,48 @@ +import { apiClient } from '@/shared/api' +import type { + CandidateRepositoryPageResponse, + PageResponse, + RegisteredRepositoryResponse, +} from '@/features/repo/model/types' + +export async function listCandidates( + page = 1, + perPage = 30, +): Promise { + const { data } = await apiClient.get( + '/api/repositories/github', { params: { page, perPage } }, + ) + return data +} + +export async function registerRepo( + githubRepoId: number, +): Promise { + const { data } = await apiClient.post( + '/api/repositories', { githubRepoId }, + ) + return data +} + +export async function listRegistered( + page = 0, + size = 20, +): Promise> { + const { data } = await apiClient.get>( + '/api/repositories', { params: { page, size, sort: 'createdAt,desc' } }, + ) + return data +} + +export async function getRepo( + id: number, +): Promise { + const { data } = await apiClient.get( + `/api/repositories/${id}`, + ) + return data +} + +export async function deleteRepo(id: number): Promise { + await apiClient.delete(`/api/repositories/${id}`) +} \ No newline at end of file From dc6ea2401d42869ab6c665d3e0a3f1a25c56c248 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:07:07 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(repo):=20useRepos=20=ED=9B=85=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/model/useRepos.ts | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 frontend/src/features/repo/model/useRepos.ts diff --git a/frontend/src/features/repo/model/useRepos.ts b/frontend/src/features/repo/model/useRepos.ts new file mode 100644 index 00000000..9c82436b --- /dev/null +++ b/frontend/src/features/repo/model/useRepos.ts @@ -0,0 +1,77 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + deleteRepo, + getRepo, + listCandidates, + listRegistered, + registerRepo, +} from '@/features/repo/api/repo' +import type { + PageResponse, + RegisteredRepositoryResponse, +} from '@/features/repo/model/types' + +const REGISTERED_KEY = 'repositories' as const +const CANDIDATES_KEY = 'repositories-candidates' as const +const POLL_INTERVAL_MS = 2_000 + +function hasInProgress( + page: PageResponse | undefined, +): boolean { + if (!page) return false + return page.content.some( + (r) => r.status === 'PENDING' || r.status === 'ANALYZING', + ) +} + +export function useRegisteredReposQuery(page = 0, size = 20) { + return useQuery({ + queryKey: [REGISTERED_KEY, page, size], + queryFn: () => listRegistered(page, size), + refetchInterval: (query) => + hasInProgress(query.state.data) ? POLL_INTERVAL_MS : false, + }) +} + +export function useRegisteredRepoQuery(id: number | null) { + return useQuery({ + queryKey: [REGISTERED_KEY, 'detail', id], + queryFn: () => getRepo(id as number), + enabled: id !== null, + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'PENDING' || status === 'ANALYZING' + ? POLL_INTERVAL_MS + : false + }, + }) +} + +export function useCandidateReposQuery(page = 1, perPage = 30) { + return useQuery({ + queryKey: [CANDIDATES_KEY, page, perPage], + queryFn: () => listCandidates(page, perPage), + }) +} + +export function useRegisterRepoMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (githubRepoId: number) => registerRepo(githubRepoId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [REGISTERED_KEY] }) + qc.invalidateQueries({ queryKey: [CANDIDATES_KEY] }) + }, + }) +} + +export function useDeleteRepoMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deleteRepo(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [REGISTERED_KEY] }) + qc.invalidateQueries({ queryKey: [CANDIDATES_KEY] }) + }, + }) +} From af87035fc4afbc49fd75c313a1ea6edb6b0800c5 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:07:46 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(repo):=20CandidateRepositoryList=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repo/ui/CandidateRepositoryList.tsx | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 frontend/src/features/repo/ui/CandidateRepositoryList.tsx diff --git a/frontend/src/features/repo/ui/CandidateRepositoryList.tsx b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx new file mode 100644 index 00000000..bc7f657d --- /dev/null +++ b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react' +import { isApiError } from '@/shared/api' +import { + useCandidateReposQuery, + useRegisterRepoMutation, +} from '@/features/repo/model/useRepos' +import type { CandidateRepositoryResponse } from '@/features/repo/model/types' + +export function CandidateRepositoryList() { + const { data, isLoading, isError, error, refetch } = useCandidateReposQuery() + const [errorMessage, setErrorMessage] = useState(null) + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : 'GitHub 레포 후보를 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const candidates = data?.content ?? [] + if (candidates.length === 0) { + return ( +
+

+ 가져올 수 있는 GitHub 레포가 없습니다. +

+
+ ) + } + + return ( +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
    + {candidates.map((candidate) => ( +
  • + setErrorMessage(null)} + /> +
  • + ))} +
+
+ ) +} + +function CandidateCard({ + candidate, + onError, + onClearError, +}: { + candidate: CandidateRepositoryResponse + onError: (message: string) => void + onClearError: () => void +}) { + const register = useRegisterRepoMutation() + const disabled = + candidate.alreadyRegistered || candidate.private || register.isPending + + const handleClick = () => { + onClearError() + register.mutate(candidate.githubRepoId, { + onError: (err) => { + onError( + isApiError(err) ? err.message : '등록 중 오류가 발생했습니다.', + ) + }, + }) + } + + return ( +
+
+
+ + {candidate.fullName} + + {candidate.private ? ( + + Private + + ) : null} + {candidate.alreadyRegistered ? ( + + 이미 등록됨 + + ) : null} +
+

+ {candidate.description ?? '설명 없음'} +

+

+ 기본 브랜치 {candidate.defaultBranch} +

+
+ +
+ ) +} + +function ListSkeleton() { + return ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • +
    +
    +
  • + ))} +
+ ) +} From 46d5fa76ad45f0198e68436d45016a9a27b19a0f Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:08:11 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(repo):=20RegisteredRepositoryList=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repo/ui/RegisteredRepositoryList.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 frontend/src/features/repo/ui/RegisteredRepositoryList.tsx diff --git a/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx b/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx new file mode 100644 index 00000000..410394c8 --- /dev/null +++ b/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx @@ -0,0 +1,211 @@ +import { isApiError } from '@/shared/api' +import { + useDeleteRepoMutation, + useRegisteredReposQuery, +} from '@/features/repo/model/useRepos' +import type { + RegisteredRepositoryResponse, + RepositoryStatus, +} from '@/features/repo/model/types' + +type Props = { + onBrowseCandidates: () => void +} + +export function RegisteredRepositoryList({ onBrowseCandidates }: Props) { + const { data, isLoading, isError, error, refetch } = useRegisteredReposQuery() + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : '레포지토리 목록을 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const repos = data?.content ?? [] + if (repos.length === 0) { + return ( +
+

+ 아직 등록된 레포지토리가 없습니다. +

+

+ GitHub에서 분석할 레포를 가져와 등록해보세요. +

+ +
+ ) + } + + return ( +
    + {repos.map((repo) => ( +
  • + +
  • + ))} +
+ ) +} + +function RegisteredRepoCard({ repo }: { repo: RegisteredRepositoryResponse }) { + const remove = useDeleteRepoMutation() + const handleDelete = () => { + const ok = window.confirm( + `'${repo.repoFullName}' 레포지토리 등록을 해제하시겠습니까?`, + ) + if (!ok) return + remove.mutate(repo.id) + } + return ( +
+ +
+ + {repo.repoFullName} + +

+ 기본 브랜치 {repo.defaultBranch} · {syncLabel(repo)} +

+
+ + +
+ ) +} + +function ListSkeleton() { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} + +const STATUS_LABEL: Record = { + PENDING: '분석 대기', + ANALYZING: '분석 중', + ANALYZED: '분석 완료', + FAILED: '분석 실패', +} + +const STATUS_CLASS: Record = { + PENDING: 'bg-info-50 text-info-700', + ANALYZING: 'bg-info-50 text-info-700', + ANALYZED: 'bg-success-50 text-success-700', + FAILED: 'bg-danger-50 text-danger-700', +} + +function StatusBadge({ status }: { status: RepositoryStatus }) { + const inProgress = status === 'PENDING' || status === 'ANALYZING' + return ( + + {inProgress ? : null} + {STATUS_LABEL[status]} + + ) +} + +function Spinner() { + return ( + + + + + ) +} + +function RepoIcon() { + return ( +
+ + + +
+ ) +} + +function syncLabel(repo: RegisteredRepositoryResponse): string { + if (repo.lastSyncedAt) return `최근 동기화 ${relativeTime(repo.lastSyncedAt)}` + return `등록 ${relativeTime(repo.createdAt)}` +} + +function relativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime() + if (diffMs < 0) return '방금 전' + const sec = diffMs / 1000 + if (sec < 60) return '방금 전' + const min = sec / 60 + if (min < 60) return `${Math.floor(min)}분 전` + const hr = min / 60 + if (hr < 24) return `${Math.floor(hr)}시간 전` + const day = hr / 24 + if (day < 7) return `${Math.floor(day)}일 전` + return new Date(iso).toLocaleDateString('ko-KR') +} From 64e8ac9639632bfef1bb4f239d6008b29d6f2f16 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:08:32 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(repo):=20RepositoryPanel=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/repo/ui/RepositoryPanel.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 frontend/src/features/repo/ui/RepositoryPanel.tsx diff --git a/frontend/src/features/repo/ui/RepositoryPanel.tsx b/frontend/src/features/repo/ui/RepositoryPanel.tsx new file mode 100644 index 00000000..7ed381c1 --- /dev/null +++ b/frontend/src/features/repo/ui/RepositoryPanel.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react' +import { useRegisteredReposQuery } from '@/features/repo/model/useRepos' +import { CandidateRepositoryList } from './CandidateRepositoryList' +import { RegisteredRepositoryList } from './RegisteredRepositoryList' + +type Tab = 'registered' | 'candidates' + +export function RepositoryPanel() { + const [tab, setTab] = useState('registered') + const { data } = useRegisteredReposQuery() + const registeredCount = data?.totalElements ?? 0 + + return ( +
+
+ setTab('registered')} + controls="repo-panel-registered" + > + 등록됨{registeredCount > 0 ? ` (${registeredCount})` : ''} + + setTab('candidates')} + controls="repo-panel-candidates" + > + GitHub에서 가져오기 + +
+ + {tab === 'registered' ? ( +
+ setTab('candidates')} + /> +
+ ) : ( +
+ +
+ )} +
+ ) +} + +function TabButton({ + active, + onClick, + controls, + children, +}: { + active: boolean + onClick: () => void + controls: string + children: React.ReactNode +}) { + return ( + + ) +} From 452e24bbcf351f8f9b4928c42a0fd44872e23c8e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:08:51 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat(repo):=20repo=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 frontend/src/features/repo/index.ts diff --git a/frontend/src/features/repo/index.ts b/frontend/src/features/repo/index.ts new file mode 100644 index 00000000..0001c494 --- /dev/null +++ b/frontend/src/features/repo/index.ts @@ -0,0 +1,8 @@ +export { RepositoryPanel } from './ui/RepositoryPanel' +export type { + CandidateRepositoryPageResponse, + CandidateRepositoryResponse, + PageResponse, + RegisteredRepositoryResponse, + RepositoryStatus, +} from './model/types' From 3ed5ed7cc41290f2484e1a96b6e11e5f8da9e8f1 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 23:09:10 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(workspace):=20RepositoryPanel=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Workspace/ui/WorkspacePage.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 3bbc74e8..931f9695 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,3 +1,4 @@ +import { RepositoryPanel } from '@/features/repo' import { ResumeList, UploadResumeButton } from '@/features/resume' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' @@ -24,21 +25,10 @@ export default function WorkspacePage() { title="내 GitHub 레포지토리" description="등록한 레포를 기반으로 코드 맥락에 맞는 질문이 생성됩니다." > - + ) } - -function EmptyRepoSlot() { - return ( -
-

아직 등록된 레포지토리가 없습니다.

-

- 다음 단계에서 GitHub 레포 가져오기가 가능해집니다. -

-
- ) -} From 95c247e4ca16ba3748d59d0f15c97fa3ac56c56e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 20 May 2026 01:36:43 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(repo):=20private=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=20=EB=B6=88=EA=B0=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/ui/CandidateRepositoryList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/repo/ui/CandidateRepositoryList.tsx b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx index bc7f657d..af879b62 100644 --- a/frontend/src/features/repo/ui/CandidateRepositoryList.tsx +++ b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx @@ -74,8 +74,7 @@ function CandidateCard({ onClearError: () => void }) { const register = useRegisterRepoMutation() - const disabled = - candidate.alreadyRegistered || candidate.private || register.isPending + const disabled = candidate.alreadyRegistered || register.isPending const handleClick = () => { onClearError()