기능구현중
This commit is contained in:
90
backend/api/commits/my-weekly.get.ts
Normal file
90
backend/api/commits/my-weekly.get.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
131
backend/api/project/[id]/commits.get.ts
Normal file
131
backend/api/project/[id]/commits.get.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
25
backend/api/project/[id]/commits/refresh.post.ts
Normal file
25
backend/api/project/[id]/commits/refresh.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
38
backend/api/repository/[id]/sync.post.ts
Normal file
38
backend/api/repository/[id]/sync.post.ts
Normal 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
236
backend/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 }
|
||||
}
|
||||
@@ -560,43 +560,70 @@ REPO_CREDENTIAL_SECRET=your-secret-key
|
||||
|
||||
## 8. 작업 일정
|
||||
|
||||
### Phase 1: DB + VCS 서버/계정 관리 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
### Phase 1: DB + VCS 서버/계정 관리 (3일) ✅ 완료
|
||||
- [x] 시작: 2026-01-11 02:13
|
||||
- [x] 완료: 2026-01-11 02:25
|
||||
- [x] 소요시간: 12분
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] wr_vcs_server 테이블 생성
|
||||
- [ ] wr_employee_vcs_account 테이블 생성
|
||||
- [ ] wr_repository 테이블 생성
|
||||
- [ ] wr_commit_log 테이블 생성
|
||||
- [ ] VCS 서버 관리 API + UI (관리자)
|
||||
- [ ] 마이페이지 VCS 계정 설정 UI
|
||||
- [x] wr_vcs_server 테이블 생성 ✅ 기존 존재
|
||||
- [x] wr_employee_vcs_account 테이블 생성 ✅ 기존 존재
|
||||
- [x] wr_repository 테이블 생성 ✅ 기존 존재
|
||||
- [x] wr_commit_log 테이블 생성 ✅
|
||||
- [x] VCS 서버 관리 API + UI (관리자) ✅
|
||||
- [x] 마이페이지 VCS 계정 설정 UI ✅
|
||||
|
||||
**생성된 파일:**
|
||||
- backend/api/admin/vcs-server/list.get.ts
|
||||
- backend/api/admin/vcs-server/create.post.ts
|
||||
- backend/api/admin/vcs-server/[id]/update.put.ts
|
||||
- backend/api/admin/vcs-server/[id]/delete.delete.ts
|
||||
- backend/api/my/vcs-accounts.get.ts
|
||||
- backend/api/my/vcs-account.post.ts
|
||||
- frontend/admin/vcs-server/index.vue
|
||||
- frontend/mypage/index.vue (VCS 계정 섹션 추가)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 저장소 관리 (2일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
### Phase 2: 저장소 관리 (2일) ✅ 완료
|
||||
- [x] 시작: 2026-01-11 23:30
|
||||
- [x] 완료: 2026-01-11 23:40
|
||||
- [x] 소요시간: 10분
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] 저장소 CRUD API
|
||||
- [ ] 프로젝트 상세에 저장소 관리 UI
|
||||
- [ ] 저장소 추가/수정 모달
|
||||
- [x] 저장소 CRUD API ✅
|
||||
- [x] 프로젝트 상세에 저장소 관리 UI ✅
|
||||
- [x] 저장소 추가/수정 모달 ✅
|
||||
|
||||
**생성된 파일:**
|
||||
- backend/api/project/[id]/repositories.get.ts
|
||||
- backend/api/project/[id]/repository/create.post.ts
|
||||
- backend/api/repository/[id]/update.put.ts
|
||||
- backend/api/repository/[id]/delete.delete.ts
|
||||
- frontend/project/[id].vue (저장소 관리 섹션 추가)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Git 커밋 수집 (3일)
|
||||
- [ ] 시작:
|
||||
- [ ] 완료:
|
||||
- [ ] 소요시간:
|
||||
### Phase 3: Git 커밋 수집 (3일) ✅ 완료
|
||||
- [x] 시작: 2026-01-12 00:20
|
||||
- [x] 완료: 2026-01-12 00:35
|
||||
- [x] 소요시간: 15분
|
||||
|
||||
**작업 내용:**
|
||||
- [ ] simple-git 연동
|
||||
- [ ] Git 커밋 수집 로직
|
||||
- [ ] 작성자 매칭 (VCS 계정 기반)
|
||||
- [ ] 수동 동기화 API
|
||||
- [x] simple-git 연동 → git CLI 직접 사용으로 변경 ✅
|
||||
- [x] Git 커밋 수집 로직 ✅
|
||||
- [x] 작성자 매칭 (VCS 계정 기반) ✅
|
||||
- [x] 수동 동기화 API ✅
|
||||
- [x] 프로젝트 커밋 조회 페이지 ✅
|
||||
- [x] 프로젝트 상세에 커밋 버튼 추가 ✅
|
||||
|
||||
**생성된 파일:**
|
||||
- backend/utils/git-sync.ts (Git 동기화 유틸리티)
|
||||
- backend/api/repository/[id]/sync.post.ts (저장소 동기화)
|
||||
- backend/api/project/[id]/commits.get.ts (커밋 목록 조회)
|
||||
- backend/api/project/[id]/commits/refresh.post.ts (커밋 새로고침)
|
||||
- backend/api/commits/my-weekly.get.ts (내 주간 커밋)
|
||||
- frontend/project/[id]/commits/index.vue (커밋 조회 페이지)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -198,6 +198,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-center" v-if="repositories.length > 0">
|
||||
<NuxtLink :to="`/project/${route.params.id}/commits`" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-git me-1"></i>커밋 내역 보기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
299
frontend/project/[id]/commits/index.vue
Normal file
299
frontend/project/[id]/commits/index.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<!-- 헤더 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<NuxtLink :to="`/project/${projectId}`" class="text-decoration-none text-muted me-2">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</NuxtLink>
|
||||
<i class="bi bi-git me-2"></i>커밋 내역
|
||||
</h4>
|
||||
<button class="btn btn-outline-primary" @click="refreshCommits" :disabled="isRefreshing">
|
||||
<span v-if="isRefreshing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-arrow-clockwise me-1"></i>새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">시작일</label>
|
||||
<input type="date" class="form-control form-control-sm" v-model="filters.startDate" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">종료일</label>
|
||||
<input type="date" class="form-control form-control-sm" v-model="filters.endDate" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">저장소</label>
|
||||
<select class="form-select form-select-sm" v-model="filters.repoId">
|
||||
<option value="">전체</option>
|
||||
<option v-for="r in repositories" :key="r.repoId" :value="r.repoId">
|
||||
[{{ r.serverType }}] {{ r.repoName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">작성자</label>
|
||||
<select class="form-select form-select-sm" v-model="filters.authorId">
|
||||
<option value="">전체</option>
|
||||
<option v-for="a in authors" :key="a.employeeId" :value="a.employeeId">
|
||||
{{ a.employeeName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary btn-sm w-100" @click="loadCommits">
|
||||
<i class="bi bi-search me-1"></i>검색
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="row mb-4" v-if="stats">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body py-3">
|
||||
<h3 class="mb-1 text-primary">{{ stats.commitCount }}</h3>
|
||||
<small class="text-muted">총 커밋</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body py-3">
|
||||
<h3 class="mb-1 text-success">+{{ formatNumber(stats.totalInsertions) }}</h3>
|
||||
<small class="text-muted">추가된 라인</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body py-3">
|
||||
<h3 class="mb-1 text-danger">-{{ formatNumber(stats.totalDeletions) }}</h3>
|
||||
<small class="text-muted">삭제된 라인</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body py-3">
|
||||
<h3 class="mb-1 text-info">{{ stats.authorCount }}</h3>
|
||||
<small class="text-muted">참여자</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 커밋 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong>커밋 목록</strong>
|
||||
<span class="text-muted ms-2" v-if="pagination">({{ pagination.total }}건)</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="commits.length === 0" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
커밋 내역이 없습니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:140px">날짜</th>
|
||||
<th style="width:150px">저장소</th>
|
||||
<th style="width:120px">작성자</th>
|
||||
<th>커밋 메시지</th>
|
||||
<th style="width:100px" class="text-end">변경</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in commits" :key="c.commitId">
|
||||
<td class="small">{{ formatDateTime(c.commitDate) }}</td>
|
||||
<td>
|
||||
<span :class="getServerBadgeClass(c.serverType)" class="me-1">{{ c.serverType }}</span>
|
||||
<span class="small">{{ c.repoName }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="c.employeeName">{{ c.employeeName }}</span>
|
||||
<span v-else class="text-muted small">{{ c.commitAuthor }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="me-2 text-muted">{{ c.commitHash }}</code>
|
||||
{{ c.commitMessage }}
|
||||
</td>
|
||||
<td class="text-end small">
|
||||
<span class="text-success" v-if="c.insertions">+{{ c.insertions }}</span>
|
||||
<span class="text-danger ms-1" v-if="c.deletions">-{{ c.deletions }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div class="card-footer" v-if="pagination && pagination.totalPages > 1">
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item" :class="{ disabled: pagination.page <= 1 }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(pagination.page - 1)">이전</a>
|
||||
</li>
|
||||
<li class="page-item" v-for="p in visiblePages" :key="p" :class="{ active: p === pagination.page }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(p)">{{ p }}</a>
|
||||
</li>
|
||||
<li class="page-item" :class="{ disabled: pagination.page >= pagination.totalPages }">
|
||||
<a class="page-link" href="#" @click.prevent="goToPage(pagination.page + 1)">다음</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => parseInt(route.params.id as string))
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isRefreshing = ref(false)
|
||||
const commits = ref<any[]>([])
|
||||
const pagination = ref<any>(null)
|
||||
const stats = ref<any>(null)
|
||||
const repositories = ref<any[]>([])
|
||||
const authors = ref<any[]>([])
|
||||
|
||||
// 기본 필터: 최근 2주
|
||||
const today = new Date()
|
||||
const twoWeeksAgo = new Date(today)
|
||||
twoWeeksAgo.setDate(today.getDate() - 14)
|
||||
|
||||
const filters = ref({
|
||||
startDate: twoWeeksAgo.toISOString().split('T')[0],
|
||||
endDate: today.toISOString().split('T')[0],
|
||||
repoId: '',
|
||||
authorId: ''
|
||||
})
|
||||
|
||||
// 저장소 목록 로드
|
||||
async function loadRepositories() {
|
||||
try {
|
||||
const data = await $fetch(`/api/repository/list?projectId=${projectId.value}`)
|
||||
repositories.value = data.repositories || []
|
||||
} catch (e) {
|
||||
console.error('저장소 목록 로드 실패:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 작성자 목록 (프로젝트 멤버)
|
||||
async function loadAuthors() {
|
||||
try {
|
||||
const data = await $fetch(`/api/project/${projectId.value}/members`)
|
||||
authors.value = data.members || []
|
||||
} catch (e) {
|
||||
// 멤버 API가 없을 수 있음
|
||||
authors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 커밋 목록 로드
|
||||
async function loadCommits(page = 1) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.startDate) params.append('startDate', filters.value.startDate)
|
||||
if (filters.value.endDate) params.append('endDate', filters.value.endDate)
|
||||
if (filters.value.repoId) params.append('repoId', filters.value.repoId)
|
||||
if (filters.value.authorId) params.append('authorId', filters.value.authorId)
|
||||
params.append('page', String(page))
|
||||
params.append('limit', '50')
|
||||
|
||||
const data = await $fetch(`/api/project/${projectId.value}/commits?${params}`)
|
||||
commits.value = data.commits || []
|
||||
pagination.value = data.pagination
|
||||
stats.value = data.stats
|
||||
} catch (e: any) {
|
||||
console.error('커밋 목록 로드 실패:', e)
|
||||
alert(e.data?.message || '커밋 목록을 불러오는데 실패했습니다.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 새로고침 (동기화)
|
||||
async function refreshCommits() {
|
||||
if (!confirm('저장소를 동기화하시겠습니까? 잠시 시간이 걸릴 수 있습니다.')) return
|
||||
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
const result = await $fetch(`/api/project/${projectId.value}/commits/refresh`, { method: 'POST' })
|
||||
alert(result.message || '동기화 완료')
|
||||
await loadCommits()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '동기화 실패')
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 이동
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || (pagination.value && page > pagination.value.totalPages)) return
|
||||
loadCommits(page)
|
||||
}
|
||||
|
||||
// 보이는 페이지 번호
|
||||
const visiblePages = computed(() => {
|
||||
if (!pagination.value) return []
|
||||
const total = pagination.value.totalPages
|
||||
const current = pagination.value.page
|
||||
const pages: number[] = []
|
||||
|
||||
let start = Math.max(1, current - 2)
|
||||
let end = Math.min(total, current + 2)
|
||||
|
||||
if (end - start < 4) {
|
||||
if (start === 1) end = Math.min(total, 5)
|
||||
else start = Math.max(1, total - 4)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
// 포맷터
|
||||
function formatDateTime(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatNumber(num: number) {
|
||||
if (!num) return '0'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
function getServerBadgeClass(type: string) {
|
||||
return type === 'GIT' ? 'badge bg-success' : 'badge bg-warning text-dark'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRepositories()
|
||||
loadAuthors()
|
||||
loadCommits()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user