Files
weeklyreport/backend/utils/svn-sync.ts
2026-01-11 14:03:12 +09:00

218 lines
6.3 KiB
TypeScript

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 = /<logentry\s+revision="(\d+)">([\s\S]*?)<\/logentry>/g
const authorRegex = /<author>(.*?)<\/author>/
const dateRegex = /<date>(.*?)<\/date>/
const msgRegex = /<msg>([\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<number | null> {
// 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 }
}