- 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
317 lines
12 KiB
Python
Executable File
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()
|