Files
tasteby/scripts/code_review.py
joungmin c78f928a2d ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한
- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows)
- Design-First docs 골격 (docs/design, docs/adr, docs/pipeline)
- 안전-최대 권한 정책 (.claude/settings.json)
- Tasteby 고유 규칙 보존 (CLAUDE.md 병합)
- scripts/enqueue.sh: Redmine 큐 투입

Refs: tasteby bootstrap
2026-06-15 10:20:50 +09:00

317 lines
12 KiB
Python
Executable File

#!/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()