[feat] swagger추가 #176
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: AI PR Inline Review Bot | |
| env: | |
| # 봇 작성자 식별자. 추후 GitHub App 계정으로 바뀌면 여기만 바꾸면 됨 | |
| REVIEW_BOT_LOGIN: github-actions[bot] | |
| # 스레드 자동응답 프롬프트에 들어갈 최대 길이(비용/타임아웃 보호) | |
| REVIEW_BOT_MAX_PARENT_BODY_CHARS: "4000" | |
| REVIEW_BOT_MAX_USER_BODY_CHARS: "4000" | |
| on: | |
| pull_request: | |
| # PR 생성 / 다시 열기 / draft 해제 시 실행 (커밋 추가 synchronize는 자동 리뷰 제외) | |
| types: [opened, reopened, ready_for_review] | |
| pull_request_review_comment: | |
| # 리뷰 코멘트 스레드에 새 댓글(리플)이 달릴 때 실행 | |
| types: [created] | |
| permissions: | |
| # 코드/PR diff 읽기 | |
| contents: read | |
| # 인라인 코멘트 작성/리플 작성 | |
| pull-requests: write | |
| concurrency: | |
| # 같은 PR에서 중복 실행 경합을 막고, 최신 실행만 남김 | |
| # pull_request / pull_request_review_comment 런이 서로 취소시키지 않도록 event_name 분리 | |
| group: ai-review-bot-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| review: | |
| # draft PR / fork PR은 건너뜀 (fork에는 secret 미주입) | |
| # pull_request 이벤트에서만 실행 | |
| # pull_request_review_comment 이벤트 처리는 아래 reply_to_thread job이 담당 | |
| if: github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Get PR diff | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_URL: ${{ github.event.pull_request.url }} | |
| run: | | |
| # PR diff 수집 | |
| set +e | |
| curl -fsS -L \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| -H "Accept: application/vnd.github.v3.diff" \ | |
| "$PR_URL" -o pr.diff | |
| curl_status=$? | |
| set -e | |
| if [ "$curl_status" -ne 0 ]; then | |
| echo "::warning::PR diff 수집에 실패했습니다(curl exit=$curl_status). 이번 런에서는 리뷰를 생략합니다." | |
| : > pr.diff | |
| fi | |
| # 에러 JSON/HTML 같은 비-diff payload 보호 | |
| if ! grep -q '^diff --git ' pr.diff; then | |
| echo "::warning::수집된 payload가 git diff 형식이 아니어서 리뷰를 생략합니다." | |
| : > pr.diff | |
| fi | |
| echo "===== RAW DIFF SIZE =====" | |
| wc -c pr.diff | |
| - name: Trim diff for token saving | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| from pathlib import Path | |
| from pathlib import PurePosixPath | |
| # 이전 단계에서 생성한 raw diff | |
| diff = Path("pr.diff").read_text(errors="ignore") | |
| # 리뷰 가치가 낮거나 noise가 큰 파일 패턴은 제외 | |
| excluded_file_names = { | |
| "package-lock.json", | |
| "pnpm-lock.yaml", | |
| "yarn.lock", | |
| "bun.lockb", | |
| } | |
| excluded_exact_paths = { | |
| # 리뷰봇이 자기 설정 파일을 계속 재리뷰하며 루프를 만드는 문제 방지 | |
| ".github/workflows/review-bot.yml", | |
| } | |
| excluded_dir_names = { | |
| ".next", | |
| "dist", | |
| "build", | |
| "coverage", | |
| ".gradle", | |
| ".idea", | |
| } | |
| excluded_extensions = ( | |
| ".png", | |
| ".jpg", | |
| ".jpeg", | |
| ".gif", | |
| ".svg", | |
| ".webp", | |
| ".ico", | |
| ".pdf", | |
| ) | |
| def normalize_quoted_path(value): | |
| text = str(value or "").strip() | |
| # git diff 헤더 경로가 따옴표로 감싸진 경우를 정리 | |
| if len(text) >= 2 and text[0] == '"' and text[-1] == '"': | |
| text = text[1:-1] | |
| text = text.replace('\\"', '"').replace('\\\\', '\\') | |
| return text | |
| def parse_paths_from_header(header_line): | |
| # 예: diff --git a/foo/bar.py b/foo/bar.py | |
| prefix = "diff --git " | |
| if not header_line.startswith(prefix): | |
| return "", "" | |
| payload = header_line[len(prefix):] | |
| if not payload.startswith("a/"): | |
| return "", "" | |
| body = payload[2:] | |
| if " b/" not in body: | |
| return "", "" | |
| # 공백이 포함된 파일명도 깨지지 않도록 마지막 " b/" 기준 분리 | |
| left, right = body.rsplit(" b/", 1) | |
| return normalize_quoted_path(left), normalize_quoted_path(right) | |
| def choose_primary_path(old_path, new_path): | |
| if new_path and new_path not in {"/dev/null", "dev/null"}: | |
| return new_path | |
| if old_path and old_path not in {"/dev/null", "dev/null"}: | |
| return old_path | |
| return "" | |
| def should_exclude_path(path): | |
| normalized = str(PurePosixPath(str(path or ""))).strip().lower() | |
| if not normalized or normalized in {"/dev/null", "dev/null"}: | |
| return False | |
| posix_path = PurePosixPath(normalized) | |
| normalized_path = posix_path.as_posix() | |
| if normalized_path in excluded_exact_paths: | |
| return True | |
| file_name = posix_path.name | |
| if file_name in excluded_file_names: | |
| return True | |
| if file_name.endswith(excluded_extensions): | |
| return True | |
| for segment in posix_path.parts: | |
| if segment in excluded_dir_names: | |
| return True | |
| return False | |
| filtered_lines = [] | |
| current_block = [] | |
| current_old_path = "" | |
| current_new_path = "" | |
| current_path = "" | |
| skip_block = False | |
| kept_paths = [] | |
| for line in diff.splitlines(): | |
| if line.startswith("diff --git "): | |
| if current_block and not skip_block: | |
| filtered_lines.extend(current_block) | |
| if current_path: | |
| kept_paths.append(current_path) | |
| current_block = [line] | |
| current_old_path, current_new_path = parse_paths_from_header(line) | |
| current_path = choose_primary_path(current_old_path, current_new_path) | |
| skip_block = should_exclude_path(current_old_path) or should_exclude_path(current_new_path) | |
| else: | |
| current_block.append(line) | |
| if current_block and not skip_block: | |
| filtered_lines.extend(current_block) | |
| if current_path: | |
| kept_paths.append(current_path) | |
| filtered = "\n".join(filtered_lines) | |
| # diff 길이 상한: 비용/지연 보호 | |
| MAX_DIFF_CHARS = 15000 | |
| diff_was_clipped = False | |
| if len(filtered) > MAX_DIFF_CHARS: | |
| diff_was_clipped = True | |
| # 글자 단위가 아닌 "라인 단위"로 자른다. | |
| # 문장 중간 절단으로 인한 오탐 코멘트를 줄이기 위함 | |
| clipped_lines = [] | |
| current_size = 0 | |
| for line in filtered.splitlines(): | |
| line_size = len(line) + 1 # newline 포함 | |
| if current_size + line_size > MAX_DIFF_CHARS: | |
| break | |
| clipped_lines.append(line) | |
| current_size += line_size | |
| filtered = "\n".join(clipped_lines) | |
| # 화이트리스트 3종만 "파일 전체 본문"을 추가 제공 | |
| # 1) .github/workflows/** | |
| # 2) Dockerfile* | |
| # 3) 대표 CI 설정 파일 | |
| ci_config_files = { | |
| ".gitlab-ci.yml", | |
| ".travis.yml", | |
| "azure-pipelines.yml", | |
| "Jenkinsfile", | |
| ".circleci/config.yml", | |
| ".buildkite/pipeline.yml", | |
| } | |
| def is_full_context_target(path): | |
| normalized = str(PurePosixPath(path)) | |
| name = PurePosixPath(normalized).name | |
| if normalized.startswith(".github/workflows/"): | |
| return True | |
| if name.startswith("Dockerfile"): | |
| return True | |
| if normalized in ci_config_files: | |
| return True | |
| return False | |
| full_context_chunks = [] | |
| full_context_paths = [] | |
| full_context_was_clipped = False | |
| max_full_context_chars = 120000 | |
| current_full_context_chars = 0 | |
| for rel_path in sorted(set(kept_paths)): | |
| if not is_full_context_target(rel_path): | |
| continue | |
| file_path = Path(rel_path) | |
| if not file_path.exists() or file_path.is_dir(): | |
| continue | |
| try: | |
| content = file_path.read_text(errors="ignore") | |
| except Exception: | |
| continue | |
| chunk = ( | |
| f"### FULL FILE: {rel_path}\n" | |
| "```text\n" | |
| f"{content}\n" | |
| "```\n" | |
| ) | |
| chunk_size = len(chunk) | |
| if current_full_context_chars + chunk_size > max_full_context_chars: | |
| full_context_was_clipped = True | |
| break | |
| full_context_chunks.append(chunk) | |
| full_context_paths.append(rel_path) | |
| current_full_context_chars += chunk_size | |
| full_context_text = "\n".join(full_context_chunks).strip() | |
| Path("pr_trimmed.diff").write_text(filtered) | |
| Path("pr_full_context.txt").write_text(full_context_text) | |
| Path("pr_context_meta.json").write_text(json.dumps({ | |
| "diff_was_clipped": diff_was_clipped, | |
| "full_context_paths": full_context_paths, | |
| "full_context_was_clipped": full_context_was_clipped, | |
| }, ensure_ascii=False, indent=2)) | |
| print("===== TRIMMED DIFF LENGTH =====") | |
| print(len(filtered)) | |
| print("===== FULL CONTEXT FILE COUNT =====") | |
| print(len(full_context_paths)) | |
| if diff_was_clipped: | |
| print("DIFF_WAS_CLIPPED=true") | |
| if full_context_was_clipped: | |
| print("FULL_CONTEXT_WAS_CLIPPED=true") | |
| PY | |
| - name: Ask OpenAI for structured review | |
| continue-on-error: true | |
| env: | |
| # 필수 시크릿 | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import re | |
| import time | |
| from pathlib import Path | |
| from urllib.error import HTTPError, URLError | |
| from urllib.request import Request, urlopen | |
| warnings = [] | |
| def add_warning(message): | |
| # 중복 warning 제거 + Actions annotation 출력 | |
| normalized = " ".join(str(message).split()) | |
| if normalized not in warnings: | |
| warnings.append(normalized) | |
| print(f"::warning::{normalized}") | |
| def write_review(summary, comments=None, fallback=False, model_used=None): | |
| # 다음 step(JS)에서 읽을 단일 결과 파일 | |
| payload = { | |
| "summary": summary, | |
| "comments": comments if isinstance(comments, list) else [], | |
| "meta": { | |
| "status": "fallback" if fallback else "ok", | |
| "model_used": model_used, | |
| "warnings": warnings, | |
| } | |
| } | |
| Path("review_result.json").write_text( | |
| json.dumps(payload, ensure_ascii=False, indent=2) | |
| ) | |
| if fallback: | |
| print("===== REVIEW RESULT JSON (FALLBACK) =====") | |
| else: | |
| print("===== REVIEW RESULT JSON =====") | |
| print(json.dumps(payload, ensure_ascii=False, indent=2)) | |
| def redact_secrets(text): | |
| value = str(text or "") | |
| if not value: | |
| return value, 0 | |
| redacted = value | |
| redaction_count = 0 | |
| token_patterns = [ | |
| (re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), "[REDACTED_GITHUB_TOKEN]"), | |
| (re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "[REDACTED_API_KEY]"), | |
| (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_AWS_ACCESS_KEY_ID]"), | |
| ( | |
| re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}\b"), | |
| "[REDACTED_JWT]", | |
| ), | |
| ( | |
| re.compile( | |
| r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----" | |
| ), | |
| "[REDACTED_PRIVATE_KEY]", | |
| ), | |
| ] | |
| for pattern, replacement in token_patterns: | |
| redacted, hit = pattern.subn(replacement, redacted) | |
| redaction_count += hit | |
| assignment_pattern = re.compile( | |
| r'(?im)^(\s*[^#\n]{0,80}(?:token|secret|password|api[_-]?key|access[_-]?key|private[_-]?key)' | |
| r'[^:=\n]{0,40}[:=]\s*)([^\n]+)$' | |
| ) | |
| template_expr_prefix = "$" + "{{" | |
| def mask_assignment(match): | |
| nonlocal redaction_count | |
| prefix = match.group(1) | |
| raw_value = (match.group(2) or "").strip() | |
| lowered = raw_value.lower() | |
| if not raw_value: | |
| return match.group(0) | |
| if template_expr_prefix in raw_value or raw_value == "***": | |
| return match.group(0) | |
| if "[redacted]" in lowered or "<redacted>" in lowered: | |
| return match.group(0) | |
| redaction_count += 1 | |
| return f"{prefix}[REDACTED]" | |
| redacted = assignment_pattern.sub(mask_assignment, redacted) | |
| return redacted, redaction_count | |
| try: | |
| diff = Path("pr_trimmed.diff").read_text(errors="ignore").strip() | |
| except Exception as e: | |
| add_warning(f"리뷰 diff 파일 읽기 실패: {e}") | |
| write_review(f"리뷰 diff 파일을 읽지 못해 자동 리뷰를 생략했습니다. ({e})", fallback=True) | |
| raise SystemExit(0) | |
| if not diff: | |
| add_warning("검토 가능한 diff가 없어 AI 리뷰를 생략했습니다.") | |
| write_review("변경 내용이 너무 작거나 제외 대상 파일만 있어 리뷰를 생략했습니다.") | |
| raise SystemExit(0) | |
| api_key = os.getenv("OPENAI_API_KEY", "").strip() | |
| if not api_key: | |
| add_warning("OPENAI_API_KEY 누락으로 AI 리뷰를 생략했습니다.") | |
| write_review("OPENAI_API_KEY가 설정되지 않아 자동 인라인 리뷰를 생략했습니다.", fallback=True) | |
| raise SystemExit(0) | |
| # 선택된 화이트리스트 파일(.github/workflows, Dockerfile, CI 설정)의 전체 본문 | |
| try: | |
| full_context = Path("pr_full_context.txt").read_text(errors="ignore").strip() | |
| except Exception: | |
| full_context = "" | |
| try: | |
| context_meta = json.loads(Path("pr_context_meta.json").read_text(errors="ignore")) | |
| except Exception: | |
| context_meta = {} | |
| diff, diff_redaction_count = redact_secrets(diff) | |
| full_context, full_context_redaction_count = redact_secrets(full_context) | |
| total_redaction_count = diff_redaction_count + full_context_redaction_count | |
| if total_redaction_count > 0: | |
| add_warning(f"OpenAI 전송 전 민감 문자열 {total_redaction_count}건을 마스킹 처리했습니다.") | |
| context_meta_text = json.dumps(context_meta, ensure_ascii=False, indent=2) | |
| full_context_section = "" | |
| if full_context: | |
| full_context_section = f""" | |
| FULL FILE CONTEXT (Whitelist Only): | |
| {full_context} | |
| """ | |
| # 응답 포맷 안정성을 위해 JSON 반환을 강하게 요구 | |
| prompt = f""" | |
| 너는 GitHub Pull Request 코드 리뷰어다. | |
| 아래 PR diff를 보고, '정말 지적할 가치가 있는 문제'만 골라라. | |
| 억지 지적은 금지한다. | |
| 반드시 아래 JSON 형식으로만 답해라. | |
| 코드블록 마크다운 없이 순수 JSON만 출력해라. | |
| {{ | |
| "summary": "전체 리뷰 요약 한두 문장", | |
| "comments": [ | |
| {{ | |
| "path": "파일경로", | |
| "line": 123, | |
| "severity": "critical|high|medium|low", | |
| "issue": "실제 문제를 한두 문장으로 설명", | |
| "reason": "왜 문제인지 근거를 구체적으로 설명", | |
| "suggestion": "해결 방법(선택 항목)" | |
| }} | |
| ] | |
| }} | |
| 규칙: | |
| 1. comments는 최대 3개 | |
| 2. severity는 critical, high, medium, low 중 하나만 사용 | |
| 3. 반드시 diff에 존재하는 파일만 사용 | |
| 4. line은 '새 코드 기준 라인 번호'를 사용 | |
| 5. 확실하지 않으면 comment를 만들지 마라 | |
| 6. 문제 없으면 comments는 빈 배열로 둬라 | |
| 7. 버그 가능성, 예외 처리 누락, 보안, 성능, 치명적 가독성 문제 위주 | |
| 8. 리뷰는 한국어로 작성 | |
| 9. issue/reason/suggestion에는 라벨("내용:", "근거:", "제안:")을 넣지 말고 본문만 작성 | |
| 10. suggestion은 불필요하면 생략 가능 | |
| 11. diff는 길이 제한으로 일부 생략될 수 있으니, 파일이 중간에 잘렸다는 유형의 추정 지적은 금지 | |
| 12. CONTEXT META에서 diff_was_clipped=true 이면, 파일/잡/핸들러가 "없다"는 단정 지적은 금지 | |
| 13. Full File Context가 제공된 파일은 해당 본문을 우선 기준으로 구조를 판단하라 | |
| 14. 리뷰 문장은 한국어 존댓말로 작성하라 (반말 금지) | |
| CONTEXT META: | |
| {context_meta_text} | |
| PR DIFF: | |
| {diff} | |
| {full_context_section} | |
| """ | |
| # gpt-5.3-mini를 우선 시도하고, 실패 시 gpt-5-mini로 폴백 | |
| models_to_try = ["gpt-5.3-mini", "gpt-5-mini"] | |
| result = None | |
| model_used = None | |
| last_error = None | |
| request_timeout_sec = 90 | |
| max_retries = 3 | |
| retry_backoff_sec = 2 | |
| for model_name in models_to_try: | |
| for attempt in range(1, max_retries + 1): | |
| body = { | |
| "model": model_name, | |
| "input": prompt | |
| } | |
| req = Request( | |
| "https://api.openai.com/v1/responses", | |
| data=json.dumps(body).encode("utf-8"), | |
| headers={ | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {api_key}", | |
| }, | |
| method="POST", | |
| ) | |
| try: | |
| with urlopen(req, timeout=request_timeout_sec) as res: | |
| result = json.loads(res.read().decode("utf-8")) | |
| model_used = model_name | |
| print(f"OpenAI model used: {model_name} (attempt {attempt})") | |
| break | |
| except HTTPError as e: | |
| error_detail = "" | |
| try: | |
| error_detail = e.read().decode("utf-8", errors="ignore") | |
| except Exception: | |
| pass | |
| last_error = f"{e} {error_detail}".strip() | |
| # 400은 입력/모델 호환 이슈일 가능성이 높아 | |
| # 같은 모델 재시도 대신 즉시 다음 모델로 폴백 | |
| if e.code == 400: | |
| add_warning(f"OpenAI model={model_name} HTTP 400 발생. 다음 모델로 폴백합니다.") | |
| print( | |
| f"OpenAI call failed for model={model_name}, attempt={attempt}: {last_error}. " | |
| "Skip retries for this model." | |
| ) | |
| break | |
| except (URLError, TimeoutError, OSError, ValueError) as e: | |
| last_error = str(e) | |
| if attempt < max_retries: | |
| sleep_sec = retry_backoff_sec ** (attempt - 1) | |
| print( | |
| f"OpenAI call failed for model={model_name}, attempt={attempt}: {last_error}. " | |
| f"Retry in {sleep_sec}s." | |
| ) | |
| time.sleep(sleep_sec) | |
| else: | |
| print( | |
| f"OpenAI call failed for model={model_name} after {attempt} attempts: {last_error}" | |
| ) | |
| if result is not None: | |
| break | |
| if result is None: | |
| summary = "OpenAI 응답을 받지 못해 자동 인라인 리뷰를 생략했습니다. 잠시 후 다시 실행해 주세요." | |
| if last_error: | |
| summary += f" (원인: {last_error})" | |
| add_warning(f"OpenAI 호출 실패: {last_error}") | |
| write_review(summary, fallback=True, model_used=model_used) | |
| raise SystemExit(0) | |
| text_parts = [] | |
| for item in result.get("output", []): | |
| for content in item.get("content", []): | |
| if content.get("type") == "output_text": | |
| text_parts.append(content.get("text", "")) | |
| raw_text = "\n".join(text_parts).strip() | |
| if not raw_text: | |
| raw_text = str(result.get("output_text", "")).strip() | |
| # 모델 출력이 비정형일 수 있어 파싱 실패를 안전하게 처리 | |
| try: | |
| parsed = json.loads(raw_text) | |
| except Exception: | |
| add_warning("OpenAI 응답 JSON 파싱 실패로 인라인 리뷰를 생략했습니다.") | |
| parsed = { | |
| "summary": "AI 응답을 구조화하지 못해 인라인 리뷰를 생략했습니다.", | |
| "comments": [] | |
| } | |
| # 기본값 보정 | |
| if "summary" not in parsed: | |
| parsed["summary"] = "리뷰 요약이 없습니다." | |
| if "comments" not in parsed or not isinstance(parsed["comments"], list): | |
| parsed["comments"] = [] | |
| # 최대 3개로 제한 | |
| parsed["comments"] = parsed["comments"][:3] | |
| write_review(parsed["summary"], parsed["comments"], model_used=model_used) | |
| PY | |
| - name: Create inline comments (reply enabled) | |
| continue-on-error: true | |
| uses: actions/github-script@v8 | |
| env: | |
| REVIEW_BOT_LOGIN: ${{ env.REVIEW_BOT_LOGIN }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| let review = { | |
| summary: '리뷰 결과 파일이 없어 인라인 리뷰를 생략했습니다.', | |
| comments: [], | |
| meta: { status: 'fallback', warnings: ['review_result.json missing'] } | |
| }; | |
| try { | |
| if (fs.existsSync('review_result.json')) { | |
| const parsed = JSON.parse(fs.readFileSync('review_result.json', 'utf8')); | |
| if (parsed && typeof parsed === 'object') { | |
| review = parsed; | |
| } | |
| } | |
| } catch (e) { | |
| console.log('review_result.json parse failed'); | |
| console.log(e); | |
| } | |
| const pr = context.payload.pull_request; | |
| if (!pr || !pr.number || !pr.head || !pr.head.sha) { | |
| core.warning('pull_request payload가 없어 인라인 코멘트 생성을 건너뜁니다.'); | |
| return; | |
| } | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pull_number = pr.number; | |
| const commit_id = pr.head.sha; | |
| const botLogin = process.env.REVIEW_BOT_LOGIN || 'github-actions[bot]'; | |
| // review_result.json 표준 스키마: | |
| // - summary: 텍스트 요약 | |
| // - comments: 인라인 코멘트 후보 | |
| // - meta: 상태/모델/경고 | |
| const rawComments = Array.isArray(review.comments) ? review.comments : []; | |
| const severityMeta = { | |
| critical: { icon: '🔴', label: 'CRITICAL' }, | |
| high: { icon: '🟠', label: 'HIGH' }, | |
| medium: { icon: '🟡', label: 'MEDIUM' }, | |
| low: { icon: '🟢', label: 'LOW' } | |
| }; | |
| const normalizeSeverity = (severity) => { | |
| const value = String(severity || '').toLowerCase().trim(); | |
| return value in severityMeta ? value : 'medium'; | |
| }; | |
| const buildBody = (comment) => { | |
| const sev = severityMeta[normalizeSeverity(comment.severity)]; | |
| const issue = String(comment.issue || '').trim() || '문제 설명이 누락되었습니다.'; | |
| const reason = String(comment.reason || '').trim() || '근거가 누락되었습니다.'; | |
| const suggestion = String(comment.suggestion || '').trim(); | |
| const lines = [ | |
| `${sev.icon} [${sev.label}]`, | |
| '', | |
| `내용: ${issue}`, | |
| `근거: ${reason}` | |
| ]; | |
| if (suggestion) { | |
| lines.push(`제안: ${suggestion}`); | |
| } | |
| return lines.join('\n'); | |
| }; | |
| const comments = rawComments | |
| .filter(c => c.path && Number.isFinite(Number(c.line))) | |
| .filter(c => { | |
| // 노이즈/무한 코멘트 방지를 위해 high 이상만 게시 | |
| const sev = normalizeSeverity(c.severity); | |
| return sev === 'critical' || sev === 'high'; | |
| }) | |
| .map(c => ({ | |
| path: c.path, | |
| line: Number(c.line), | |
| severity: c.severity, | |
| issue: c.issue, | |
| reason: c.reason, | |
| suggestion: c.suggestion, | |
| body: buildBody(c) | |
| })) | |
| .slice(0, 3); | |
| const resolveLine = (reviewComment) => Number(reviewComment.line ?? reviewComment.original_line ?? -1); | |
| const descByCreatedAt = (a, b) => new Date(b.created_at) - new Date(a.created_at); | |
| const now = Date.now(); | |
| // 너무 오래된 스레드는 재사용하지 않음 | |
| const replyWindowMs = 1000 * 60 * 60 * 24 * 14; | |
| const isWithinReplyWindow = (rc) => { | |
| const createdAt = new Date(rc.created_at).getTime(); | |
| return Number.isFinite(createdAt) && now - createdAt <= replyWindowMs; | |
| }; | |
| let existingReviewComments = []; | |
| try { | |
| existingReviewComments = await github.paginate(github.rest.pulls.listReviewComments, { | |
| owner, | |
| repo, | |
| pull_number, | |
| per_page: 100 | |
| }); | |
| } catch (e) { | |
| console.log('Failed to list existing review comments'); | |
| console.log(e); | |
| } | |
| const isReplyTarget = (rc, path, line) => { | |
| if (rc.path !== path || resolveLine(rc) !== line) return false; | |
| if (rc.in_reply_to_id) return false; | |
| if (!rc.user || rc.user.login !== botLogin) return false; | |
| // outdated comment(position=null)는 새 인라인으로 분기 | |
| if (Object.prototype.hasOwnProperty.call(rc, 'position') && rc.position === null) { | |
| return false; | |
| } | |
| // 오래된 스레드는 재활용하지 않음 | |
| if (!isWithinReplyWindow(rc)) { | |
| return false; | |
| } | |
| // 가능하면 현재 head commit에 연결된 코멘트만 스레드 재사용 | |
| const hasCommitInfo = Boolean(rc.commit_id || rc.original_commit_id); | |
| if (hasCommitInfo && rc.commit_id !== commit_id && rc.original_commit_id !== commit_id) { | |
| return false; | |
| } | |
| return true; | |
| }; | |
| const findReplyTarget = (path, line) => { | |
| return existingReviewComments | |
| .filter(rc => isReplyTarget(rc, path, line)) | |
| .sort(descByCreatedAt)[0]; | |
| }; | |
| const hasSameReply = (parentId, body) => { | |
| const normalizedBody = String(body || '').trim(); | |
| return existingReviewComments.some(rc => ( | |
| rc.in_reply_to_id === parentId && | |
| rc.user && | |
| rc.user.login === botLogin && | |
| String(rc.body || '').trim() === normalizedBody | |
| )); | |
| }; | |
| const hasExistingTopLevelAtLocation = (path, line) => { | |
| return existingReviewComments.some(rc => ( | |
| !rc.in_reply_to_id && | |
| rc.user && | |
| rc.user.login === botLogin && | |
| rc.path === path && | |
| resolveLine(rc) === line && | |
| isWithinReplyWindow(rc) && | |
| !(Object.prototype.hasOwnProperty.call(rc, 'position') && rc.position === null) | |
| )); | |
| }; | |
| let created = 0; | |
| let replied = 0; | |
| let skipped = 0; | |
| let failed = 0; | |
| for (const c of comments) { | |
| // 동일 위치에 활성 스레드가 이미 있으면 새 코멘트/리플을 만들지 않는다. | |
| // (동일 지적이 synchronize마다 누적되는 현상 방지) | |
| if (hasExistingTopLevelAtLocation(c.path, c.line)) { | |
| skipped += 1; | |
| continue; | |
| } | |
| const target = findReplyTarget(c.path, c.line); | |
| if (target) { | |
| if (hasSameReply(target.id, c.body)) { | |
| // 완전 동일 답글은 중복 생성하지 않음 | |
| skipped += 1; | |
| continue; | |
| } | |
| try { | |
| const replyRes = await github.rest.pulls.createReplyForReviewComment({ | |
| owner, | |
| repo, | |
| pull_number, | |
| comment_id: target.id, | |
| body: c.body | |
| }); | |
| replied += 1; | |
| if (replyRes && replyRes.data) { | |
| existingReviewComments.push(replyRes.data); | |
| } | |
| continue; | |
| } catch (e) { | |
| // 리플 실패 시에도 리뷰는 계속 진행(새 인라인으로 폴백) | |
| console.log(`Reply failed for thread ${target.id} (${c.path}:${c.line}), fallback to new inline comment`); | |
| console.log(e); | |
| } | |
| } | |
| try { | |
| const createRes = await github.rest.pulls.createReviewComment({ | |
| owner, | |
| repo, | |
| pull_number, | |
| commit_id, | |
| path: c.path, | |
| line: c.line, | |
| side: 'RIGHT', | |
| body: c.body | |
| }); | |
| created += 1; | |
| if (createRes && createRes.data) { | |
| existingReviewComments.push(createRes.data); | |
| } | |
| } catch (e) { | |
| failed += 1; | |
| console.log(`::warning::Inline comment create failed for ${c.path}:${c.line}`); | |
| console.log(`Inline comment failed: ${c.path}:${c.line}`); | |
| console.log(e); | |
| } | |
| } | |
| const status = String((review.meta && review.meta.status) || 'ok'); | |
| const modelUsed = String((review.meta && review.meta.model_used) || 'unknown'); | |
| const reviewWarnings = Array.isArray(review.meta && review.meta.warnings) ? review.meta.warnings : []; | |
| if (status === 'fallback') { | |
| console.log('::warning::AI review step ended in fallback mode.'); | |
| } | |
| if (failed > 0) { | |
| console.log(`::warning::Inline comment failure count=${failed}`); | |
| } | |
| console.log(`AI summary: ${String(review.summary || '요약 없음')}`); | |
| console.log(`Inline comments created=${created}, replied=${replied}, skipped=${skipped}, failed=${failed}`); | |
| console.log(`AI review status=${status}, model=${modelUsed}`); | |
| if (reviewWarnings.length > 0) { | |
| console.log(`AI review warnings=${reviewWarnings.join(' | ')}`); | |
| } | |
| // PR 요약 코멘트는 남기지 않고, Actions Summary로만 가시성 제공 | |
| if (process.env.GITHUB_STEP_SUMMARY) { | |
| const lines = [ | |
| '### AI Review Bot', | |
| `- status: ${status}`, | |
| `- model: ${modelUsed}`, | |
| `- inline created: ${created}`, | |
| `- inline replied: ${replied}`, | |
| `- inline skipped: ${skipped}`, | |
| `- inline failed: ${failed}` | |
| ]; | |
| if (reviewWarnings.length > 0) { | |
| lines.push(`- warnings: ${reviewWarnings.join(' | ')}`); | |
| } | |
| fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`); | |
| } | |
| reply_to_thread: | |
| # 사용자가 리뷰봇 인라인 코멘트 스레드에 단 리플에만 응답 | |
| if: github.event_name == 'pull_request_review_comment' && github.event.pull_request.state == 'open' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository && github.event.comment.in_reply_to_id != null | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Reply to review thread with OpenAI | |
| continue-on-error: true | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BOT_LOGIN: ${{ env.REVIEW_BOT_LOGIN }} | |
| MAX_PARENT_BODY_CHARS: ${{ env.REVIEW_BOT_MAX_PARENT_BODY_CHARS }} | |
| MAX_USER_BODY_CHARS: ${{ env.REVIEW_BOT_MAX_USER_BODY_CHARS }} | |
| OWNER: ${{ github.repository_owner }} | |
| REPO: ${{ github.event.repository.name }} | |
| PULL_NUMBER: ${{ github.event.pull_request.number }} | |
| COMMENT_ID: ${{ github.event.comment.id }} | |
| PARENT_COMMENT_ID: ${{ github.event.comment.in_reply_to_id }} | |
| USER_LOGIN: ${{ github.event.comment.user.login }} | |
| USER_BODY: ${{ github.event.comment.body }} | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import time | |
| from urllib.error import HTTPError, URLError | |
| from urllib.parse import quote | |
| from urllib.request import Request, urlopen | |
| owner = os.getenv("OWNER", "") | |
| repo = os.getenv("REPO", "") | |
| pull_number = os.getenv("PULL_NUMBER", "") | |
| comment_id = os.getenv("COMMENT_ID", "") | |
| parent_comment_id = os.getenv("PARENT_COMMENT_ID", "") | |
| user_login = os.getenv("USER_LOGIN", "reviewer") | |
| user_body = os.getenv("USER_BODY", "").strip() | |
| gh_token = os.getenv("GH_TOKEN", "") | |
| openai_api_key = os.getenv("OPENAI_API_KEY", "").strip() | |
| bot_login = os.getenv("BOT_LOGIN", "github-actions[bot]") | |
| max_parent_body_chars = int(os.getenv("MAX_PARENT_BODY_CHARS", "4000")) | |
| max_user_body_chars = int(os.getenv("MAX_USER_BODY_CHARS", "4000")) | |
| step_summary_path = os.getenv("GITHUB_STEP_SUMMARY", "") | |
| def warn(message): | |
| normalized = " ".join(str(message).split()) | |
| print(f"::warning::{normalized}") | |
| def gh_request(url, method="GET", body=None): | |
| req = Request( | |
| url, | |
| method=method, | |
| headers={ | |
| "Accept": "application/vnd.github+json", | |
| "Authorization": f"Bearer {gh_token}", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| }, | |
| ) | |
| if body is not None: | |
| raw = json.dumps(body).encode("utf-8") | |
| req.data = raw | |
| req.add_header("Content-Type", "application/json") | |
| with urlopen(req, timeout=30) as res: | |
| return json.loads(res.read().decode("utf-8")) | |
| def gh_paginated(url): | |
| # review comment는 페이지네이션 필수 (긴 PR 중복응답 방지) | |
| items = [] | |
| page = 1 | |
| while True: | |
| separator = "&" if "?" in url else "?" | |
| page_url = f"{url}{separator}per_page=100&page={page}" | |
| data = gh_request(page_url) | |
| if not isinstance(data, list) or not data: | |
| break | |
| items.extend(data) | |
| if len(data) < 100: | |
| break | |
| page += 1 | |
| if page > 20: | |
| break | |
| return items | |
| def clip_text(text, max_chars): | |
| # 과도한 본문 길이로 인한 비용/지연 방지 | |
| value = str(text or "").strip() | |
| if len(value) <= max_chars: | |
| return value | |
| return value[:max_chars] | |
| def append_summary(lines): | |
| if not step_summary_path: | |
| return | |
| try: | |
| with open(step_summary_path, "a", encoding="utf-8") as fp: | |
| fp.write("\n".join(lines) + "\n") | |
| except Exception: | |
| pass | |
| if not gh_token: | |
| warn("GH_TOKEN missing; skip thread reply") | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| "- reason: GH_TOKEN missing", | |
| ]) | |
| raise SystemExit(0) | |
| if not (owner and repo and pull_number and comment_id and parent_comment_id): | |
| warn("Required GitHub event fields are missing; skip thread reply") | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| "- reason: missing event fields", | |
| ]) | |
| raise SystemExit(0) | |
| if user_login == bot_login: | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| "- reason: commenter is bot", | |
| ]) | |
| raise SystemExit(0) | |
| # "수정 완료/해결" 성격의 확인 댓글에는 봇이 추가 답글을 달지 않는다. | |
| user_body_lower = user_body.lower() | |
| resolved_markers = [ | |
| "resolved", | |
| "fixed", | |
| "done", | |
| "commit", | |
| "정정", | |
| "수정완료", | |
| "수정 완료", | |
| "수정했습니다", | |
| "수정 했습니다", | |
| "수정반영", | |
| "수정 반영", | |
| "반영완료", | |
| "반영 완료", | |
| "반영했습니다", | |
| "반영 했습니다", | |
| "적용완료", | |
| "적용 완료", | |
| "해결완료", | |
| "해결 완료", | |
| "처리완료", | |
| "처리 완료", | |
| "관련 커밋", | |
| ] | |
| if any(marker in user_body_lower for marker in resolved_markers): | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| "- reason: user marked thread as resolved", | |
| ]) | |
| raise SystemExit(0) | |
| # parent가 봇 코멘트가 아닐 경우 응답하지 않음 | |
| parent_url = f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/pulls/comments/{quote(parent_comment_id)}" | |
| try: | |
| parent = gh_request(parent_url) | |
| except Exception as e: | |
| warn(f"Failed to fetch parent comment: {e}") | |
| raise SystemExit(0) | |
| parent_login = ((parent.get("user") or {}).get("login") or "").strip() | |
| if parent_login != bot_login: | |
| print(f"Parent comment author is not {bot_login}: {parent_login}") | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| f"- reason: parent author is {parent_login}", | |
| ]) | |
| raise SystemExit(0) | |
| # 같은 사용자 리플에 대해 봇이 이미 답글을 단 경우 중복 응답 방지 | |
| try: | |
| all_comments = gh_paginated( | |
| f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/pulls/{quote(pull_number)}/comments" | |
| ) | |
| except Exception as e: | |
| all_comments = [] | |
| warn(f"Failed to list review comments: {e}") | |
| already_replied = False | |
| if isinstance(all_comments, list): | |
| for c in all_comments: | |
| author = ((c.get("user") or {}).get("login") or "").strip() | |
| in_reply_to_id = c.get("in_reply_to_id") | |
| if author == bot_login and str(in_reply_to_id) == str(comment_id): | |
| already_replied = True | |
| break | |
| if already_replied: | |
| print("Bot already replied to this user comment; skip") | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: skipped", | |
| "- reason: already replied to this user comment", | |
| ]) | |
| raise SystemExit(0) | |
| # OpenAI로 스레드 답글 생성 | |
| response_text = "" | |
| if not openai_api_key: | |
| warn("OPENAI_API_KEY missing in reply_to_thread; use fallback reply message") | |
| response_text = ( | |
| f"@{user_login} 코멘트 확인했습니다. " | |
| "현재 자동 응답 설정 문제로 상세 답변 생성에 실패했습니다. " | |
| "필요 시 사람이 수동으로 확인해 주세요." | |
| ) | |
| else: | |
| parent_body = clip_text(parent.get("body"), max_parent_body_chars) | |
| path = parent.get("path") or "" | |
| line = parent.get("line") or parent.get("original_line") or "" | |
| user_body_prompt = clip_text(user_body, max_user_body_chars) | |
| prompt = f""" | |
| 너는 코드 변경을 수행하지 않는 GitHub AI 리뷰봇이다. | |
| 아래 정보를 바탕으로 사용자 질문/의견에 한국어로 답하라. | |
| 답변은 2~4문장, 불필요한 반복 금지, 코드리뷰 문맥에만 집중. | |
| 규칙: | |
| 1) 절대 실행 약속을 하지 마라. | |
| - 금지 예시: "수정하겠습니다", "반영하겠습니다", "처리하겠습니다", "적용하겠습니다" | |
| 2) 절대 선택/승인 질문으로 끝내지 마라. | |
| - 금지 예시: "이렇게 할까요?", "원하시나요?" | |
| 3) 역할은 "설명/근거 제시"에 한정한다. 작업 계획 제안, 진행 의사 표현 금지. | |
| 4) 1인칭 실행 주체("제가", "해드리겠습니다") 사용 금지. | |
| 5) 확실하지 않으면 단정 대신 불확실성만 짧게 명시한다. | |
| 6) 마크다운 코드블록은 사용하지 마라. | |
| 7) 답변은 반드시 존댓말(~요/~습니다)로 작성하고 반말은 금지한다. | |
| 파일: {path} | |
| 라인: {line} | |
| 기존 봇 코멘트: | |
| {parent_body} | |
| 사용자({user_login}) 리플: | |
| {user_body_prompt} | |
| """ | |
| models_to_try = ["gpt-5.3-mini", "gpt-5-mini"] | |
| request_timeout_sec = 90 | |
| max_retries = 3 | |
| retry_backoff_sec = 2 | |
| result = None | |
| last_error = None | |
| for model_name in models_to_try: | |
| for attempt in range(1, max_retries + 1): | |
| body = {"model": model_name, "input": prompt} | |
| req = Request( | |
| "https://api.openai.com/v1/responses", | |
| data=json.dumps(body).encode("utf-8"), | |
| headers={ | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {openai_api_key}", | |
| }, | |
| method="POST", | |
| ) | |
| try: | |
| with urlopen(req, timeout=request_timeout_sec) as res: | |
| result = json.loads(res.read().decode("utf-8")) | |
| print(f"Reply model used: {model_name} (attempt {attempt})") | |
| break | |
| except HTTPError as e: | |
| detail = "" | |
| try: | |
| detail = e.read().decode("utf-8", errors="ignore") | |
| except Exception: | |
| pass | |
| last_error = f"{e} {detail}".strip() | |
| if e.code == 400: | |
| warn(f"Reply model={model_name} HTTP 400, fallback to next model") | |
| print(f"Reply model={model_name} HTTP 400, skip retries for this model") | |
| break | |
| except (URLError, TimeoutError, OSError, ValueError) as e: | |
| last_error = str(e) | |
| if attempt < max_retries: | |
| sleep_sec = retry_backoff_sec ** (attempt - 1) | |
| print(f"Reply generation failed model={model_name} attempt={attempt}: {last_error}. Retry in {sleep_sec}s") | |
| time.sleep(sleep_sec) | |
| else: | |
| print(f"Reply generation failed model={model_name} after {attempt} attempts: {last_error}") | |
| if result is not None: | |
| break | |
| if result is None: | |
| warn(f"Reply generation failed for all models: {last_error}") | |
| response_text = ( | |
| f"@{user_login} 코멘트 확인했습니다. " | |
| "지금은 자동 답변 생성이 일시적으로 실패해 상세 답변을 남기지 못했습니다. " | |
| "필요 시 사람이 수동으로 확인해 주세요." | |
| ) | |
| else: | |
| parts = [] | |
| for item in result.get("output", []): | |
| for content in item.get("content", []): | |
| if content.get("type") == "output_text": | |
| parts.append(content.get("text", "")) | |
| response_text = "\n".join(parts).strip() | |
| if not response_text: | |
| response_text = str(result.get("output_text", "")).strip() | |
| if not response_text: | |
| response_text = ( | |
| f"@{user_login} 의견 감사합니다. " | |
| "자동 응답 출력이 비어 있어 구체 답변을 생성하지 못했습니다." | |
| ) | |
| # 답글 톤 가드: 실행 약속/승인 질문 어투를 중립 설명형으로 강제 | |
| banned_phrases = [ | |
| "수정하겠습니다", "반영하겠습니다", "처리하겠습니다", "적용하겠습니다", | |
| "해드리겠습니다", "제가", "원하시나요", "할까요", "하시겠어요", | |
| "진행할까요", "도와드릴까요" | |
| ] | |
| if any(token in response_text for token in banned_phrases): | |
| response_text = ( | |
| f"@{user_login} 코멘트 감사합니다. " | |
| "이 답글은 코드 변경을 수행하지 않는 리뷰봇 설명 응답입니다. " | |
| "기존 코멘트의 의도와 근거를 중심으로만 안내합니다." | |
| ) | |
| # 존댓말 강제: 반말 패턴이 감지되거나 존댓말 표지가 없으면 안전 문구로 교체 | |
| informal_markers = [ | |
| "맞아", "아냐", "해줘", "해봐", "할게", "볼래", "어때", "그냥", "거든", "했어" | |
| ] | |
| polite_markers = ["요", "습니다", "드립니다", "해주세요", "바랍니다"] | |
| if any(token in response_text for token in informal_markers): | |
| response_text = ( | |
| f"@{user_login} 코멘트 감사합니다. " | |
| "리뷰봇은 설명과 근거 안내만 제공하며, 해당 내용은 존댓말 기준으로 안내드립니다." | |
| ) | |
| elif not any(token in response_text for token in polite_markers): | |
| response_text = ( | |
| f"@{user_login} 코멘트 감사합니다. " | |
| "리뷰봇 응답 톤 기준에 맞춰 존댓말로만 안내드립니다." | |
| ) | |
| # 댓글 글자수 안전장치 (GitHub body 최대치 근접 보호) | |
| response_text = response_text.strip() | |
| if response_text.endswith("?"): | |
| response_text = response_text[:-1].rstrip() + "." | |
| if len(response_text) > 60000: | |
| response_text = response_text[:60000] | |
| # 사용자 리플(comment_id)에 답글 시도 -> 실패 시 부모 코멘트(parent_comment_id)에 폴백 | |
| target_ids = [comment_id, parent_comment_id] | |
| posted = False | |
| last_post_error = None | |
| for target_id in target_ids: | |
| post_url = ( | |
| f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/pulls/" | |
| f"{quote(pull_number)}/comments/{quote(str(target_id))}/replies" | |
| ) | |
| try: | |
| gh_request(post_url, method="POST", body={"body": response_text}) | |
| posted = True | |
| print(f"Reply posted to comment_id={target_id}") | |
| break | |
| except Exception as e: | |
| last_post_error = e | |
| print(f"Reply post failed for comment_id={target_id}: {e}") | |
| if not posted: | |
| warn(f"Failed to post thread reply: {last_post_error}") | |
| print(f"Failed to post reply after fallback: {last_post_error}") | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: failed", | |
| f"- parent_comment_id: {parent_comment_id}", | |
| f"- user_comment_id: {comment_id}", | |
| ]) | |
| else: | |
| append_summary([ | |
| "### AI Review Thread Reply", | |
| "- status: replied", | |
| f"- parent_comment_id: {parent_comment_id}", | |
| f"- user_comment_id: {comment_id}", | |
| ]) | |
| raise SystemExit(0) | |
| PY |