#!/usr/bin/env python3 """ Tasteby 페르소나 기반 코드 리뷰 4개 페르소나(프론트엔드, 백엔드, 보안, 아키텍처)가 git diff를 리뷰하고 최종 권고를 출력. 사용법: # 커밋되지 않은 변경 리뷰 python3 scripts/code_review.py # 최근 N개 커밋 리뷰 python3 scripts/code_review.py --commits 3 # 특정 영역만 리뷰 (frontend / backend / all) python3 scripts/code_review.py --scope frontend python3 scripts/code_review.py --scope backend # 특정 커밋 범위 리뷰 python3 scripts/code_review.py --range abc1234..def5678 """ import subprocess, json, os, sys, argparse from pathlib import Path # .env 파일에서 환경변수 로드 def load_dotenv(): env_path = Path(__file__).resolve().parent.parent / ".env" if env_path.exists(): with open(env_path) as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue if "=" in line: key, _, val = line.partition("=") os.environ.setdefault(key.strip(), val.strip()) load_dotenv() OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY") MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5") API_URL = "https://openrouter.ai/api/v1/chat/completions" # --- 페르소나 정의 --- PERSONAS = { "frontend": [ { "name": "프론트엔드 UX 전문가", "emoji": "\U0001f3a8", "prompt": """너는 Next.js/React 전문 프론트엔드 엔지니어야. 이 코드 변경에서 UX, 성능, 접근성 문제를 찾아내. 특히: - 불필요한 리렌더링이나 useEffect 남용은 없는지 - 로딩/에러 상태 처리가 빠져있는지 - 모바일 반응형이 깨질 수 있는 레이아웃 변경은 없는지 - Tailwind CSS 클래스 사용이 적절한지 - 네이버 지도 API 관련 변경이 올바른지 한국어로 3줄 이내로 핵심만 답변.""" }, { "name": "타입스크립트 품질 검사관", "emoji": "\U0001f9d0", "prompt": """너는 TypeScript 타입 안전성과 코드 품질만 보는 엄격한 리뷰어야. 이 코드 변경에서 타입 안전성과 코드 품질 문제를 찾아내. 특히: - any 타입 사용이나 타입 단언(as) 남용은 없는지 - API 응답 타입이 제대로 정의되어 있는지 - null/undefined 처리가 빠져있는지 - 컴포넌트 props 인터페이스가 명확한지 한국어로 3줄 이내로 핵심만 답변.""" }, ], "backend": [ { "name": "백엔드 API 설계자", "emoji": "\u2699\ufe0f", "prompt": """너는 Spring Boot + MyBatis 전문 백엔드 엔지니어야. 이 코드 변경에서 API 설계, 데이터 접근, 성능 문제를 찾아내. 특히: - MyBatis SQL에 N+1 쿼리나 비효율적인 조인은 없는지 - Oracle DB 특성(인덱스, CLOB 처리, ROWNUM)을 고려했는지 - REST API 설계가 일관적인지 (네이밍, HTTP 메서드, 상태 코드) - Redis 캐싱 전략이 적절한지 (TTL, 키 설계, 무효화) - ClobTypeHandler와 @JsonRawValue 사용이 올바른지 한국어로 3줄 이내로 핵심만 답변.""" }, { "name": "보안 감사관", "emoji": "\U0001f6e1\ufe0f", "prompt": """너는 OWASP Top 10을 기준으로 보안 취약점만 찾는 보안 전문가야. 이 코드 변경에서 보안 위험 요소를 찾아내. 특히: - SQL Injection 가능성 (MyBatis ${} 사용 여부) - XSS 취약점 (사용자 입력이 그대로 렌더링되는지) - 민감 정보 노출 (API 키, DB 비밀번호, 에러 스택트레이스) - CORS 설정이 너무 개방적이지 않은지 - JWT/인증 토큰 처리가 안전한지 - 입력값 검증이 빠져있는지 한국어로 3줄 이내로 핵심만 답변.""" }, ], "common": [ { "name": "아키텍처 리뷰어", "emoji": "\U0001f3d7\ufe0f", "prompt": """너는 전체 시스템 아키텍처를 보는 테크 리드야. 이 코드 변경이 전체 아키텍처에 미치는 영향을 평가해. 특히: - 프론트엔드-백엔드 간 API 계약이 맞는지 - 관심사 분리가 제대로 되어 있는지 (컨트롤러/서비스/매퍼) - 기존 코드 패턴과 일관성이 있는지 - 확장성이나 유지보수성을 해치는 변경은 없는지 한국어로 3줄 이내로 핵심만 답변.""" }, ] } PROJECT_CONTEXT = """ [프로젝트 컨텍스트 — Tasteby] - YouTube 맛집 영상에서 식당 정보를 추출하여 지도에 표시하는 서비스 - 스택: Spring Boot 3.3.5 + Java 21 + MyBatis + Oracle 23ai + Redis + Next.js 16 + TypeScript + Tailwind CSS - 프론트엔드: Next.js App Router, TypeScript, Tailwind CSS, 네이버 지도 API - 백엔드: Spring Boot REST API, MyBatis XML 매퍼, Oracle ADB, Redis 캐싱 - 주요 패턴: Controller(thin) -> Service(비즈니스 로직) -> Mapper(MyBatis XML) - 도메인: Lombok @Data/@Builder, Jackson SNAKE_CASE, CLOB은 ClobTypeHandler - UUID 생성: IdGenerator.newId() (32자 대문자 hex) - 인증: JWT 토큰 (AuthUtil.requireAdmin()) [리뷰 시 참고] - MyBatis에서 #{} 는 안전(PreparedStatement), ${} 는 SQL Injection 위험 - Oracle CLOB 필드는 ClobTypeHandler + @JsonRawValue 조합으로 처리 - Oracle 대문자 컬럼명 → JsonUtil.lowerKeys()로 변환 - resultType="map" 쿼리 시 서비스 레이어에서 JsonUtil.lowerKeys() 필수 - UserInfo.isAdmin은 @JsonProperty("is_admin") 필요 (JavaBeans boolean 네이밍) - CORS 설정: WebConfig.java에서 관리, 새 HTTP 메서드 추가 시 allowedMethods 확인 """ def get_diff(args): """변경사항 diff 가져오기""" if args.range: cmd = ["git", "diff", args.range] elif args.commits: cmd = ["git", "diff", f"HEAD~{args.commits}", "HEAD"] else: diff = subprocess.run(["git", "diff", "HEAD"], capture_output=True, text=True).stdout if not diff.strip(): diff = subprocess.run(["git", "diff", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout return diff return subprocess.run(cmd, capture_output=True, text=True).stdout def filter_diff_by_scope(diff, scope): """scope에 따라 diff 필터링""" if scope == "all": return diff lines = diff.split("\n") filtered = [] include = False for line in lines: if line.startswith("diff --git"): if scope == "frontend": include = "frontend/" in line elif scope == "backend": include = "backend-java/" in line if include: filtered.append(line) return "\n".join(filtered) def get_changed_files(args): """변경된 파일 목록""" if args.range: cmd = ["git", "diff", "--name-only", args.range] elif args.commits: cmd = ["git", "diff", "--name-only", f"HEAD~{args.commits}", "HEAD"] else: result = subprocess.run(["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True).stdout.strip() if not result: result = subprocess.run(["git", "diff", "--name-only", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout.strip() return result return subprocess.run(cmd, capture_output=True, text=True).stdout.strip() def call_llm(system_prompt, user_content): """OpenRouter API 호출""" import urllib.request body = json.dumps({ "model": MODEL, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content} ], "max_tokens": 500, }).encode() req = urllib.request.Request(API_URL, data=body, headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json", }) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) return data["choices"][0]["message"]["content"] except Exception as e: return f"[API 호출 실패] {e}" def select_personas(scope, files): """변경 파일에 따라 적절한 페르소나 선택""" personas = list(PERSONAS["common"]) has_frontend = any(f.startswith("frontend/") for f in files.split("\n")) if files else False has_backend = any(f.startswith("backend-java/") for f in files.split("\n")) if files else False if scope == "frontend" or (scope == "all" and has_frontend): personas = PERSONAS["frontend"] + personas if scope == "backend" or (scope == "all" and has_backend): personas = PERSONAS["backend"] + personas if scope == "all" and not has_frontend and not has_backend: personas = PERSONAS["frontend"] + PERSONAS["backend"] + personas return personas def main(): parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 코드 리뷰") parser.add_argument("--commits", "-n", type=int, help="최근 N개 커밋 리뷰") parser.add_argument("--range", "-r", type=str, help="커밋 범위 (예: abc..def)") parser.add_argument("--scope", "-s", choices=["frontend", "backend", "all"], default="all", help="리뷰 범위 (frontend/backend/all)") parser.add_argument("--model", "-m", type=str, help="사용할 모델 (기본: anthropic/claude-haiku-4-5)") args = parser.parse_args() if args.model: global MODEL MODEL = args.model if not OPENROUTER_API_KEY: print("ERROR: OPENROUTER_API_KEY 환경변수가 필요합니다") print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나") print(" export OPENROUTER_API_KEY=sk-or-...") sys.exit(1) diff = get_diff(args) if not diff.strip(): print("변경사항이 없습니다.") sys.exit(0) diff = filter_diff_by_scope(diff, args.scope) if not diff.strip(): print(f"'{args.scope}' 범위에 해당하는 변경사항이 없습니다.") sys.exit(0) files = get_changed_files(args) personas = select_personas(args.scope, files) max_diff = 8000 truncated = len(diff) > max_diff diff_for_review = diff[:max_diff] print("=" * 60) print(f" Tasteby 코드 리뷰 ({args.scope})") print("=" * 60) print(f"\n 모델: {MODEL}") print(f" 변경 파일:\n") for f in files.split("\n"): if f.strip(): if f.startswith("frontend/"): tag = "[FE]" elif f.startswith("backend-java/"): tag = "[BE]" else: tag = "[--]" print(f" {tag} {f}") if truncated: print(f"\n (diff가 {max_diff}자로 잘림, 원본 {len(diff)}자)") print() opinions = [] for p in personas: print(f" {p['emoji']} [{p['name']}] 리뷰 중...") user_msg = f"{PROJECT_CONTEXT}\n\n아래 코드 변경을 리뷰해줘:\n\n```diff\n{diff_for_review}\n```" result = call_llm(p["prompt"], user_msg) opinions.append({"name": p["name"], "emoji": p["emoji"], "opinion": result}) print(f" {result}\n") print("=" * 60) summary_prompt = """너는 프론트엔드/백엔드 리뷰어들의 의견을 종합하는 테크 리드야. 각 의견을 보고 최종 권고를 내려줘: - PASS: 문제 없음, 배포 가능 - WARN: 사소한 이슈 있지만 배포 가능, 후속 작업 필요 - FAIL: 수정 필요, 배포 전 반드시 고쳐야 함 한국어로 최종 판정 1줄 + 이유/액션 아이템 2-3줄로 답변.""" opinions_text = "\n\n".join( f"[{o['name']}]\n{o['opinion']}" for o in opinions ) print(f"\n [최종 권고]") final = call_llm( summary_prompt, f"{PROJECT_CONTEXT}\n\n변경사항 요약:\n{diff_for_review[:3000]}\n\n리뷰 의견:\n{opinions_text}" ) print(f" {final}") print("\n" + "=" * 60) if __name__ == "__main__": main()