Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-01-11 14:02:35 +09:00
8 changed files with 876 additions and 25 deletions

View File

@@ -0,0 +1,90 @@
import { query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 내 주간 커밋 조회 (주간보고 작성용)
* GET /api/commits/my-weekly
*
* Query params:
* - projectId: 프로젝트 ID (필수)
* - startDate: 주 시작일 (YYYY-MM-DD)
* - endDate: 주 종료일 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const queryParams = getQuery(event)
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
// 조건 빌드
const conditions = [
'r.project_id = $1',
'c.employee_id = $2'
]
const values: any[] = [projectId, employeeId]
let paramIndex = 3
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
const whereClause = conditions.join(' AND ')
// 내 커밋 목록
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_date,
c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name,
s.server_type
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT 100
`, values)
// 통계
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash?.substring(0, 8),
commitMessage: c.commit_message,
commitDate: c.commit_date,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
serverType: c.server_type
})),
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0')
}
}
})

View File

@@ -0,0 +1,131 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 프로젝트 커밋 목록 조회
* GET /api/project/[id]/commits
*
* Query params:
* - startDate: 시작일 (YYYY-MM-DD)
* - endDate: 종료일 (YYYY-MM-DD)
* - repoId: 저장소 ID (옵션)
* - authorId: 작성자 ID (옵션)
* - page: 페이지 번호 (기본 1)
* - limit: 페이지당 개수 (기본 50)
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
const queryParams = getQuery(event)
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
const repoId = queryParams.repoId ? parseInt(queryParams.repoId as string) : null
const authorId = queryParams.authorId ? parseInt(queryParams.authorId as string) : null
const page = parseInt(queryParams.page as string) || 1
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
const offset = (page - 1) * limit
// 조건 빌드
const conditions = ['r.project_id = $1']
const values: any[] = [projectId]
let paramIndex = 2
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
if (repoId) {
conditions.push(`c.repo_id = $${paramIndex++}`)
values.push(repoId)
}
if (authorId) {
conditions.push(`c.employee_id = $${paramIndex++}`)
values.push(authorId)
}
const whereClause = conditions.join(' AND ')
// 커밋 목록 조회
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_author, c.commit_email,
c.commit_date, c.employee_id, c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name, r.repo_path,
s.server_type, s.server_name,
e.employee_name, e.display_name
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
LEFT JOIN wr_employee_info e ON c.employee_id = e.employee_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`, [...values, limit, offset])
// 전체 개수 조회
const countResult = await query(`
SELECT COUNT(*) as total
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
const total = parseInt(countResult[0]?.total || '0')
// 통계 (해당 기간)
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions,
COUNT(DISTINCT c.employee_id) as author_count
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash,
commitMessage: c.commit_message,
commitAuthor: c.commit_author,
commitEmail: c.commit_email,
commitDate: c.commit_date,
employeeId: c.employee_id,
employeeName: c.display_name || c.employee_name,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
repoPath: c.repo_path,
serverType: c.server_type,
serverName: c.server_name
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0'),
authorCount: parseInt(statsResult[0]?.author_count || '0')
}
}
})

View File

@@ -0,0 +1,25 @@
import { requireAuth } from '../../../../utils/session'
import { syncProjectGitRepositories } from '../../../../utils/git-sync'
/**
* 프로젝트 커밋 새로고침 (모든 저장소 동기화)
* POST /api/project/[id]/commits/refresh
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
const result = await syncProjectGitRepositories(projectId)
return {
success: result.success,
message: result.success
? `${result.results.length}개 저장소 동기화 완료`
: '일부 저장소 동기화 실패',
results: result.results
}
})

View File

@@ -0,0 +1,38 @@
import { queryOne } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { syncGitRepository } from '../../../utils/git-sync'
/**
* 저장소 동기화 (수동)
* POST /api/repository/[id]/sync
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(getRouterParam(event, 'id') || '0')
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
// 저장소 정보 확인
const repo = await queryOne(`
SELECT r.*, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1
`, [repoId])
if (!repo) {
throw createError({ statusCode: 404, message: '저장소를 찾을 수 없습니다.' })
}
if (repo.server_type === 'GIT') {
const result = await syncGitRepository(repoId)
return result
} else if (repo.server_type === 'SVN') {
// SVN은 별도 구현 예정
return { success: false, message: 'SVN 동기화는 준비 중입니다.' }
}
return { success: false, message: '지원하지 않는 서버 타입입니다.' }
})

236
backend/utils/git-sync.ts Normal file
View 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 }
}