From 07bf4da811e00abc2c919dae86d28d616f6de409 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Sun, 11 Jan 2026 14:03:12 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/project/[id]/commits/refresh.post.ts | 16 +- backend/api/repository/[id]/sync.post.ts | 5 +- backend/utils/svn-sync.ts | 217 ++++++++++++++++++ .../07_SVN_Git_커밋내역_연동_작업계획서.md | 29 ++- 4 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 backend/utils/svn-sync.ts diff --git a/backend/api/project/[id]/commits/refresh.post.ts b/backend/api/project/[id]/commits/refresh.post.ts index 54c66c8..e64a995 100644 --- a/backend/api/project/[id]/commits/refresh.post.ts +++ b/backend/api/project/[id]/commits/refresh.post.ts @@ -1,5 +1,6 @@ import { requireAuth } from '../../../../utils/session' import { syncProjectGitRepositories } from '../../../../utils/git-sync' +import { syncProjectSvnRepositories } from '../../../../utils/svn-sync' /** * 프로젝트 커밋 새로고침 (모든 저장소 동기화) @@ -13,13 +14,18 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' }) } - const result = await syncProjectGitRepositories(projectId) + // Git과 SVN 모두 동기화 + const gitResult = await syncProjectGitRepositories(projectId) + const svnResult = await syncProjectSvnRepositories(projectId) + + const allResults = [...gitResult.results, ...svnResult.results] + const allSuccess = allResults.every(r => r.success) return { - success: result.success, - message: result.success - ? `${result.results.length}개 저장소 동기화 완료` + success: allSuccess, + message: allSuccess + ? `${allResults.length}개 저장소 동기화 완료` : '일부 저장소 동기화 실패', - results: result.results + results: allResults } }) diff --git a/backend/api/repository/[id]/sync.post.ts b/backend/api/repository/[id]/sync.post.ts index fe67533..c798637 100644 --- a/backend/api/repository/[id]/sync.post.ts +++ b/backend/api/repository/[id]/sync.post.ts @@ -1,6 +1,7 @@ import { queryOne } from '../../../utils/db' import { requireAuth } from '../../../utils/session' import { syncGitRepository } from '../../../utils/git-sync' +import { syncSvnRepository } from '../../../utils/svn-sync' /** * 저장소 동기화 (수동) @@ -30,8 +31,8 @@ export default defineEventHandler(async (event) => { const result = await syncGitRepository(repoId) return result } else if (repo.server_type === 'SVN') { - // SVN은 별도 구현 예정 - return { success: false, message: 'SVN 동기화는 준비 중입니다.' } + const result = await syncSvnRepository(repoId) + return result } return { success: false, message: '지원하지 않는 서버 타입입니다.' } diff --git a/backend/utils/svn-sync.ts b/backend/utils/svn-sync.ts new file mode 100644 index 0000000..b893126 --- /dev/null +++ b/backend/utils/svn-sync.ts @@ -0,0 +1,217 @@ +import { query, execute, queryOne } from './db' +import { execSync, exec } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +interface SvnLogEntry { + revision: string + author: string + date: string + message: string +} + +/** + * 저장소 정보 조회 + */ +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]) +} + +/** + * SVN URL 생성 + */ +function buildSvnUrl(serverUrl: string, repoPath: string): string { + // 이미 전체 URL인 경우 + if (repoPath.startsWith('svn://') || repoPath.startsWith('http://') || repoPath.startsWith('https://')) { + return repoPath + } + + // 서버 URL + 경로 조합 + let baseUrl = serverUrl.replace(/\/$/, '') + let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}` + + return `${baseUrl}${path}` +} + +/** + * SVN 로그 XML 파싱 + */ +function parseSvnLogXml(xmlContent: string): SvnLogEntry[] { + const entries: SvnLogEntry[] = [] + + // 간단한 XML 파싱 (정규식 사용) + const logEntryRegex = /([\s\S]*?)<\/logentry>/g + const authorRegex = /(.*?)<\/author>/ + const dateRegex = /(.*?)<\/date>/ + const msgRegex = /([\s\S]*?)<\/msg>/ + + let match + while ((match = logEntryRegex.exec(xmlContent)) !== null) { + const revision = match[1] + const content = match[2] + + const authorMatch = content.match(authorRegex) + const dateMatch = content.match(dateRegex) + const msgMatch = content.match(msgRegex) + + entries.push({ + revision, + author: authorMatch ? authorMatch[1] : '', + date: dateMatch ? dateMatch[1] : '', + message: msgMatch ? msgMatch[1].trim() : '' + }) + } + + return entries +} + +/** + * VCS 계정으로 사용자 매칭 + */ +async function matchAuthor(serverId: number, authorName: string): Promise { + // SVN 사용자명으로 매칭 + let matched = await queryOne(` + SELECT employee_id FROM wr_employee_vcs_account + WHERE server_id = $1 AND vcs_username = $2 + `, [serverId, authorName]) + + if (matched) { + return matched.employee_id + } + + // VCS 계정에 없으면 직원 이름으로 매칭 시도 + matched = await queryOne(` + SELECT employee_id FROM wr_employee_info + WHERE (employee_name = $1 OR display_name = $1) AND is_active = true + `, [authorName]) + + return matched?.employee_id || null +} + +/** + * SVN 저장소 동기화 + */ +export async function syncSvnRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> { + const repo = await getRepositoryInfo(repoId) + + if (!repo) { + return { success: false, message: '저장소를 찾을 수 없습니다.' } + } + + if (repo.server_type !== 'SVN') { + return { success: false, message: 'SVN 저장소가 아닙니다.' } + } + + const svnUrl = buildSvnUrl(repo.server_url, repo.repo_path) + + // SVN 명령어 구성 + let command = `svn log "${svnUrl}" --xml` + + // 인증 정보 추가 + if (repo.auth_username && repo.auth_credential) { + command += ` --username "${repo.auth_username}" --password "${repo.auth_credential}"` + } + + // 기간 제한 + if (repo.last_sync_at) { + const sinceDate = new Date(repo.last_sync_at) + sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 + command += ` -r {${sinceDate.toISOString()}}:HEAD` + } else { + // 최초 동기화: 최근 100개 또는 30일 + command += ' -l 100' + } + + // 비대화형 모드 + command += ' --non-interactive --trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other' + + try { + const { stdout, stderr } = await execAsync(command, { + maxBuffer: 10 * 1024 * 1024, // 10MB + timeout: 60000 // 60초 타임아웃 + }) + + const entries = parseSvnLogXml(stdout) + let insertedCount = 0 + + // 커밋 저장 + for (const entry of entries) { + const employeeId = await matchAuthor(repo.server_id, entry.author) + + // UPSERT (중복 무시) + const result = await execute(` + INSERT INTO wr_commit_log ( + repo_id, commit_hash, commit_message, commit_author, + commit_date, employee_id, synced_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (repo_id, commit_hash) DO NOTHING + `, [ + repoId, + `r${entry.revision}`, // SVN: r123 형식 + entry.message, + entry.author, + entry.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, `${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`]) + + return { + success: true, + message: `동기화 완료: ${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`, + commitCount: insertedCount + } + + } catch (error: any) { + console.error('SVN 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 || '동기화 실패' } + } +} + +/** + * 프로젝트의 모든 SVN 저장소 동기화 + */ +export async function syncProjectSvnRepositories(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 = 'SVN' + `, [projectId]) + + const results = [] + for (const repo of repos) { + const result = await syncSvnRepository(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 c1d2164..b4e46b8 100644 --- a/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md +++ b/claude_temp/07_SVN_Git_커밋내역_연동_작업계획서.md @@ -627,29 +627,34 @@ REPO_CREDENTIAL_SECRET=your-secret-key --- -### Phase 4: SVN 커밋 수집 (3일) 🔄 진행중 +### Phase 4: SVN 커밋 수집 (3일) ✅ 완료 - [x] 시작: 2026-01-12 00:36 -- [ ] 완료: -- [ ] 소요시간: +- [x] 완료: 2026-01-12 00:42 +- [x] 소요시간: 6분 **작업 내용:** -- [ ] SVN 명령어 연동 -- [ ] SVN 커밋 수집 로직 -- [ ] XML 파싱 -- [ ] 작성자 매칭 +- [x] SVN 명령어 연동 ✅ +- [x] SVN 커밋 수집 로직 ✅ +- [x] XML 파싱 ✅ +- [x] 작성자 매칭 ✅ + +**생성된 파일:** +- backend/utils/svn-sync.ts (SVN 동기화 유틸리티) +- backend/api/repository/[id]/sync.post.ts (SVN 지원 추가) +- backend/api/project/[id]/commits/refresh.post.ts (SVN 지원 추가) --- -### Phase 5: 커밋 조회 화면 (3일) -- [ ] 시작: +### Phase 5: 커밋 조회 화면 (3일) 🔄 진행중 +- [x] 시작: 2026-01-12 00:43 - [ ] 완료: - [ ] 소요시간: **작업 내용:** -- [ ] 프로젝트별 커밋 조회 API -- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits) +- [x] 프로젝트별 커밋 조회 API ✅ (P3에서 완료) +- [x] 프로젝트 커밋 조회 페이지 (/project/[id]/commits) ✅ (P3에서 완료) - [ ] 주간보고 작성 시 커밋 참고 UI -- [ ] 새로고침 버튼 (최신 동기화) +- [ ] 새로고침 버튼 (최신 동기화) ✅ (P3에서 완료) ---