기능구현중
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { requireAuth } from '../../../../utils/session'
|
import { requireAuth } from '../../../../utils/session'
|
||||||
import { syncProjectGitRepositories } from '../../../../utils/git-sync'
|
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가 필요합니다.' })
|
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 {
|
return {
|
||||||
success: result.success,
|
success: allSuccess,
|
||||||
message: result.success
|
message: allSuccess
|
||||||
? `${result.results.length}개 저장소 동기화 완료`
|
? `${allResults.length}개 저장소 동기화 완료`
|
||||||
: '일부 저장소 동기화 실패',
|
: '일부 저장소 동기화 실패',
|
||||||
results: result.results
|
results: allResults
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { queryOne } from '../../../utils/db'
|
import { queryOne } from '../../../utils/db'
|
||||||
import { requireAuth } from '../../../utils/session'
|
import { requireAuth } from '../../../utils/session'
|
||||||
import { syncGitRepository } from '../../../utils/git-sync'
|
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)
|
const result = await syncGitRepository(repoId)
|
||||||
return result
|
return result
|
||||||
} else if (repo.server_type === 'SVN') {
|
} else if (repo.server_type === 'SVN') {
|
||||||
// SVN은 별도 구현 예정
|
const result = await syncSvnRepository(repoId)
|
||||||
return { success: false, message: 'SVN 동기화는 준비 중입니다.' }
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: '지원하지 않는 서버 타입입니다.' }
|
return { success: false, message: '지원하지 않는 서버 타입입니다.' }
|
||||||
|
|||||||
217
backend/utils/svn-sync.ts
Normal file
217
backend/utils/svn-sync.ts
Normal file
@@ -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 = /<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 }
|
||||||
|
}
|
||||||
@@ -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:36
|
||||||
- [ ] 완료:
|
- [x] 완료: 2026-01-12 00:42
|
||||||
- [ ] 소요시간:
|
- [x] 소요시간: 6분
|
||||||
|
|
||||||
**작업 내용:**
|
**작업 내용:**
|
||||||
- [ ] SVN 명령어 연동
|
- [x] SVN 명령어 연동 ✅
|
||||||
- [ ] SVN 커밋 수집 로직
|
- [x] SVN 커밋 수집 로직 ✅
|
||||||
- [ ] XML 파싱
|
- [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
|
- [x] 프로젝트별 커밋 조회 API ✅ (P3에서 완료)
|
||||||
- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits)
|
- [x] 프로젝트 커밋 조회 페이지 (/project/[id]/commits) ✅ (P3에서 완료)
|
||||||
- [ ] 주간보고 작성 시 커밋 참고 UI
|
- [ ] 주간보고 작성 시 커밋 참고 UI
|
||||||
- [ ] 새로고침 버튼 (최신 동기화)
|
- [ ] 새로고침 버튼 (최신 동기화) ✅ (P3에서 완료)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user