feat: AI 코드 검토 — 연결된 Gitea 코드로 체크리스트 구현 여부 판정

- ChecklistItem.reviewVerdict('done'|'missing'|null) 컬럼 추가 + 응답/프론트 타입 전파
- GiteaService.fetchRepoCode: 기본 브랜치 텍스트 파일 스냅샷(의존성/빌드/바이너리 제외, ~120KB 예산)
- AiService.reviewChecklist: 코드+체크리스트를 Claude 에 보내 항목별 done/missing 판정 후 체크리스트에 반영
- POST /ai/review-checklist(throttle 6/분, Claude 타임아웃 60s), Gitea 미연동/코드없음/항목없음 400
- TaskService.getTaskForReview/applyReviewVerdicts
- 프론트: TaskDetailPage 승인 요청 카드(prog/changes)에 "AI 검토" 버튼 → 체크박스 ✓(초록)/✗(빨강), 요약 배너

베스트에포트 휴리스틱(코드 일부만 검토), Gitea 연동 저장소 한정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 17:19:01 +09:00
parent 57a88ff741
commit 85ae82f3ce
14 changed files with 543 additions and 11 deletions
+137 -4
View File
@@ -21,6 +21,7 @@ import {
} from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useAi } from '@/composables/useAi'
import UserAvatar from '@/components/UserAvatar.vue'
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
@@ -31,6 +32,7 @@ const taskId = computed(() => Number(route.params.taskId))
const taskStore = useTaskStore()
const authStore = useAuthStore()
const ai = useAi()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
@@ -169,6 +171,33 @@ async function sendApproval() {
}
}
// --- AI 코드 검토 ---
// 연결된 Gitea 코드를 검토해 체크리스트 항목별 구현 여부(✓/✗)를 채운다.
const aiReviewing = ref(false)
const aiReviewSummary = ref<string | null>(null)
const aiReviewTruncated = ref(false)
async function runAiReview() {
if (!task.value || aiReviewing.value) return
aiReviewing.value = true
aiReviewSummary.value = null
try {
const result = await ai.reviewChecklist({
repoId: repoId.value,
taskSeq: task.value.id,
})
aiReviewSummary.value = result.summary
aiReviewTruncated.value = result.truncated
// 서버가 체크리스트에 판정을 반영했으므로 상세를 다시 불러와 ✓/✗ 갱신
await loadTask()
} catch {
window.alert(
'AI 코드 검토에 실패했습니다. Gitea 연결 상태를 확인하거나 잠시 후 다시 시도해 주세요.',
)
} finally {
aiReviewing.value = false
}
}
/* ============================================================
* 댓글 / 활동 탭
* ============================================================ */
@@ -486,20 +515,48 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
</div>
<!--
체크리스트는 읽기 전용 사용자가 개별 토글/추가/삭제하지 않는다.
완료 처리는 승인 워크플로우(승인 완료 일괄 완료)로만 이루어진다.
완료 처리는 승인 워크플로우(승인 완료 일괄 완료) 또는 AI 코드 검토로 채워진다.
-->
<div
v-if="aiReviewSummary"
class="ai-review-summary"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
<span>{{ aiReviewSummary }}<template v-if="aiReviewTruncated"> (코드 일부만 검토됨)</template></span>
</div>
<div class="checklist">
<div
v-for="(item, i) in task.checklist"
:key="item.id ?? i"
class="citem"
:class="{ done: item.done }"
:class="{ done: item.done, missing: item.reviewVerdict === 'missing' }"
:title="item.reviewVerdict === 'missing' ? '미구현으로 판정됨' : undefined"
>
<span
class="cbox"
:class="{ done: item.done }"
:class="{
done: item.done,
missing: item.reviewVerdict === 'missing',
}"
>
<svg
v-if="item.reviewVerdict === 'missing'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -864,6 +921,23 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
placeholder="승인 요청 메시지(선택) — 작업 내용이나 검토 포인트를 적어주세요."
/>
<div class="ap-card-actions">
<button
class="ap-btn ai-review"
type="button"
:disabled="aiReviewing"
title="연결된 Gitea 코드를 검토해 체크리스트 구현 여부를 채웁니다"
@click="runAiReview"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
{{ aiReviewing ? '검토 중…' : 'AI 검토' }}
</button>
<button
class="ap-btn request-send"
type="button"
@@ -911,6 +985,23 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
placeholder="보완 내용 메시지(선택)"
/>
<div class="ap-card-actions">
<button
class="ap-btn ai-review"
type="button"
:disabled="aiReviewing"
title="연결된 Gitea 코드를 검토해 체크리스트 구현 여부를 채웁니다"
@click="runAiReview"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
{{ aiReviewing ? '검토 중…' : 'AI 검토' }}
</button>
<button
class="ap-btn request-send"
type="button"
@@ -1497,13 +1588,18 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
background: var(--green);
border-color: var(--green);
}
.cbox.missing {
background: var(--red);
border-color: var(--red);
}
.cbox svg {
width: 0.75rem;
height: 0.75rem;
color: #fff;
opacity: 0;
}
.cbox.done svg {
.cbox.done svg,
.cbox.missing svg {
opacity: 1;
}
.citem .ctext {
@@ -1514,6 +1610,31 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
color: var(--text-3);
text-decoration: line-through;
}
.citem.missing .ctext {
color: var(--red);
}
/* AI 코드 검토 요약 배너 */
.ai-review-summary {
display: flex;
align-items: flex-start;
gap: 0.4375rem;
margin-bottom: 0.625rem;
padding: 0.5625rem 0.75rem;
background: var(--accent-weak);
border: 1px solid var(--accent-border);
border-radius: var(--radius);
font-size: 0.781rem;
line-height: 1.5;
color: var(--text);
}
.ai-review-summary svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--accent);
flex-shrink: 0;
margin-top: 0.125rem;
}
/* 댓글/활동 탭 */
.thread-tabs {
@@ -2249,6 +2370,18 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
.ap-btn.request-send:hover {
background: var(--accent-hover);
}
.ap-btn.ai-review {
color: var(--accent);
background: var(--accent-weak);
border-color: var(--accent-border);
}
.ap-btn.ai-review:hover:not(:disabled) {
background: #e7e4fb;
}
.ap-btn.ai-review:disabled {
opacity: 0.6;
cursor: default;
}
/* ===== 5단계: 첨부파일 ===== */
.files-head {