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