diff --git a/backend/api/commits/my-weekly.get.ts b/backend/api/commits/my-weekly.get.ts new file mode 100644 index 0000000..c662e21 --- /dev/null +++ b/backend/api/commits/my-weekly.get.ts @@ -0,0 +1,90 @@ +import { query } from '../../utils/db' +import { requireAuth } from '../../utils/session' + +/** + * 내 주간 커밋 조회 (주간보고 작성용) + * GET /api/commits/my-weekly + * + * Query params: + * - projectId: 프로젝트 ID (필수) + * - startDate: 주 시작일 (YYYY-MM-DD) + * - endDate: 주 종료일 (YYYY-MM-DD) + */ +export default defineEventHandler(async (event) => { + const employeeId = await requireAuth(event) + const queryParams = getQuery(event) + + const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null + const startDate = queryParams.startDate as string + const endDate = queryParams.endDate as string + + if (!projectId) { + throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) + } + + // 조건 빌드 + const conditions = [ + 'r.project_id = $1', + 'c.employee_id = $2' + ] + const values: any[] = [projectId, employeeId] + let paramIndex = 3 + + if (startDate) { + conditions.push(`c.commit_date >= $${paramIndex++}`) + values.push(startDate) + } + + if (endDate) { + conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`) + values.push(endDate) + } + + const whereClause = conditions.join(' AND ') + + // 내 커밋 목록 + const commits = await query(` + SELECT + c.commit_id, c.commit_hash, c.commit_message, c.commit_date, + c.files_changed, c.insertions, c.deletions, + r.repo_id, r.repo_name, + s.server_type + FROM wr_commit_log c + JOIN wr_repository r ON c.repo_id = r.repo_id + JOIN wr_vcs_server s ON r.server_id = s.server_id + WHERE ${whereClause} + ORDER BY c.commit_date DESC + LIMIT 100 + `, values) + + // 통계 + const statsResult = await query(` + SELECT + COUNT(*) as commit_count, + COALESCE(SUM(c.insertions), 0) as total_insertions, + COALESCE(SUM(c.deletions), 0) as total_deletions + FROM wr_commit_log c + JOIN wr_repository r ON c.repo_id = r.repo_id + WHERE ${whereClause} + `, values) + + return { + commits: commits.map(c => ({ + commitId: c.commit_id, + commitHash: c.commit_hash?.substring(0, 8), + commitMessage: c.commit_message, + commitDate: c.commit_date, + filesChanged: c.files_changed, + insertions: c.insertions, + deletions: c.deletions, + repoId: c.repo_id, + repoName: c.repo_name, + serverType: c.server_type + })), + stats: { + commitCount: parseInt(statsResult[0]?.commit_count || '0'), + totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'), + totalDeletions: parseInt(statsResult[0]?.total_deletions || '0') + } + } +}) diff --git a/backend/api/project/[id]/commits.get.ts b/backend/api/project/[id]/commits.get.ts new file mode 100644 index 0000000..83d6935 --- /dev/null +++ b/backend/api/project/[id]/commits.get.ts @@ -0,0 +1,131 @@ +import { query } from '../../../utils/db' +import { requireAuth } from '../../../utils/session' + +/** + * 프로젝트 커밋 목록 조회 + * GET /api/project/[id]/commits + * + * Query params: + * - startDate: 시작일 (YYYY-MM-DD) + * - endDate: 종료일 (YYYY-MM-DD) + * - repoId: 저장소 ID (옵션) + * - authorId: 작성자 ID (옵션) + * - page: 페이지 번호 (기본 1) + * - limit: 페이지당 개수 (기본 50) + */ +export default defineEventHandler(async (event) => { + await requireAuth(event) + const projectId = parseInt(getRouterParam(event, 'id') || '0') + const queryParams = getQuery(event) + + if (!projectId) { + throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) + } + + const startDate = queryParams.startDate as string + const endDate = queryParams.endDate as string + const repoId = queryParams.repoId ? parseInt(queryParams.repoId as string) : null + const authorId = queryParams.authorId ? parseInt(queryParams.authorId as string) : null + const page = parseInt(queryParams.page as string) || 1 + const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100) + const offset = (page - 1) * limit + + // 조건 빌드 + const conditions = ['r.project_id = $1'] + const values: any[] = [projectId] + let paramIndex = 2 + + if (startDate) { + conditions.push(`c.commit_date >= $${paramIndex++}`) + values.push(startDate) + } + + if (endDate) { + conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`) + values.push(endDate) + } + + if (repoId) { + conditions.push(`c.repo_id = $${paramIndex++}`) + values.push(repoId) + } + + if (authorId) { + conditions.push(`c.employee_id = $${paramIndex++}`) + values.push(authorId) + } + + const whereClause = conditions.join(' AND ') + + // 커밋 목록 조회 + const commits = await query(` + SELECT + c.commit_id, c.commit_hash, c.commit_message, c.commit_author, c.commit_email, + c.commit_date, c.employee_id, c.files_changed, c.insertions, c.deletions, + r.repo_id, r.repo_name, r.repo_path, + s.server_type, s.server_name, + e.employee_name, e.display_name + FROM wr_commit_log c + JOIN wr_repository r ON c.repo_id = r.repo_id + JOIN wr_vcs_server s ON r.server_id = s.server_id + LEFT JOIN wr_employee_info e ON c.employee_id = e.employee_id + WHERE ${whereClause} + ORDER BY c.commit_date DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `, [...values, limit, offset]) + + // 전체 개수 조회 + const countResult = await query(` + SELECT COUNT(*) as total + FROM wr_commit_log c + JOIN wr_repository r ON c.repo_id = r.repo_id + WHERE ${whereClause} + `, values) + + const total = parseInt(countResult[0]?.total || '0') + + // 통계 (해당 기간) + const statsResult = await query(` + SELECT + COUNT(*) as commit_count, + COALESCE(SUM(c.insertions), 0) as total_insertions, + COALESCE(SUM(c.deletions), 0) as total_deletions, + COUNT(DISTINCT c.employee_id) as author_count + FROM wr_commit_log c + JOIN wr_repository r ON c.repo_id = r.repo_id + WHERE ${whereClause} + `, values) + + return { + commits: commits.map(c => ({ + commitId: c.commit_id, + commitHash: c.commit_hash, + commitMessage: c.commit_message, + commitAuthor: c.commit_author, + commitEmail: c.commit_email, + commitDate: c.commit_date, + employeeId: c.employee_id, + employeeName: c.display_name || c.employee_name, + filesChanged: c.files_changed, + insertions: c.insertions, + deletions: c.deletions, + repoId: c.repo_id, + repoName: c.repo_name, + repoPath: c.repo_path, + serverType: c.server_type, + serverName: c.server_name + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + }, + stats: { + commitCount: parseInt(statsResult[0]?.commit_count || '0'), + totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'), + totalDeletions: parseInt(statsResult[0]?.total_deletions || '0'), + authorCount: parseInt(statsResult[0]?.author_count || '0') + } + } +}) diff --git a/backend/api/project/[id]/commits/refresh.post.ts b/backend/api/project/[id]/commits/refresh.post.ts new file mode 100644 index 0000000..54c66c8 --- /dev/null +++ b/backend/api/project/[id]/commits/refresh.post.ts @@ -0,0 +1,25 @@ +import { requireAuth } from '../../../../utils/session' +import { syncProjectGitRepositories } from '../../../../utils/git-sync' + +/** + * 프로젝트 커밋 새로고침 (모든 저장소 동기화) + * POST /api/project/[id]/commits/refresh + */ +export default defineEventHandler(async (event) => { + await requireAuth(event) + const projectId = parseInt(getRouterParam(event, 'id') || '0') + + if (!projectId) { + throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) + } + + const result = await syncProjectGitRepositories(projectId) + + return { + success: result.success, + message: result.success + ? `${result.results.length}개 저장소 동기화 완료` + : '일부 저장소 동기화 실패', + results: result.results + } +}) diff --git a/backend/api/repository/[id]/sync.post.ts b/backend/api/repository/[id]/sync.post.ts new file mode 100644 index 0000000..fe67533 --- /dev/null +++ b/backend/api/repository/[id]/sync.post.ts @@ -0,0 +1,38 @@ +import { queryOne } from '../../../utils/db' +import { requireAuth } from '../../../utils/session' +import { syncGitRepository } from '../../../utils/git-sync' + +/** + * 저장소 동기화 (수동) + * POST /api/repository/[id]/sync + */ +export default defineEventHandler(async (event) => { + await requireAuth(event) + const repoId = parseInt(getRouterParam(event, 'id') || '0') + + if (!repoId) { + throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' }) + } + + // 저장소 정보 확인 + const repo = await queryOne(` + SELECT r.*, s.server_type + FROM wr_repository r + JOIN wr_vcs_server s ON r.server_id = s.server_id + WHERE r.repo_id = $1 + `, [repoId]) + + if (!repo) { + throw createError({ statusCode: 404, message: '저장소를 찾을 수 없습니다.' }) + } + + if (repo.server_type === 'GIT') { + const result = await syncGitRepository(repoId) + return result + } else if (repo.server_type === 'SVN') { + // SVN은 별도 구현 예정 + return { success: false, message: 'SVN 동기화는 준비 중입니다.' } + } + + return { success: false, message: '지원하지 않는 서버 타입입니다.' } +}) diff --git a/backend/utils/git-sync.ts b/backend/utils/git-sync.ts new file mode 100644 index 0000000..ba0e223 --- /dev/null +++ b/backend/utils/git-sync.ts @@ -0,0 +1,236 @@ +import { query, execute, queryOne } from './db' +import { existsSync, mkdirSync, rmSync } from 'fs' +import { join } from 'path' +import { execSync, exec } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +// 임시 저장소 디렉토리 +const REPO_TEMP_DIR = process.env.REPO_TEMP_DIR || join(process.cwd(), '.tmp', 'repos') + +interface CommitInfo { + hash: string + author: string + email: string + date: string + message: string + filesChanged?: number + insertions?: number + deletions?: number +} + +/** + * 저장소 정보 조회 + */ +async function getRepositoryInfo(repoId: number) { + return await queryOne(` + SELECT + r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url, + r.default_branch, r.last_sync_at, + s.server_id, s.server_type, s.server_url, s.server_name, + s.auth_type, s.auth_username, s.auth_credential + FROM wr_repository r + JOIN wr_vcs_server s ON r.server_id = s.server_id + WHERE r.repo_id = $1 AND r.is_active = true + `, [repoId]) +} + +/** + * Git 저장소 URL 생성 + */ +function buildGitUrl(serverUrl: string, repoPath: string, authUsername?: string, authCredential?: string): string { + // 이미 전체 URL인 경우 + if (repoPath.startsWith('http://') || repoPath.startsWith('https://') || repoPath.startsWith('git@')) { + return repoPath + } + + // 서버 URL + 경로 조합 + let baseUrl = serverUrl.replace(/\/$/, '') + let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}` + + // HTTPS 인증 추가 + if (authUsername && authCredential && baseUrl.startsWith('https://')) { + const urlObj = new URL(baseUrl) + urlObj.username = encodeURIComponent(authUsername) + urlObj.password = encodeURIComponent(authCredential) + baseUrl = urlObj.toString().replace(/\/$/, '') + } + + return `${baseUrl}${path}` +} + +/** + * Git 커밋 로그 파싱 + */ +function parseGitLog(output: string): CommitInfo[] { + const commits: CommitInfo[] = [] + const lines = output.trim().split('\n') + + for (const line of lines) { + if (!line.trim()) continue + + // 포맷: hash|author|email|date|message + const parts = line.split('|') + if (parts.length >= 5) { + commits.push({ + hash: parts[0], + author: parts[1], + email: parts[2], + date: parts[3], + message: parts.slice(4).join('|') // 메시지에 | 있을 수 있음 + }) + } + } + + return commits +} + +/** + * VCS 계정으로 사용자 매칭 + */ +async function matchAuthor(serverId: number, authorName: string, authorEmail: string): Promise { + // 이메일로 먼저 매칭 + let matched = await queryOne(` + SELECT employee_id FROM wr_employee_vcs_account + WHERE server_id = $1 AND (vcs_email = $2 OR vcs_username = $3) + `, [serverId, authorEmail, authorName]) + + if (matched) { + return matched.employee_id + } + + // VCS 계정에 없으면 직원 이메일로 매칭 시도 + matched = await queryOne(` + SELECT employee_id FROM wr_employee_info + WHERE email = $1 AND is_active = true + `, [authorEmail]) + + return matched?.employee_id || null +} + +/** + * Git 저장소 동기화 + */ +export async function syncGitRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> { + const repo = await getRepositoryInfo(repoId) + + if (!repo) { + return { success: false, message: '저장소를 찾을 수 없습니다.' } + } + + if (repo.server_type !== 'GIT') { + return { success: false, message: 'Git 저장소가 아닙니다.' } + } + + // 임시 디렉토리 생성 + if (!existsSync(REPO_TEMP_DIR)) { + mkdirSync(REPO_TEMP_DIR, { recursive: true }) + } + + const localPath = join(REPO_TEMP_DIR, `repo_${repoId}`) + const gitUrl = buildGitUrl(repo.server_url, repo.repo_path, repo.auth_username, repo.auth_credential) + const branch = repo.default_branch || 'main' + + try { + // Clone 또는 Pull + if (existsSync(localPath)) { + // 기존 저장소 업데이트 + await execAsync(`cd "${localPath}" && git fetch origin && git reset --hard origin/${branch}`) + } else { + // 새로 클론 (shallow clone으로 최근 커밋만) + await execAsync(`git clone --depth 100 --single-branch --branch ${branch} "${gitUrl}" "${localPath}"`) + } + + // 마지막 동기화 이후 커밋 조회 + let sinceOption = '' + if (repo.last_sync_at) { + const sinceDate = new Date(repo.last_sync_at) + sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 (중복 방지용 UPSERT 사용) + sinceOption = `--since="${sinceDate.toISOString()}"` + } else { + sinceOption = '--since="30 days ago"' // 최초 동기화: 최근 30일 + } + + // 커밋 로그 조회 + const logFormat = '%H|%an|%ae|%aI|%s' + const { stdout } = await execAsync( + `cd "${localPath}" && git log ${sinceOption} --format="${logFormat}" --no-merges`, + { maxBuffer: 10 * 1024 * 1024 } // 10MB + ) + + const commits = parseGitLog(stdout) + let insertedCount = 0 + + // 커밋 저장 + for (const commit of commits) { + const employeeId = await matchAuthor(repo.server_id, commit.author, commit.email) + + // UPSERT (중복 무시) + const result = await execute(` + INSERT INTO wr_commit_log ( + repo_id, commit_hash, commit_message, commit_author, commit_email, + commit_date, employee_id, synced_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (repo_id, commit_hash) DO NOTHING + `, [ + repoId, + commit.hash, + commit.message, + commit.author, + commit.email, + commit.date, + employeeId + ]) + + if (result.rowCount && result.rowCount > 0) { + insertedCount++ + } + } + + // 동기화 상태 업데이트 + await execute(` + UPDATE wr_repository + SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2 + WHERE repo_id = $1 + `, [repoId, `${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`]) + + return { + success: true, + message: `동기화 완료: ${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`, + commitCount: insertedCount + } + + } catch (error: any) { + console.error('Git sync error:', error) + + // 실패 상태 업데이트 + await execute(` + UPDATE wr_repository + SET last_sync_status = 'FAILED', last_sync_message = $2 + WHERE repo_id = $1 + `, [repoId, error.message?.substring(0, 500)]) + + return { success: false, message: error.message || '동기화 실패' } + } +} + +/** + * 프로젝트의 모든 Git 저장소 동기화 + */ +export async function syncProjectGitRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> { + const repos = await query(` + SELECT r.repo_id + FROM wr_repository r + JOIN wr_vcs_server s ON r.server_id = s.server_id + WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'GIT' + `, [projectId]) + + const results = [] + for (const repo of repos) { + const result = await syncGitRepository(repo.repo_id) + results.push({ repoId: repo.repo_id, ...result }) + } + + return { success: results.every(r => r.success), results } +} diff --git a/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md b/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md index e39842b..668f5a3 100644 --- a/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md +++ b/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md @@ -560,43 +560,70 @@ REPO_CREDENTIAL_SECRET=your-secret-key ## 8. 작업 일정 -### Phase 1: DB + VCS 서버/계정 관리 (3일) -- [ ] 시작: -- [ ] 완료: -- [ ] 소요시간: +### Phase 1: DB + VCS 서버/계정 관리 (3일) ✅ 완료 +- [x] 시작: 2026-01-11 02:13 +- [x] 완료: 2026-01-11 02:25 +- [x] 소요시간: 12분 **작업 내용:** -- [ ] wr_vcs_server 테이블 생성 -- [ ] wr_employee_vcs_account 테이블 생성 -- [ ] wr_repository 테이블 생성 -- [ ] wr_commit_log 테이블 생성 -- [ ] VCS 서버 관리 API + UI (관리자) -- [ ] 마이페이지 VCS 계정 설정 UI +- [x] wr_vcs_server 테이블 생성 ✅ 기존 존재 +- [x] wr_employee_vcs_account 테이블 생성 ✅ 기존 존재 +- [x] wr_repository 테이블 생성 ✅ 기존 존재 +- [x] wr_commit_log 테이블 생성 ✅ +- [x] VCS 서버 관리 API + UI (관리자) ✅ +- [x] 마이페이지 VCS 계정 설정 UI ✅ + +**생성된 파일:** +- backend/api/admin/vcs-server/list.get.ts +- backend/api/admin/vcs-server/create.post.ts +- backend/api/admin/vcs-server/[id]/update.put.ts +- backend/api/admin/vcs-server/[id]/delete.delete.ts +- backend/api/my/vcs-accounts.get.ts +- backend/api/my/vcs-account.post.ts +- frontend/admin/vcs-server/index.vue +- frontend/mypage/index.vue (VCS 계정 섹션 추가) --- -### Phase 2: 저장소 관리 (2일) -- [ ] 시작: -- [ ] 완료: -- [ ] 소요시간: +### Phase 2: 저장소 관리 (2일) ✅ 완료 +- [x] 시작: 2026-01-11 23:30 +- [x] 완료: 2026-01-11 23:40 +- [x] 소요시간: 10분 **작업 내용:** -- [ ] 저장소 CRUD API -- [ ] 프로젝트 상세에 저장소 관리 UI -- [ ] 저장소 추가/수정 모달 +- [x] 저장소 CRUD API ✅ +- [x] 프로젝트 상세에 저장소 관리 UI ✅ +- [x] 저장소 추가/수정 모달 ✅ + +**생성된 파일:** +- backend/api/project/[id]/repositories.get.ts +- backend/api/project/[id]/repository/create.post.ts +- backend/api/repository/[id]/update.put.ts +- backend/api/repository/[id]/delete.delete.ts +- frontend/project/[id].vue (저장소 관리 섹션 추가) --- -### Phase 3: Git 커밋 수집 (3일) -- [ ] 시작: -- [ ] 완료: -- [ ] 소요시간: +### Phase 3: Git 커밋 수집 (3일) ✅ 완료 +- [x] 시작: 2026-01-12 00:20 +- [x] 완료: 2026-01-12 00:35 +- [x] 소요시간: 15분 **작업 내용:** -- [ ] simple-git 연동 -- [ ] Git 커밋 수집 로직 -- [ ] 작성자 매칭 (VCS 계정 기반) -- [ ] 수동 동기화 API +- [x] simple-git 연동 → git CLI 직접 사용으로 변경 ✅ +- [x] Git 커밋 수집 로직 ✅ +- [x] 작성자 매칭 (VCS 계정 기반) ✅ +- [x] 수동 동기화 API ✅ +- [x] 프로젝트 커밋 조회 페이지 ✅ +- [x] 프로젝트 상세에 커밋 버튼 추가 ✅ + +**생성된 파일:** +- backend/utils/git-sync.ts (Git 동기화 유틸리티) +- backend/api/repository/[id]/sync.post.ts (저장소 동기화) +- backend/api/project/[id]/commits.get.ts (커밋 목록 조회) +- backend/api/project/[id]/commits/refresh.post.ts (커밋 새로고침) +- backend/api/commits/my-weekly.get.ts (내 주간 커밋) +- frontend/project/[id]/commits/index.vue (커밋 조회 페이지) --- diff --git a/frontend/project/[id].vue b/frontend/project/[id].vue index 6bc7690..b697f67 100644 --- a/frontend/project/[id].vue +++ b/frontend/project/[id].vue @@ -198,6 +198,11 @@ + diff --git a/frontend/project/[id]/commits/index.vue b/frontend/project/[id]/commits/index.vue new file mode 100644 index 0000000..42f6000 --- /dev/null +++ b/frontend/project/[id]/commits/index.vue @@ -0,0 +1,299 @@ + + +