기능구현중
This commit is contained in:
236
server/utils/git-sync.ts
Normal file
236
server/utils/git-sync.ts
Normal file
@@ -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<number | null> {
|
||||
// 이메일로 먼저 매칭
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user