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
@@ -11,8 +11,13 @@ import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
import {
AiService,
type ChecklistReviewResult,
type ChecklistSuggestionGroup,
} from './ai.service';
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
import { ReviewChecklistDto } from './dto/review-checklist.dto';
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
@ApiTags('AI')
@@ -37,4 +42,23 @@ export class AiSuggestController {
content: dto.content,
});
}
@Post('review-checklist')
@HttpCode(HttpStatus.OK)
// 코드 검토는 비용이 커서 더 엄격히 — IP당 분당 6회
@Throttle({ default: { limit: 6, ttl: 60_000 } })
@ApiOperation({
summary: '코드 검토 — Gitea 코드로 체크리스트 구현 여부 판정',
})
@ApiResponse({
status: 200,
description: '항목별 판정 + 요약(체크리스트 반영됨)',
})
@ApiResponse({ status: 404, description: '저장소/업무 없음 (RES_001)' })
reviewChecklist(
@CurrentUser() user: PublicUser,
@Body() dto: ReviewChecklistDto,
): Promise<ChecklistReviewResult> {
return this.aiService.reviewChecklist(user.id, dto.repoId, dto.taskSeq);
}
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskModule } from '../task/task.module';
import { RepoModule } from '../repo/repo.module';
import { GiteaModule } from '../gitea/gitea.module';
import { AiController } from './ai.controller';
import { AiSuggestController } from './ai-suggest.controller';
import { AiService } from './ai.service';
@@ -9,12 +10,13 @@ import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속.
// Task/RepoModule 을 가져와 사용자 업무·저장소 컨텍스트를 시스템 프롬프트에 주입한다.
// Task/Repo/GiteaModule 을 가져와 업무·저장소 컨텍스트 주입 및 코드 검토에 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([AiConversation, AiMessage]),
TaskModule,
RepoModule,
GiteaModule,
],
controllers: [AiController, AiSuggestController],
providers: [AiService],
+189 -2
View File
@@ -10,7 +10,9 @@ import {
} from '../../common/pagination/pagination';
import { TaskService } from '../task/task.service';
import type { TaskStatus } from '../task/entities/task.entity';
import type { ReviewVerdict } from '../task/entities/checklist-item.entity';
import { RepoService } from '../repo/repo.service';
import { GiteaService } from '../gitea/gitea.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
@@ -42,6 +44,19 @@ export interface ChecklistSuggestionGroup {
items: string[];
}
// 코드 검토 결과 — 항목별 판정 + 요약
export interface ChecklistReviewItem {
id: string;
text: string;
verdict: ReviewVerdict; // 'done'(구현됨) | 'missing'(미구현)
reason: string;
}
export interface ChecklistReviewResult {
items: ChecklistReviewItem[];
summary: string;
truncated: boolean; // 코드가 예산 초과로 일부만 검토됨
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_TOKENS = 1024;
@@ -91,6 +106,21 @@ const SUGGEST_SYSTEM_PROMPT = [
'{"groups":[{"category":"필수 기능","items":["...","..."]},{"category":"보안 기능","items":["..."]}]}',
].join('\n');
// 코드 검토용 시스템 프롬프트 — 체크리스트 항목별 구현 여부를 코드 근거로 판정, JSON 으로만 응답
const REVIEW_SYSTEM_PROMPT = [
'당신은 코드 리뷰어입니다. 주어진 저장소 코드를 근거로, 업무의 체크리스트 각 항목이 실제로 구현되었는지 판정합니다.',
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·코드펜스 없이 순수 JSON 만 출력합니다.',
'- 각 항목의 verdict 는 "done"(코드에 구현 근거가 명확) 또는 "missing"(근거 없음/미구현) 중 하나입니다.',
'- 코드에 근거가 없으면 추측하지 말고 "missing" 으로 판정하세요.',
'- reason 은 판정 근거를 한국어 한 문장으로(가능하면 파일/함수 언급). 제공된 코드는 일부일 수 있음을 감안하세요.',
'- items 의 id 는 입력으로 받은 체크리스트 항목 id 를 그대로 사용합니다.',
'',
'[형식]',
'{"items":[{"id":"<항목id>","verdict":"done","reason":"..."}],"summary":"전체 요약 한두 문장"}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
@@ -103,6 +133,7 @@ export class AiService {
config: ConfigService,
private readonly taskService: TaskService,
private readonly repoService: RepoService,
private readonly giteaService: GiteaService,
@InjectRepository(AiConversation)
private readonly convRepo: Repository<AiConversation>,
@InjectRepository(AiMessage)
@@ -254,6 +285,160 @@ export class AiService {
);
}
// 코드 검토 — 연결된 Gitea 저장소 코드를 가져와 체크리스트 항목별 구현 여부를 판정하고
// 결과(done/missing)를 체크리스트에 반영(applyReviewVerdicts)한다.
async reviewChecklist(
userId: string,
repoId: string,
taskSeq: number,
): Promise<ChecklistReviewResult> {
this.ensureEnabled();
const { repo, task } = await this.taskService.getTaskForReview(
repoId,
userId,
taskSeq,
);
const checklist = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder);
if (checklist.length === 0) {
throw new BusinessException(
'BIZ_001',
'검토할 체크리스트 항목이 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
if (!this.giteaService.enabled || repo.giteaRepoId === null) {
throw new BusinessException(
'BIZ_001',
'Gitea 에 연결된 저장소만 코드 검토를 할 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
// 1) 저장소 코드 스냅샷
let code: {
files: { path: string; content: string }[];
truncated: boolean;
};
try {
code = await this.giteaService.fetchRepoCode(repo.slugName, repo.branch);
} catch (e) {
this.logger.error(
`Gitea 코드 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
);
throw new BusinessException(
'BIZ_001',
'저장소 코드를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
if (code.files.length === 0) {
throw new BusinessException(
'BIZ_001',
'검토할 코드가 저장소에 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
// 2) 프롬프트 구성(코드 + 체크리스트)
const codeBlock = code.files
.map((f) => `### ${f.path}\n${f.content}`)
.join('\n\n');
const checklistBlock = checklist
.map((c) => `- id:${c.id} | ${c.text}`)
.join('\n');
const userMsg = [
`[업무] ${task.title}`,
task.content.length ? `내용:\n${task.content.join('\n')}` : '',
'',
`[체크리스트] (각 항목의 구현 여부를 판정)`,
checklistBlock,
'',
`[저장소 코드]${code.truncated ? ' (일부만 포함됨 — 용량 제한)' : ''}`,
codeBlock,
]
.filter(Boolean)
.join('\n');
// 3) 판정
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
REVIEW_SYSTEM_PROMPT,
2048,
60_000, // 코드 검토는 프롬프트가 커서 더 넉넉히
);
const parsed = this.parseReview(raw, checklist);
// 4) 체크리스트에 반영(done/missing)
const verdictMap = new Map<string, ReviewVerdict>(
parsed.items.map((i) => [i.id, i.verdict]),
);
await this.taskService.applyReviewVerdicts(task.id, verdictMap);
return { ...parsed, truncated: code.truncated };
}
// Claude 의 검토 JSON 파싱 — 알려진 체크리스트 id 만 채택, 누락 항목은 missing 으로 보정
private parseReview(
raw: string,
checklist: { id: string; text: string }[],
): { items: ChecklistReviewItem[]; summary: string } {
let text = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```$/, '')
.trim();
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) text = text.slice(start, end + 1);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw this.reviewFailed();
}
const obj = parsed as { items?: unknown; summary?: unknown };
const rawItems: unknown[] = Array.isArray(obj.items) ? obj.items : [];
const byId = new Map<string, ReviewVerdict>();
for (const it of rawItems) {
const r = it as { id?: unknown; verdict?: unknown };
if (typeof r.id !== 'string') continue;
const verdict: ReviewVerdict = r.verdict === 'done' ? 'done' : 'missing';
byId.set(r.id, verdict);
}
const reasonById = new Map<string, string>();
for (const it of rawItems) {
const r = it as { id?: unknown; reason?: unknown };
if (typeof r.id === 'string' && typeof r.reason === 'string') {
reasonById.set(r.id, r.reason.trim());
}
}
// 알려진 체크리스트 항목 기준으로 결과 구성(모델이 빠뜨린 항목은 missing)
const items: ChecklistReviewItem[] = checklist.map((c) => ({
id: c.id,
text: c.text,
verdict: byId.get(c.id) ?? 'missing',
reason: reasonById.get(c.id) ?? '코드에서 구현 근거를 찾지 못했습니다.',
}));
const summary =
typeof obj.summary === 'string' && obj.summary.trim()
? obj.summary.trim()
: '코드 검토를 완료했습니다.';
return { items, summary };
}
private reviewFailed(): BusinessException {
return new BusinessException(
'BIZ_001',
'코드 검토 결과를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
@@ -418,6 +603,8 @@ export class AiService {
private async callClaude(
messages: { role: 'user' | 'assistant'; content: string }[],
system: string,
maxTokens: number = MAX_TOKENS,
timeoutMs: number = REQUEST_TIMEOUT_MS,
): Promise<string> {
let response: Response;
try {
@@ -430,11 +617,11 @@ export class AiService {
},
body: JSON.stringify({
model: this.model,
max_tokens: MAX_TOKENS,
max_tokens: maxTokens,
system,
messages,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
this.logger.error(
@@ -0,0 +1,17 @@
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 코드 검토 요청 — 대상 저장소·업무
export class ReviewChecklistDto {
@ApiProperty({ description: '저장소 식별자(slugName)' })
@IsString()
@IsNotEmpty({ message: '저장소가 필요합니다.' })
repoId!: string;
@ApiProperty({ description: '업무 순번(seq)' })
@Type(() => Number)
@IsInt()
@Min(1)
taskSeq!: number;
}
@@ -36,6 +36,12 @@ interface GiteaRepoApi {
default_branch: string;
}
// Git 트리 API 응답(재귀)
interface GiteaTreeApi {
tree?: { path: string; type: string; size?: number }[];
truncated?: boolean;
}
// 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트.
// 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다.
// 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어,
@@ -154,6 +160,68 @@ export class GiteaService {
);
}
// 저장소 코드 스냅샷 — AI 코드 검토용. 기본 브랜치의 텍스트 파일을 바이트 예산까지 모아 반환.
// 바이너리/대용량/무관 경로(node_modules, .git, 잠금파일 등)는 제외하고, 합산이 예산을 넘으면 잘린다.
async fetchRepoCode(
name: string,
branch: string,
byteBudget = 120_000,
): Promise<{
files: { path: string; content: string }[];
truncated: boolean;
}> {
const repoPath = `${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`;
// 1) 재귀 트리 — 기본 브랜치 이름을 ref 로 사용
const tree = await this.request<GiteaTreeApi>(
'GET',
`/repos/${repoPath}/git/trees/${encodeURIComponent(branch)}?recursive=true&per_page=1000`,
);
const blobs = (tree?.tree ?? [])
.filter((e) => e.type === 'blob' && typeof e.path === 'string')
.filter((e) => this.isReviewablePath(e.path))
.filter((e) => (e.size ?? 0) > 0 && (e.size ?? 0) <= 64_000)
// 작은 파일 우선(예산 내 더 많은 파일 포함)
.sort((a, b) => (a.size ?? 0) - (b.size ?? 0));
const files: { path: string; content: string }[] = [];
let used = 0;
let truncated = tree?.truncated ?? false;
for (const blob of blobs) {
if (used + (blob.size ?? 0) > byteBudget) {
truncated = true;
continue;
}
try {
const raw = await this.requestText(
`/repos/${repoPath}/raw/${blob.path
.split('/')
.map(encodeURIComponent)
.join('/')}?ref=${encodeURIComponent(branch)}`,
);
if (raw == null) continue;
files.push({ path: blob.path, content: raw });
used += Buffer.byteLength(raw, 'utf8');
} catch {
// 개별 파일 실패는 건너뜀(베스트에포트)
}
}
return { files, truncated };
}
// 검토 대상 경로 필터 — 의존성/빌드산출물/바이너리/잠금파일 제외
private isReviewablePath(path: string): boolean {
const lower = path.toLowerCase();
const skipDir =
/(^|\/)(node_modules|\.git|dist|build|coverage|\.next|\.nuxt|vendor|__pycache__)\//;
if (skipDir.test('/' + lower)) return false;
if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(lower))
return false;
// 텍스트(코드/설정/문서) 확장자만 — 이미지/폰트/바이너리 제외
return /\.(ts|tsx|js|jsx|vue|mjs|cjs|json|md|py|java|go|rb|php|cs|c|cc|cpp|h|hpp|rs|kt|swift|dart|sql|ya?ml|toml|env|sh|html?|css|scss|sass|less)$/.test(
lower,
);
}
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용
async listOrgRepos(): Promise<GiteaRepoResult[]> {
const limit = 50; // Gitea 기본 최대 페이지 크기
@@ -204,6 +272,15 @@ export class GiteaService {
return (await res.json()) as T;
}
// raw 파일 등 텍스트 본문 요청 — JSON 이 아니라 원문 텍스트를 반환(404 등은 null)
private async requestText(path: string): Promise<string | null> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
headers: { Authorization: `token ${this.token}` },
});
if (!res.ok) return null;
return res.text();
}
// API 응답 → 보관용 결과 매핑
private toResult(data: GiteaRepoApi): GiteaRepoResult {
return {
@@ -8,6 +8,9 @@ import {
} from 'typeorm';
import { Task } from './task.entity';
// AI 코드 검토 판정 — 'done'(구현됨) | 'missing'(미구현). null 은 미검토.
export type ReviewVerdict = 'done' | 'missing';
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@@ -31,6 +34,14 @@ export class ChecklistItem {
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
// AI 코드 검토 판정(미검토면 null)
@Column({
name: 'review_verdict',
type: 'varchar',
nullable: true,
})
reviewVerdict!: ReviewVerdict | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+46 -2
View File
@@ -11,7 +11,10 @@ import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task, type TaskStatus } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import {
ChecklistItem,
type ReviewVerdict,
} from './entities/checklist-item.entity';
import { Comment } from './entities/comment.entity';
import { Attachment, type AttachmentKind } from './entities/attachment.entity';
import { CreateTaskDto } from './dto/create-task.dto';
@@ -52,6 +55,7 @@ export interface ChecklistItemResponse {
id: string;
text: string;
done: boolean;
reviewVerdict: ReviewVerdict | null; // AI 코드 검토 판정(미검토면 null)
}
// 저장소 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
@@ -1086,6 +1090,41 @@ export class TaskService {
}
}
// --- AI 코드 검토 연동 (ai 모듈에서 사용) ---
// 검토 대상 업무 조회 — 멤버 검증 후 업무(+체크리스트) 반환.
// 반환된 repo 로 호출측(AiService)이 Gitea 코드를 가져온다.
async getTaskForReview(
slugName: string,
actingUserId: string,
seq: number,
): Promise<{ repo: Repo; task: Task }> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertMember(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
return { repo, task };
}
// AI 검토 결과를 체크리스트에 반영 — verdict('done'|'missing')별로 done·reviewVerdict 갱신.
// verdicts: 체크리스트 항목 id → 판정. 누락된 항목은 변경하지 않는다.
async applyReviewVerdicts(
taskId: string,
verdicts: Map<string, ReviewVerdict>,
): Promise<void> {
const items = await this.checklistRepo.find({
where: { task: { id: taskId } },
});
const toSave: ChecklistItem[] = [];
for (const item of items) {
const verdict = verdicts.get(item.id);
if (!verdict) continue;
item.reviewVerdict = verdict;
item.done = verdict === 'done';
toSave.push(item);
}
if (toSave.length) await this.checklistRepo.save(toSave);
}
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
@@ -1251,7 +1290,12 @@ export class TaskService {
const checklist = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ id: c.id, text: c.text, done: c.done }));
.map((c) => ({
id: c.id,
text: c.text,
done: c.done,
reviewVerdict: c.reviewVerdict ?? null,
}));
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
const approval: ApprovalInfo | null =