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 } }