From 92c2e2809312880c46ad5c9b5d187961fbd2f502 Mon Sep 17 00:00:00 2001 From: "lkh14011424@gmail.com" Date: Thu, 9 Apr 2026 23:38:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat/#8]=20=EC=8B=9C=EA=B0=84=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EB=B3=B5=EC=9E=A1=EB=8F=84=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(subrequest=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/complexity-analysis.js | 411 ++++++++++++++++++++ handlers/webhooks.js | 18 + plan.md | 652 ++++++++++++++++++++++++++++++++ research.md | 248 ++++++++++++ 4 files changed, 1329 insertions(+) create mode 100644 handlers/complexity-analysis.js create mode 100644 plan.md create mode 100644 research.md diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js new file mode 100644 index 0000000..a302106 --- /dev/null +++ b/handlers/complexity-analysis.js @@ -0,0 +1,411 @@ +/** + * 시간/공간 복잡도 자동 분석. + * PR opened/reopened/synchronize 시 호출된다. + * + * 모든 로직(상수, OpenAI 호출, 댓글 포맷, upsert)을 이 파일에 응집한다. + */ + +import { getGitHubHeaders } from "../utils/github.js"; +import { hasMaintenanceLabel } from "../utils/validation.js"; + +// ── 상수 ────────────────────────────────────────── + +const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; +const COMPLEXITY_COMMENT_MARKER = ""; +const MAX_FILE_SIZE = 15000; +const MAX_TOTAL_SIZE = 60000; +const FILE_DELIMITER = "====="; + +// ── OpenAI 호출 ─────────────────────────────────── + +const SYSTEM_PROMPT = `당신은 알고리즘 풀이의 시간/공간 복잡도를 분석하는 전문가입니다. + +여러 문제의 솔루션 코드가 구분자(===== {문제명} =====)로 나뉘어 제공됩니다. +각 문제별로 독립적으로 분석하세요. + +하나의 문제 안에 같은 문제를 여러 가지 방식으로 푼 풀이가 포함될 수 있습니다. +각 문제마다 코드에서 독립된 풀이가 몇 개인지 판별하세요. (함수/클래스/메서드 단위로 구분) + +각 풀이에 대해: +1. name: 함수명 또는 식별 가능한 이름 (예: "twoSum_bruteForce", "Solution.maxArea") +2. description: 접근 방식 한 줄 설명 (예: "이진 탐색", "HashMap 활용") +3. 코드의 실제 시간/공간 복잡도를 Big-O 표기로 계산 (actualTime, actualSpace). +4. 해당 풀이 바로 위/근처에 사용자가 남긴 시간복잡도/공간복잡도 주석을 찾으세요. + 주석은 자유 포맷이며 언어별 주석 스타일(//, #, /* */, --, """)과 한/영 키워드가 섞일 수 있습니다. + 예: "// TC: O(n)", "# 시간복잡도: O(n log n)", "/* Space: O(1) */", "// Time: O(n^2)" + - 찾았으면 hasUserAnnotation=true, userTime/userSpace에 사용자 값 그대로. + - 한쪽만 적혀 있으면 다른 쪽은 null. + - 전혀 없으면 hasUserAnnotation=false, userTime=null, userSpace=null. +5. matches.time / matches.space: + - hasUserAnnotation=false면 둘 다 false. + - 사용자 값이 있는 항목만 actual과 비교하여 일치 여부를 boolean으로 반환. +6. feedback (한국어 1-3문장): + - 일치하면: 칭찬 + 핵심 근거 짧게. + - 불일치하면: 어디가 왜 다른지 설명 + "다시 분석해보시는 것을 권장드립니다" 톤. + - 주석이 없으면: 풀이 핵심 근거만 설명. +7. suggestion (한국어, 항상 string): + - 의미 있는 한 단계 이상 개선 여지가 있을 때만 제안 (예: O(n^2) → O(n)). + - 문제 제약을 모를 수 있으므로 단정 금지. "고려해볼 만한 대안:" 톤. + - 개선 여지 없으면 "현재 구현이 적절해 보입니다." + +반드시 아래 JSON 스키마로만 응답: +{ + "files": [ + { + "problemName": string, + "solutions": [ + { + "name": string, + "description": string, + "hasUserAnnotation": boolean, + "userTime": string|null, + "userSpace": string|null, + "actualTime": string, + "actualSpace": string, + "matches": { "time": boolean, "space": boolean }, + "feedback": string, + "suggestion": string + } + ] + } + ] +}`; + +async function callComplexityAnalysis(fileEntries, apiKey) { + const userPrompt = fileEntries + .map( + (f) => + `${FILE_DELIMITER} ${f.problemName} ${FILE_DELIMITER}\n\`\`\`\n${f.content}\n\`\`\`` + ) + .join("\n\n"); + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4.1-nano", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_tokens: 4000, + temperature: 0.2, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${error}`); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + if (!content) throw new Error("Empty response from OpenAI"); + + let parsed; + try { + parsed = JSON.parse(content); + } catch { + throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`); + } + + const files = Array.isArray(parsed.files) ? parsed.files : []; + + return files.map((file) => ({ + problemName: + typeof file.problemName === "string" ? file.problemName : "unknown", + solutions: (Array.isArray(file.solutions) ? file.solutions : []).map( + (s) => ({ + name: typeof s.name === "string" ? s.name : "unknown", + description: typeof s.description === "string" ? s.description : "", + hasUserAnnotation: s.hasUserAnnotation === true, + userTime: typeof s.userTime === "string" ? s.userTime : null, + userSpace: typeof s.userSpace === "string" ? s.userSpace : null, + actualTime: typeof s.actualTime === "string" ? s.actualTime : "?", + actualSpace: typeof s.actualSpace === "string" ? s.actualSpace : "?", + matches: { + time: s.matches?.time === true, + space: s.matches?.space === true, + }, + feedback: typeof s.feedback === "string" ? s.feedback : "", + suggestion: typeof s.suggestion === "string" ? s.suggestion : "", + }) + ), + })); +} + +// ── 댓글 포맷터 ────────────────────────────────── + +function buildSummaryResult(solution) { + if (!solution.hasUserAnnotation) { + return `Time: ${solution.actualTime} / Space: ${solution.actualSpace}`; + } + const timePart = solution.userTime + ? `Time: ${solution.matches.time ? "✅" : "❌"} ${solution.userTime} → ${solution.actualTime}` + : `Time: ${solution.actualTime}`; + const spacePart = solution.userSpace + ? `Space: ${solution.matches.space ? "✅" : "❌"} ${solution.userSpace} → ${solution.actualSpace}` + : `Space: ${solution.actualSpace}`; + return `${timePart} / ${spacePart}`; +} + +function buildSolutionBody(solution) { + const lines = []; + + if (solution.hasUserAnnotation) { + const timeMark = solution.matches.time ? "✅" : "❌"; + const spaceMark = solution.matches.space ? "✅" : "❌"; + lines.push("| | 유저 분석 | 실제 분석 | 결과 |"); + lines.push("|---|---|---|---|"); + lines.push( + `| **Time** | ${solution.userTime ?? "-"} | ${solution.actualTime} | ${solution.userTime ? timeMark : "-"} |` + ); + lines.push( + `| **Space** | ${solution.userSpace ?? "-"} | ${solution.actualSpace} | ${solution.userSpace ? spaceMark : "-"} |` + ); + } else { + lines.push("| | 복잡도 |"); + lines.push("|---|---|"); + lines.push(`| **Time** | ${solution.actualTime} |`); + lines.push(`| **Space** | ${solution.actualSpace} |`); + } + + lines.push(""); + if (solution.feedback) { + lines.push(`**피드백**: ${solution.feedback}`); + lines.push(""); + } + if (solution.suggestion) { + lines.push(`**개선 제안**: ${solution.suggestion}`); + lines.push(""); + } + + return lines; +} + +function formatComplexityCommentBody(entries) { + const lines = []; + lines.push(COMPLEXITY_COMMENT_MARKER); + lines.push("### 📊 시간/공간 복잡도 분석"); + lines.push(""); + + for (const { problemName, solutions } of entries) { + lines.push(`### ${problemName}`); + lines.push(""); + + if (!solutions || solutions.length === 0) { + lines.push(`> ⚠️ 분석 결과가 없습니다.`); + lines.push(""); + continue; + } + + const isMulti = solutions.length > 1; + const hasAnyAnnotationMissing = solutions.some( + (s) => !s.hasUserAnnotation + ); + + if (isMulti) { + lines.push( + `> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.` + ); + lines.push(""); + + solutions.forEach((sol, idx) => { + const summaryResult = buildSummaryResult(sol); + lines.push(`
`); + lines.push( + `풀이 ${idx + 1}: ${sol.name} — ${summaryResult}` + ); + lines.push(""); + lines.push(...buildSolutionBody(sol)); + lines.push(`
`); + lines.push(""); + }); + } else { + lines.push(...buildSolutionBody(solutions[0])); + } + + if (hasAnyAnnotationMissing) { + lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); + lines.push(""); + } + } + + lines.push("---"); + lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다."); + + return lines.join("\n") + "\n"; +} + +// ── 댓글 upsert ────────────────────────────────── + +async function upsertComplexityComment( + repoOwner, + repoName, + prNumber, + body, + appToken +) { + const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`; + + const listResponse = await fetch( + `${baseUrl}/issues/${prNumber}/comments?per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + if (!listResponse.ok) { + throw new Error( + `Failed to list comments: ${listResponse.status} ${listResponse.statusText}` + ); + } + + const comments = await listResponse.json(); + const existing = comments.find( + (c) => + c.user?.type === "Bot" && + c.body?.includes(COMPLEXITY_COMMENT_MARKER) + ); + + const headers = { + ...getGitHubHeaders(appToken), + "Content-Type": "application/json", + }; + + if (existing) { + const res = await fetch(`${baseUrl}/issues/comments/${existing.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error( + `Failed to update complexity comment ${existing.id}: ${res.status}` + ); + } + console.log( + `[complexity] Updated comment ${existing.id} on PR #${prNumber}` + ); + } else { + const res = await fetch(`${baseUrl}/issues/${prNumber}/comments`, { + method: "POST", + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`Failed to post complexity comment: ${res.status}`); + } + console.log(`[complexity] Created complexity comment on PR #${prNumber}`); + } +} + +// ── 오케스트레이션 (export) ─────────────────────── + +export async function analyzeComplexity( + repoOwner, + repoName, + prNumber, + prData, + appToken, + openaiApiKey +) { + if (prData.draft === true) { + console.log(`[complexity] Skipping PR #${prNumber}: draft`); + return { skipped: "draft" }; + } + const labels = (prData.labels || []).map((l) => l.name); + if (hasMaintenanceLabel(labels)) { + console.log(`[complexity] Skipping PR #${prNumber}: maintenance`); + return { skipped: "maintenance" }; + } + + // 1) PR files + const filesRes = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + if (!filesRes.ok) { + throw new Error( + `Failed to list PR files: ${filesRes.status} ${filesRes.statusText}` + ); + } + const allFiles = await filesRes.json(); + + const solutionFiles = allFiles.filter( + (f) => + (f.status === "added" || f.status === "modified") && + SOLUTION_PATH_REGEX.test(f.filename) + ); + + console.log( + `[complexity] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solutions` + ); + + if (solutionFiles.length === 0) { + return { skipped: "no-solution-files" }; + } + + // 2) 모든 솔루션 파일 다운로드 + const fileEntries = []; + let totalSize = 0; + + for (const file of solutionFiles) { + const problemName = file.filename.split("/")[0]; + try { + const rawRes = await fetch(file.raw_url); + if (!rawRes.ok) { + console.error( + `[complexity] Failed to fetch ${file.filename}: ${rawRes.status}` + ); + continue; + } + let content = await rawRes.text(); + if (content.length > MAX_FILE_SIZE) { + content = content.slice(0, MAX_FILE_SIZE); + } + + if (totalSize + content.length > MAX_TOTAL_SIZE) { + console.log( + `[complexity] Reached MAX_TOTAL_SIZE, skipping remaining files` + ); + break; + } + + totalSize += content.length; + fileEntries.push({ problemName, content }); + } catch (error) { + console.error( + `[complexity] Failed to download ${file.filename}: ${error.message}` + ); + } + } + + if (fileEntries.length === 0) { + return { skipped: "all-downloads-failed" }; + } + + // 3) OpenAI 1회 호출로 모든 파일 분석 + const analysisResults = await callComplexityAnalysis( + fileEntries, + openaiApiKey + ); + + // 4) 결과를 fileEntries 순서에 맞춰 매핑 + const entries = fileEntries.map((fe) => { + const match = analysisResults.find( + (r) => r.problemName === fe.problemName + ); + return match || { problemName: fe.problemName, solutions: [] }; + }); + + // 5) 본문 빌드 + upsert + const body = formatComplexityCommentBody(entries); + await upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken); + + return { + analyzed: entries.filter((e) => e.solutions.length > 0).length, + total: fileEntries.length, + }; +} diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 4c67cff..dbbd722 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -23,6 +23,7 @@ import { performAIReview, addReactionToComment } from "../utils/prReview.js"; import { hasApprovedReview, safeJson } from "../utils/prActions.js"; import { tagPatterns } from "./tag-patterns.js"; import { postLearningStatus } from "./learning-status.js"; +import { analyzeComplexity } from "./complexity-analysis.js"; /** * GitHub webhook 이벤트 처리 @@ -288,6 +289,23 @@ async function handlePullRequestEvent(payload, env) { } } + // 시간/공간 복잡도 분석 (OPENAI_API_KEY 있을 때만) + if (env.OPENAI_API_KEY) { + try { + await analyzeComplexity( + repoOwner, + repoName, + prNumber, + pr, + appToken, + env.OPENAI_API_KEY + ); + } catch (error) { + console.error(`[handlePullRequestEvent] complexity analysis failed: ${error.message}`); + // 복잡도 분석 실패는 전체 흐름을 중단시키지 않음 + } + } + return corsResponse({ message: "Processed", pr: prNumber, diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..91f0d4d --- /dev/null +++ b/plan.md @@ -0,0 +1,652 @@ +# 시간/공간 복잡도 자동 분석 — 구현 계획 + +연관 이슈: [DaleStudy/github#8](https://github.com/DaleStudy/github/issues/8) +참고: [research.md](research.md) (단, §7 항목들은 본 작업 범위 외로 두고 건드리지 않음) + +--- + +## 1. 목표 & 스코프 + +PR `opened` / `reopened` / `synchronize` 시, 솔루션 파일들의 시간/공간 복잡도를 OpenAI로 분석하여 **PR에 단 하나의 issue 댓글**(upsert)을 작성한다. 댓글에는 PR에 포함된 모든 솔루션 파일이 섹션별로 누적된다. + +처리 케이스: + +**단일 풀이** +- **케이스 1**: 사용자가 TC/SC 주석을 달았고 분석 결과와 일치 → ✅ 비교 표 +- **케이스 2**: 사용자 주석이 있지만 불일치 → ❌ 비교 표 + 다시 풀이 권장 톤 피드백 +- **케이스 3**: 사용자 주석 없음 → 분석 결과만 + 주석 작성 권장 안내 + +**멀티 풀이** (하나의 솔루션 파일에 여러 접근법이 포함된 경우) +- **케이스 4**: 멀티 풀이 + 유저 주석 있음 → 풀이별 `
` 접기, summary에 결과(✅/❌) 표시 +- **케이스 5**: 멀티 풀이 + 유저 주석 없음 → 풀이별 `
` 접기, summary에 복잡도 표시 + +--- + +## 2. 아키텍처 결정 + +| 항목 | 결정 | 이유 | +|---|---|---| +| 진입점 | [handlers/webhooks.js](handlers/webhooks.js) `handlePullRequestEvent` 안에 등록 | 패턴 태깅/학습 현황과 동일한 위치 | +| 파일 구조 | **`handlers/complexity-analysis.js` 단일 파일** | 상수, OpenAI 호출, 댓글 포맷터, upsert 모두 한 파일에 응집. 다른 기능 파일에 영향 없음 | +| 댓글 종류 | **PR issue comment** (review comment 아님) | 솔루션이 여러 개여도 한 댓글에 합쳐서 보여줘야 하므로 | +| 댓글 식별 | HTML 마커 `` | learningComment.js 와 동일 패턴 | +| 작성 방식 | upsert (있으면 PATCH, 없으면 POST) | learningComment.js 의 `upsertLearningStatusComment` 와 동일 패턴 | +| OpenAI 호출 | **모든 솔루션 파일을 구분자로 합쳐 1회 호출** | 파일별 호출 대비 API 비용/latency 대폭 절감. Workers timeout 위험도 줄어듦 | +| OpenAI 모델 | `gpt-4.1-nano`, `response_format: json_object`, `temperature: 0.2` | openai.js 의 다른 분석 함수들과 동일 | +| webhooks 통합 | 기존 try/catch 패턴과 동일하게 추가 | 다른 기능 소스코드에 영향 없음 | +| Non-blocking | 핸들러 자체가 throw 안 함, 실패는 console.error만 | 기존 패턴 동일 | +| 주석 파싱 | OpenAI에게 위임 | 자유 포맷 + 다국어 + 다언어. 정규식으로는 신뢰성 낮음 | +| 멀티 풀이 감지 | OpenAI에게 위임 | 언어/구조가 다양해 정규식으로 함수 경계를 잡기 어려움 | +| 멀티 풀이 포맷 | `
` 접기 + summary에 결과 표시 | 댓글 길이 관리 + 접힌 상태에서도 핵심 정보 확인 가능 | + +> **단일 파일 구조의 이유**: 이 기능은 자체적으로 완결된 기능이고, 다른 핸들러와 공유할 유틸이 없다. `utils/` 에 분산시키면 오히려 코드 추적이 어려워지고, 다른 팀의 파일(`constants.js`, `openai.js`)에 변경이 생긴다. 한 파일에 모아두면 기능 전체를 한눈에 파악할 수 있고, 삭제/수정 시에도 한 곳만 건드리면 된다. + +> **OpenAI 1회 호출의 이유**: PR에 솔루션 파일이 5개 있으면 기존 설계는 OpenAI 호출 5회. 각 호출은 ~1-3초이므로 최대 15초. Workers timeout(10초) 위험이 크다. 모든 파일을 구분자(`===== {problemName} =====`)로 합쳐 1회 호출하면 latency는 1회분, 비용도 입출력 토큰 합산으로 비슷하거나 더 저렴하다. + +--- + +## 3. 파일 변경 목록 + +### 신규 +- `handlers/complexity-analysis.js` — 상수, OpenAI 호출, 댓글 포맷터, upsert, 오케스트레이션 **모두 포함** + +### 수정 +- `handlers/webhooks.js` — `handlePullRequestEvent`에 호출 추가 (기존 try/catch 패턴과 동일하게) + +### 건드리지 않음 +- `utils/openai.js` — 수정 없음 +- `utils/constants.js` — 수정 없음 +- `handlers/tag-patterns.js` — 다른 팀 스코프 +- `handlers/learning-status.js` — 기존 기능 +- index.js — 신규 엔드포인트 없음 +- 인증/Webhook 검증 흐름 + +--- + +## 4. 데이터 흐름 + +``` +pull_request (opened/reopened/synchronize) + │ + ▼ +handlePullRequestEvent ── (기존) Week 체크 + │ + ├── (기존) try { tagPatterns(...) } catch { ... } + ├── (기존) try { postLearningStatus(...) } catch { ... } + └── (신규) try { analyzeComplexity(...) } catch { ... } + │ + ▼ + analyzeComplexity + 1) GET /pulls/{n}/files?per_page=100 + 2) SOLUTION_PATH_REGEX 로 솔루션 파일 필터 + 3) 각 파일의 raw_url 다운로드 + trim + 4) 모든 파일을 구분자로 합쳐 OpenAI 1회 호출 + ┌─────────────────────────────┐ + │ ===== problem-a ===== │ + │ <코드 A> │ + │ │ + │ ===== problem-b ===== │ + │ <코드 B> │ + └─────────────────────────────┘ + ↓ (1회 API 호출) + ┌─────────────────────────────┐ + │ { "files": [ │ + │ { "problemName": "problem-a",│ + │ "solutions": [...] }, │ + │ { "problemName": "problem-b",│ + │ "solutions": [...] } │ + │ ]} │ + └─────────────────────────────┘ + 5) formatComplexityCommentBody(parsedResult) + - 풀이 1개: 기존 테이블 포맷 + - 풀이 2개+:
접기 포맷 + 6) upsertComplexityComment(prNumber, body) +``` + +--- + +## 5. 상세 구현 + +### 5.1 `handlers/complexity-analysis.js` (신규 — 단일 파일) + +이 파일 하나에 다음 요소를 모두 포함한다: + +1. **상수**: `SOLUTION_PATH_REGEX`, `COMPLEXITY_COMMENT_MARKER`, `MAX_FILE_SIZE`, `MAX_TOTAL_SIZE` +2. **OpenAI 호출**: `callComplexityAnalysis()` — 모든 솔루션을 한 번에 분석 +3. **댓글 포맷터**: `formatComplexityCommentBody()` — 단일/멀티 풀이 분기 +4. **댓글 upsert**: `upsertComplexityComment()` — 마커 기반 생성/수정 +5. **오케스트레이션**: `analyzeComplexity()` — export, webhooks.js에서 호출 + +```js +/** + * 시간/공간 복잡도 자동 분석. + * PR opened/reopened/synchronize 시 호출된다. + * + * 모든 로직(상수, OpenAI 호출, 댓글 포맷, upsert)을 이 파일에 응집한다. + */ + +import { getGitHubHeaders } from "../utils/github.js"; +import { hasMaintenanceLabel } from "../utils/validation.js"; + +// ── 상수 ────────────────────────────────────────── + +const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; +const COMPLEXITY_COMMENT_MARKER = ""; +const MAX_FILE_SIZE = 15000; +const MAX_TOTAL_SIZE = 60000; // 모든 파일 합산 최대 (OpenAI 입력 제한 고려) +const FILE_DELIMITER = "====="; + +// ── OpenAI 호출 ─────────────────────────────────── + +const SYSTEM_PROMPT = `당신은 알고리즘 풀이의 시간/공간 복잡도를 분석하는 전문가입니다. + +여러 문제의 솔루션 코드가 구분자(===== {문제명} =====)로 나뉘어 제공됩니다. +각 문제별로 독립적으로 분석하세요. + +하나의 문제 안에 같은 문제를 여러 가지 방식으로 푼 풀이가 포함될 수 있습니다. +각 문제마다 코드에서 독립된 풀이가 몇 개인지 판별하세요. (함수/클래스/메서드 단위로 구분) + +각 풀이에 대해: +1. name: 함수명 또는 식별 가능한 이름 (예: "twoSum_bruteForce", "Solution.maxArea") +2. description: 접근 방식 한 줄 설명 (예: "이진 탐색", "HashMap 활용") +3. 코드의 실제 시간/공간 복잡도를 Big-O 표기로 계산 (actualTime, actualSpace). +4. 해당 풀이 바로 위/근처에 사용자가 남긴 시간복잡도/공간복잡도 주석을 찾으세요. + 주석은 자유 포맷이며 언어별 주석 스타일(//, #, /* */, --, """)과 한/영 키워드가 섞일 수 있습니다. + 예: "// TC: O(n)", "# 시간복잡도: O(n log n)", "/* Space: O(1) */", "// Time: O(n^2)" + - 찾았으면 hasUserAnnotation=true, userTime/userSpace에 사용자 값 그대로. + - 한쪽만 적혀 있으면 다른 쪽은 null. + - 전혀 없으면 hasUserAnnotation=false, userTime=null, userSpace=null. +5. matches.time / matches.space: + - hasUserAnnotation=false면 둘 다 false. + - 사용자 값이 있는 항목만 actual과 비교하여 일치 여부를 boolean으로 반환. +6. feedback (한국어 1-3문장): + - 일치하면: 칭찬 + 핵심 근거 짧게. + - 불일치하면: 어디가 왜 다른지 설명 + "다시 분석해보시는 것을 권장드립니다" 톤. + - 주석이 없으면: 풀이 핵심 근거만 설명. +7. suggestion (한국어, 항상 string): + - 의미 있는 한 단계 이상 개선 여지가 있을 때만 제안 (예: O(n^2) → O(n)). + - 문제 제약을 모를 수 있으므로 단정 금지. "고려해볼 만한 대안:" 톤. + - 개선 여지 없으면 "현재 구현이 적절해 보입니다." + +반드시 아래 JSON 스키마로만 응답: +{ + "files": [ + { + "problemName": string, + "solutions": [ + { + "name": string, + "description": string, + "hasUserAnnotation": boolean, + "userTime": string|null, + "userSpace": string|null, + "actualTime": string, + "actualSpace": string, + "matches": { "time": boolean, "space": boolean }, + "feedback": string, + "suggestion": string + } + ] + } + ] +}`; + +/** + * 모든 솔루션 파일을 한 번의 OpenAI 호출로 분석한다. + * + * @param {Array<{ problemName: string, content: string }>} fileEntries + * @param {string} apiKey + * @returns {Promise }>>} + */ +async function callComplexityAnalysis(fileEntries, apiKey) { + // 구분자로 모든 파일을 하나의 프롬프트로 합침 + const userPrompt = fileEntries + .map((f) => `${FILE_DELIMITER} ${f.problemName} ${FILE_DELIMITER}\n\`\`\`\n${f.content}\n\`\`\``) + .join("\n\n"); + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4.1-nano", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_tokens: 4000, + temperature: 0.2, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${error}`); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + if (!content) throw new Error("Empty response from OpenAI"); + + let parsed; + try { + parsed = JSON.parse(content); + } catch { + throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`); + } + + const files = Array.isArray(parsed.files) ? parsed.files : []; + + return files.map((file) => ({ + problemName: typeof file.problemName === "string" ? file.problemName : "unknown", + solutions: (Array.isArray(file.solutions) ? file.solutions : []).map((s) => ({ + name: typeof s.name === "string" ? s.name : "unknown", + description: typeof s.description === "string" ? s.description : "", + hasUserAnnotation: s.hasUserAnnotation === true, + userTime: typeof s.userTime === "string" ? s.userTime : null, + userSpace: typeof s.userSpace === "string" ? s.userSpace : null, + actualTime: typeof s.actualTime === "string" ? s.actualTime : "?", + actualSpace: typeof s.actualSpace === "string" ? s.actualSpace : "?", + matches: { + time: s.matches?.time === true, + space: s.matches?.space === true, + }, + feedback: typeof s.feedback === "string" ? s.feedback : "", + suggestion: typeof s.suggestion === "string" ? s.suggestion : "", + })), + })); +} + +// ── 댓글 포맷터 ────────────────────────────────── + +function buildSummaryResult(solution) { + if (!solution.hasUserAnnotation) { + return `Time: ${solution.actualTime} / Space: ${solution.actualSpace}`; + } + const timePart = solution.userTime + ? `Time: ${solution.matches.time ? "✅" : "❌"} ${solution.userTime} → ${solution.actualTime}` + : `Time: ${solution.actualTime}`; + const spacePart = solution.userSpace + ? `Space: ${solution.matches.space ? "✅" : "❌"} ${solution.userSpace} → ${solution.actualSpace}` + : `Space: ${solution.actualSpace}`; + return `${timePart} / ${spacePart}`; +} + +function buildSolutionBody(solution) { + const lines = []; + + if (solution.hasUserAnnotation) { + const timeMark = solution.matches.time ? "✅" : "❌"; + const spaceMark = solution.matches.space ? "✅" : "❌"; + lines.push("| | 유저 분석 | 실제 분석 | 결과 |"); + lines.push("|---|---|---|---|"); + lines.push(`| **Time** | ${solution.userTime ?? "-"} | ${solution.actualTime} | ${solution.userTime ? timeMark : "-"} |`); + lines.push(`| **Space** | ${solution.userSpace ?? "-"} | ${solution.actualSpace} | ${solution.userSpace ? spaceMark : "-"} |`); + } else { + lines.push("| | 복잡도 |"); + lines.push("|---|---|"); + lines.push(`| **Time** | ${solution.actualTime} |`); + lines.push(`| **Space** | ${solution.actualSpace} |`); + } + + lines.push(""); + if (solution.feedback) { + lines.push(`**피드백**: ${solution.feedback}`); + lines.push(""); + } + if (solution.suggestion) { + lines.push(`**개선 제안**: ${solution.suggestion}`); + lines.push(""); + } + + return lines; +} + +/** + * @param {Array<{ problemName: string, solutions: Array }>} entries + * @returns {string} + */ +function formatComplexityCommentBody(entries) { + const lines = []; + lines.push(COMPLEXITY_COMMENT_MARKER); + lines.push("### 📊 시간/공간 복잡도 분석"); + lines.push(""); + + for (const { problemName, solutions } of entries) { + lines.push(`### ${problemName}`); + lines.push(""); + + if (!solutions || solutions.length === 0) { + lines.push(`> ⚠️ 분석 결과가 없습니다.`); + lines.push(""); + continue; + } + + const isMulti = solutions.length > 1; + const hasAnyAnnotationMissing = solutions.some((s) => !s.hasUserAnnotation); + + if (isMulti) { + lines.push(`> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.`); + lines.push(""); + + solutions.forEach((sol, idx) => { + const summaryResult = buildSummaryResult(sol); + lines.push(`
`); + lines.push(`풀이 ${idx + 1}: ${sol.name} — ${summaryResult}`); + lines.push(""); + lines.push(...buildSolutionBody(sol)); + lines.push(`
`); + lines.push(""); + }); + } else { + lines.push(...buildSolutionBody(solutions[0])); + } + + if (hasAnyAnnotationMissing) { + lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); + lines.push(""); + } + } + + lines.push("---"); + lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다."); + + return lines.join("\n") + "\n"; +} + +// ── 댓글 upsert ────────────────────────────────── + +async function upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken) { + const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`; + + const listResponse = await fetch( + `${baseUrl}/issues/${prNumber}/comments?per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + if (!listResponse.ok) { + throw new Error(`Failed to list comments: ${listResponse.status} ${listResponse.statusText}`); + } + + const comments = await listResponse.json(); + const existing = comments.find( + (c) => c.user?.type === "Bot" && c.body?.includes(COMPLEXITY_COMMENT_MARKER) + ); + + const headers = { + ...getGitHubHeaders(appToken), + "Content-Type": "application/json", + }; + + if (existing) { + const res = await fetch(`${baseUrl}/issues/comments/${existing.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`Failed to update complexity comment ${existing.id}: ${res.status}`); + } + console.log(`[complexity] Updated comment ${existing.id} on PR #${prNumber}`); + } else { + const res = await fetch(`${baseUrl}/issues/${prNumber}/comments`, { + method: "POST", + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`Failed to post complexity comment: ${res.status}`); + } + console.log(`[complexity] Created complexity comment on PR #${prNumber}`); + } +} + +// ── 오케스트레이션 (export) ─────────────────────── + +/** + * @param {string} repoOwner + * @param {string} repoName + * @param {number} prNumber + * @param {object} prData - PR 객체 (draft, labels) + * @param {string} appToken + * @param {string} openaiApiKey + */ +export async function analyzeComplexity( + repoOwner, + repoName, + prNumber, + prData, + appToken, + openaiApiKey +) { + if (prData.draft === true) { + console.log(`[complexity] Skipping PR #${prNumber}: draft`); + return { skipped: "draft" }; + } + const labels = (prData.labels || []).map((l) => l.name); + if (hasMaintenanceLabel(labels)) { + console.log(`[complexity] Skipping PR #${prNumber}: maintenance`); + return { skipped: "maintenance" }; + } + + // 1) PR files + const filesRes = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + if (!filesRes.ok) { + throw new Error(`Failed to list PR files: ${filesRes.status} ${filesRes.statusText}`); + } + const allFiles = await filesRes.json(); + + const solutionFiles = allFiles.filter( + (f) => + (f.status === "added" || f.status === "modified") && + SOLUTION_PATH_REGEX.test(f.filename) + ); + + console.log( + `[complexity] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solutions` + ); + + if (solutionFiles.length === 0) { + return { skipped: "no-solution-files" }; + } + + // 2) 모든 솔루션 파일 다운로드 + const fileEntries = []; + let totalSize = 0; + + for (const file of solutionFiles) { + const problemName = file.filename.split("/")[0]; + try { + const rawRes = await fetch(file.raw_url); + if (!rawRes.ok) { + console.error(`[complexity] Failed to fetch ${file.filename}: ${rawRes.status}`); + continue; + } + let content = await rawRes.text(); + if (content.length > MAX_FILE_SIZE) { + content = content.slice(0, MAX_FILE_SIZE); + } + + // 합산 크기 제한 + if (totalSize + content.length > MAX_TOTAL_SIZE) { + console.log(`[complexity] Reached MAX_TOTAL_SIZE, skipping remaining files`); + break; + } + + totalSize += content.length; + fileEntries.push({ problemName, content }); + } catch (error) { + console.error(`[complexity] Failed to download ${file.filename}: ${error.message}`); + } + } + + if (fileEntries.length === 0) { + return { skipped: "all-downloads-failed" }; + } + + // 3) OpenAI 1회 호출로 모든 파일 분석 + const analysisResults = await callComplexityAnalysis(fileEntries, openaiApiKey); + + // 4) OpenAI 결과를 fileEntries 순서에 맞춰 매핑 + // (OpenAI가 problemName을 반환하므로 매칭, 매칭 실패 시 순서 기반 fallback) + const entries = fileEntries.map((fe) => { + const match = analysisResults.find((r) => r.problemName === fe.problemName); + return match || { problemName: fe.problemName, solutions: [] }; + }); + + // 5) 본문 빌드 + upsert + const body = formatComplexityCommentBody(entries); + await upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken); + + return { + analyzed: entries.filter((e) => e.solutions.length > 0).length, + total: fileEntries.length, + }; +} +``` + +### 5.2 `handlers/webhooks.js` 통합 + +[handlers/webhooks.js:256-289](handlers/webhooks.js#L256-L289) 의 기존 두 try/catch 블록 뒤에, 동일한 패턴으로 신규 호출을 추가한다. **기존 코드는 수정하지 않는다.** + +```js +// import 추가 (파일 상단) +import { analyzeComplexity } from "./complexity-analysis.js"; + +// handlePullRequestEvent 안, 기존 학습 현황 try/catch 블록(L274-289) 바로 뒤에 추가: + + // 시간/공간 복잡도 분석 (OPENAI_API_KEY 있을 때만) + if (env.OPENAI_API_KEY) { + try { + await analyzeComplexity( + repoOwner, + repoName, + prNumber, + pr, + appToken, + env.OPENAI_API_KEY + ); + } catch (error) { + console.error(`[handlePullRequestEvent] complexity analysis failed: ${error.message}`); + // 복잡도 분석 실패는 전체 흐름을 중단시키지 않음 + } + } +``` + +> 기존 tagPatterns, postLearningStatus 호출은 그대로 둔다. 동일한 try/catch 순차 패턴을 유지하여 다른 기능에 영향을 주지 않는다. + +--- + +## 6. 케이스별 출력 검증 + +### 케이스 1 (단일 풀이, 주석 일치) +```json +{ "files": [{ + "problemName": "container-with-most-water", + "solutions": [{ + "name": "maxArea", "description": "투 포인터", + "hasUserAnnotation": true, + "userTime": "O(n)", "userSpace": "O(1)", + "actualTime": "O(n)", "actualSpace": "O(1)", + "matches": { "time": true, "space": true } + }] +}]} +``` +포맷터: 풀이 1개 → 접기 없이 비교 표 + ✅ 두 개 출력 → 이슈 케이스 1과 일치. + +### 케이스 2 (단일 풀이, 불일치) +`matches.time=false`, `feedback`에 "다시 분석해보시는 것을 권장드립니다" 톤 → 이슈 케이스 2와 일치. + +### 케이스 3 (단일 풀이, 주석 없음) +`hasUserAnnotation=false` → 단일 컬럼 표 + 안내 quote → 이슈 케이스 3과 일치. + +### 케이스 4 (멀티 풀이, 주석 있음) +```json +{ "files": [{ + "problemName": "find-minimum-in-rotated-sorted-array", + "solutions": [ + { "name": "findMin_use_math_min", "description": "Math.min 활용", "hasUserAnnotation": true, ... }, + { "name": "findMin_naive", "description": "선형 탐색", "hasUserAnnotation": true, ... }, + { "name": "findMin", "description": "이진 탐색", "hasUserAnnotation": true, ... } + ] +}]} +``` +포맷터: 풀이 3개 → `
` 접기 적용, 각 summary에 결과 표시 → 이슈 케이스 4와 일치. + +### 케이스 5 (멀티 풀이, 주석 없음) +`hasUserAnnotation=false` → `
` 접기, summary에 복잡도만 표시 + 주석 작성 권장 → 이슈 케이스 5와 일치. + +### 다중 파일 PR (1회 호출로 처리) +PR에 파일 3개 → 구분자로 합쳐 OpenAI 1회 호출 → `files[]` 배열에 3개 항목 → `### {problemName}` 섹션 3개의 단일 댓글. + +--- + +## 7. 비용 / 안정성 메모 + +- **OpenAI 호출 횟수**: PR당 항상 1회. 파일 수와 무관. 기존 설계(파일당 1회) 대비 대폭 절감. +- **max_tokens**: 4000. 파일 5개 × 멀티 풀이까지 커버. +- **입력 크기 제한**: `MAX_TOTAL_SIZE = 60000`으로 모든 파일 합산 제한. 초과 시 남은 파일 skip. +- **개별 파일 다운로드 실패**: 해당 파일만 skip, 나머지 정상 진행. OpenAI 호출은 성공한 파일들만 대상. +- **OpenAI 호출 실패**: 전체 분석 실패 → try/catch에서 잡혀 console.error만 출력. 다른 기능에 영향 없음. +- **댓글 upsert**: 마커 기반이므로 PR에 push가 반복돼도 댓글은 1개로 유지. + +--- + +## 8. 테스트 방법 + +### 로컬 +```bash +wrangler dev # http://localhost:8787 + +# webhook payload 시뮬레이션 +curl -X POST http://localhost:8787/webhooks \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: pull_request" \ + --data @fixtures/pull_request_opened.json +``` + +### 수동 시나리오 (DaleStudy/leetcode-study 의 테스트 PR) +1. **케이스 1**: 솔루션 상단에 정확한 `// TC: O(n)` / `// SC: O(1)` 주석을 단 파일 하나 PR. +2. **케이스 2**: `// TC: O(n)` 인데 실제로는 정렬을 쓰는 코드 PR. +3. **케이스 3**: 주석 없는 솔루션 파일 PR. +4. **케이스 4**: 하나의 파일에 여러 풀이 + 각각 주석이 달린 PR (test.js 참고). +5. **케이스 5**: 하나의 파일에 여러 풀이 + 주석 없는 PR. +6. **다중 파일**: 두 폴더 이상 변경하는 PR → OpenAI 1회 호출로 모두 분석되는지. +7. **재푸시**: 같은 PR에 force push → 댓글이 PATCH 되는지. + +각 시나리오에서 [tag-patterns.js](handlers/tag-patterns.js) 와 [learning-status.js](handlers/learning-status.js) 가 여전히 정상 동작하는지도 같이 확인 (회귀 방지). + +--- + +## 9. 작업 순서 (체크리스트) + +- [ ] `handlers/complexity-analysis.js` 신규 작성 (상수 + OpenAI 호출 + 댓글 포맷터 + upsert + 오케스트레이션) +- [ ] `handlers/webhooks.js`에 import + try/catch 블록 추가 +- [ ] `wrangler dev`로 케이스 1~5 / 다중 파일 / 재푸시 수동 검증 +- [ ] AGENTS.md 의 폴더 구조 / 기능 목록에 "시간/공간 복잡도 자동 분석" 추가 (간단히 한 줄) + +--- + +## 10. 본 작업 범위 외 (의도적 제외) + +research.md §7의 다음 항목들은 이번 PR에 **포함하지 않는다**: +- `ctx.waitUntil()` 도입 +- `prReview.js` Content-Type / response.ok 일괄 보강 +- PKCS1 분기 정리 +- truncated tree 처리 +- 라우팅 객체화 +- 패턴 태깅의 변경 파일 한정 처리 + +기존 기능 소스코드 수정도 **포함하지 않는다**: +- `utils/openai.js` — 수정하지 않음 +- `utils/constants.js` — 수정하지 않음 +- `handlers/tag-patterns.js` — 다른 팀 스코프 +- `handlers/learning-status.js` — 기존 기능 유지 +- 기존 tagPatterns/postLearningStatus 호출 패턴 변경 + +이번 작업은 **신규 파일 1개 추가 + webhooks.js에 호출 1개 추가**만 한다. diff --git a/research.md b/research.md new file mode 100644 index 0000000..8395bd8 --- /dev/null +++ b/research.md @@ -0,0 +1,248 @@ +# DaleStudy GitHub App – 코드베이스 분석 보고서 + +조사 일자: 2026-04-08 +대상 저장소: `/Users/lkhoony/Desktop/github` (Cloudflare Worker 기반 GitHub App) + +--- + +## 1. 한눈에 보는 개요 + +DaleStudy 조직의 LeetCode 스터디 저장소(`DaleStudy/leetcode-study`)를 자동화하기 위한 **Cloudflare Workers** 기반 GitHub App입니다. PR이 올라오면 다음 4가지 자동화를 수행합니다. + +1. **Week 라벨(프로젝트 필드) 누락 체크 / 경고 댓글** +2. **알고리즘 패턴 태깅** (각 솔루션 파일에 OpenAI로 패턴 분석 → 파일 단위 review comment) +3. **학습 현황 댓글** (사용자의 누적 풀이 + 이번 PR 분석을 표 형태로 issue comment) +4. **AI 코드 리뷰** (`@dalestudy` 멘션 시 OpenAI로 PR diff 리뷰, 또는 `approve` 시 자동 승인) + +추가로 운영자 수동 호출용 엔드포인트(`/check-weeks`, `/approve-prs`, `/merge-prs`)도 제공합니다. + +질문에서 말씀하신 "PR이 올라오면 파일을 가져와 시간/공간 복잡도를 분석" 부분은 [utils/openai.js](utils/openai.js#L15) 의 `generateCodeReview` 시스템 프롬프트에 명시되어 있습니다. 단, **자동으로 매번 실행되는 것은 아니고**, 실제로는 멘션(`@dalestudy`)이 있을 때만 작동합니다 — 자동 실행되는 것은 패턴 태깅과 학습 현황입니다 ([handlers/webhooks.js:257-289](handlers/webhooks.js#L257-L289)). **여기가 첫 번째로 의도와 코드가 어긋나는 지점입니다.** + +--- + +## 2. 진입점 (Entry Point) + +### [index.js](index.js) +- Cloudflare Workers의 표준 `export default { fetch }` 구조. +- 모든 요청은 `POST`만 허용 (`OPTIONS`는 CORS preflight 처리). +- 라우팅 테이블 (전부 `if pathname ===` 분기): + | Pathname | 핸들러 | 용도 | + |---|---|---| + | `/webhooks` | `handleWebhook` | GitHub Webhook 수신 (서명 검증 포함) | + | `/check-weeks` | `checkWeeks` | 모든 Open PR Week 검사 (수동) | + | `/approve-prs`, `/approve_prs` | `approvePrs` | 일괄 승인 (수동) | + | `/merge-prs`, `/merge_prs` | `mergePrs` | 일괄 병합 (수동) | +- `/webhooks`는 `request.text()`로 raw body를 받아 HMAC 검증 후, **새 Request 객체를 만들어서** 핸들러에 전달합니다 (body가 한 번 read되면 다시 못 읽기 때문). + +--- + +## 3. 폴더 구조 및 역할 + +``` +. +├── index.js # 라우터 (엔트리포인트) +├── wrangler.jsonc # Cloudflare Workers 설정 +├── test-migration.sh # 마이그레이션 테스트 스크립트 +├── handlers/ # 엔드포인트별 비즈니스 로직 +│ ├── webhooks.js # GitHub webhook 이벤트 디스패처 (메인) +│ ├── check-weeks.js # Week 일괄 검사 +│ ├── approve_prs.js # 일괄 승인 +│ ├── merge_prs.js # 일괄 병합 +│ ├── tag-patterns.js # 알고리즘 패턴 태깅 오케스트레이션 +│ └── learning-status.js # 학습 현황 오케스트레이션 +└── utils/ # 재사용 유틸 + ├── github.js # GitHub App 인증 (JWT/Installation Token), GraphQL, 헤더 + ├── webhook.js # HMAC SHA-256 webhook 서명 검증 + ├── cors.js # corsResponse / errorResponse / preflightResponse + ├── constants.js # ALLOWED_ORG, ALLOWED_REPO, MAINTENANCE_LABEL 등 + ├── validation.js # validateOrganization, hasMaintenanceLabel, isClosedPR + ├── pullRequests.js # PR 목록/메타 fetch + ├── prActions.js # hasApprovedReview, safeJson 등 PR 액션 헬퍼 + ├── prReview.js # AI 코드 리뷰 (diff fetch + OpenAI + 댓글 작성) + ├── prWeeks.js # ensureWarningComment / removeWarningComment / handleWeekComment + ├── openai.js # OpenAI Chat Completions 래퍼 3종 + ├── learningData.js # problem-categories.json, repo tree, PR files fetch + └── learningComment.js # 학습 현황 댓글 포맷터 + upsert +``` + +--- + +## 4. 실행 흐름 (Webhook 기준) + +[handlers/webhooks.js](handlers/webhooks.js) 의 `handleWebhook`이 디스패처입니다. + +1. **공통 게이트** ([webhooks.js:37-49](handlers/webhooks.js#L37-L49)) + - `payload.organization?.login !== "DaleStudy"` → 무시 + - `payload.repository?.name !== ALLOWED_REPO("leetcode-study")` → 무시 +2. **이벤트 분기** (`X-GitHub-Event` 헤더) + - `projects_v2_item` → `handleProjectsV2ItemEvent` + - `pull_request` → `handlePullRequestEvent` + - `issue_comment` → `handleIssueCommentEvent` + - `pull_request_review_comment` → `handlePullRequestReviewCommentEvent` + +### 4.1 `pull_request` 이벤트 — 우리가 가장 신경 써야 할 흐름 +[webhooks.js:212-297](handlers/webhooks.js#L212-L297) + +- `opened`/`reopened`/`synchronize`만 처리. +- maintenance 라벨이 있으면 early exit. +- App token 발급. +- **opened/reopened일 때만** Week 체크 (3초 sleep 후 — 프로젝트 자동 추가 race 회피). +- `OPENAI_API_KEY`가 있으면: + 1. `tagPatterns(...)` — 솔루션 파일별 패턴 태깅 (try/catch로 무시) + 2. `postLearningStatus(...)` — 학습 현황 댓글 (try/catch로 무시) + +### 4.2 패턴 태깅 — [handlers/tag-patterns.js](handlers/tag-patterns.js) +1. PR draft / maintenance 라벨이면 skip. +2. `GET /pulls/{n}/files?per_page=100`로 변경 파일 목록 조회. +3. `^[^/]+/[^/]+\.[^.]+$` 정규식으로 `{문제폴더}/{사용자}.ext` 형태만 필터. +4. 기존 Bot이 단 패턴 코멘트(``)를 모두 삭제. +5. 각 파일을 `raw_url`로 다운로드 → 20K자 trim → `generatePatternAnalysis`로 OpenAI 호출 → 파일 단위 review comment(`subject_type: "file"`) 작성. + +### 4.3 학습 현황 — [handlers/learning-status.js](handlers/learning-status.js) +1. 저장소 루트의 `problem-categories.json`을 raw로 fetch (없으면 조용히 skip). +2. `git/trees/main?recursive=1`로 사용자가 푼 모든 문제 추출 (`{문제}/{username}.ext`). +3. PR files API로 이번 PR 제출 파일 추출. +4. 각 제출 파일에 대해 `generateApproachAnalysis` 호출 (의도된 접근법과 일치하는지 boolean + 1문장 설명). +5. `buildCategoryProgress`로 카테고리별 진행도(정렬: 진행률 내림차순) 계산. +6. `formatLearningStatusComment`로 마크다운 표 작성. +7. `upsertLearningStatusComment`로 기존 봇 댓글이 있으면 PATCH, 없으면 POST. + +### 4.4 issue_comment / review_comment — 멘션 기반 +- `@dalestudy` 멘션 감지 → `extractMentionAndRequest` +- 텍스트가 `approve`/`승인`이면 → `handleApprovalRequest` (closed/draft/maintenance/이미 승인 체크 후 `event: "APPROVE"` POST) +- 그 외이면 → `performAIReview` (PR diff fetch → OpenAI → 댓글 작성, line comment에는 thread reply로) +- 시작/성공/실패 시 `eyes`/`+1`/`-1` reaction을 댓글에 답니다. + +--- + +## 5. 인증 (utils/github.js) + +`generateGitHubAppToken(env)`이 핵심: +1. `createJWT(APP_ID, PRIVATE_KEY)` — RS256 JWT 직접 서명 (Web Crypto API). `iat = now-60`, `exp = now+10min`. +2. `GET /app/installations`로 전체 설치 목록 조회 → `account.login === "DaleStudy"` 필터. +3. `POST /app/installations/{id}/access_tokens` → installation token (1시간 유효). + +`getGitHubHeaders(token)` — 모든 REST 호출에서 공유하는 헤더 빌더. +`getPRInfoFromNodeId(nodeId, token)` — `projects_v2_item` 이벤트에서 PR 번호/리포지토리를 GraphQL로 역조회. + +--- + +## 6. OpenAI 사용 (utils/openai.js) + +세 가지 함수 모두 `gpt-4.1-nano` 모델 사용. 모두 직접 fetch (SDK 미사용 — Workers 호환). + +| 함수 | 용도 | 응답 형식 | max_tokens | +|---|---|---|---| +| `generateCodeReview` | 멘션 기반 PR 리뷰 (Q&A or 전체) | 자유 텍스트 | 2000 | +| `generatePatternAnalysis` | 단일 파일 알고리즘 패턴 분류 | JSON `{patterns, description}` | 500 | +| `generateApproachAnalysis` | 풀이가 의도한 접근법과 맞는지 | JSON `{matches, explanation}` | 200 | + +`generateCodeReview` 시스템 프롬프트에 시간/공간 복잡도 분석 요구가 들어 있습니다 ([openai.js:31-36](utils/openai.js#L31-L36)). + +--- + +## 7. 발견된 버그 / 개선사항 + +### 🔴 버그 (실제 동작에 영향 있음) + +1. **PKCS1 Private Key 분기는 사실상 죽은 코드 / 동작 안 함** — [utils/github.js:147-174](utils/github.js#L147-L174) + `importPrivateKey`가 `BEGIN RSA PRIVATE KEY` 헤더(=PKCS1)를 detect하긴 하지만, `crypto.subtle.importKey("pkcs8", ...)`로 무조건 PKCS8로 import합니다. PKCS1 키를 넣으면 무조건 실패합니다. AGENTS.md는 "PKCS8/PKCS1 모두 지원"이라고 적혀 있는데 실제론 PKCS8만 지원됨. 둘 중 하나로 정리 필요 (문서를 고치든, ASN.1 wrapping 코드를 추가하든). + +2. **`postReviewComment`/`postThreadReply`/`addReactionToComment`에 `Content-Type` 헤더 누락** — [utils/prReview.js:51-59](utils/prReview.js#L51-L59), [prReview.js:79-87](utils/prReview.js#L79-L87), [prReview.js:111-115](utils/prReview.js#L111-L115) + `getGitHubHeaders`는 `Content-Type`을 포함하지 않는데, JSON body를 보내면서 `Content-Type: application/json`을 추가하지 않습니다. GitHub REST API는 너그러워서 보통 통과하지만, 표준 위반이고 일부 엔드포인트에서 거부될 수 있습니다. 또한 응답 상태를 **확인하지 않습니다** (`if (!response.ok)` 없음) — 댓글 작성이 실패해도 조용히 성공한 것처럼 동작합니다. 비교 대상으로 [handlers/webhooks.js:632-645](handlers/webhooks.js#L632-L645) 의 승인 코드는 명시적으로 `Content-Type`을 추가하고 `response.ok`를 검사합니다 — 이 패턴을 prReview.js에도 적용해야 합니다. + +3. **`pull_request.synchronize` 시 매번 패턴 태깅이 다시 돌아감** — [handlers/webhooks.js:257-272](handlers/webhooks.js#L257-L272) + [tag-patterns.js:75-77](handlers/tag-patterns.js#L75-L77) + `tagPatterns`는 진입할 때마다 기존 봇 패턴 코멘트를 **모두 삭제하고 다시 작성**합니다. PR에 커밋이 푸시될 때마다 이 작업이 발생하는데, 변경되지 않은 파일까지 다시 OpenAI 호출이 일어납니다. 비용 + rate limit 리스크가 있습니다. 최소한 "이번 push에서 변경된 파일만" 처리하거나, file SHA를 코멘트 본문에 박아두고 동일하면 skip하는 로직이 필요합니다. + +4. **`fetchUserSolutions`의 truncated 트리** — [utils/learningData.js:72-76](utils/learningData.js#L72-L76) + `git/trees/main?recursive=1`은 7MB / 100k 엔트리 제한이 있어 큰 저장소에서 `truncated: true`가 나옵니다. 현재는 console.warn만 하고 그대로 진행해서, 사용자의 누적 풀이 수가 **실제보다 적게** 카운트됩니다. leetcode-study는 충분히 큰 저장소이므로 곧 부딪힙니다. Tree API를 디렉터리별로 재귀 호출하거나 GraphQL로 대체해야 합니다. + +5. **`extractMentionAndRequest`의 `@dalestudy bot` 같은 입력 처리** — [handlers/webhooks.js:313-314](handlers/webhooks.js#L313-L314) + `/@dalestudy\s*(.*)/i`는 멘션 뒤 텍스트를 통째로 잡습니다. `@dalestudy bot please review`처럼 봇 닉네임이 끼면 `userRequest`가 `"bot please review"`가 되어 `genericReviewKeywords` 매칭이 안 되고 Q&A 모드로 동작합니다. 또한 멘션이 댓글 끝에 있으면 빈 문자열이 아니라 줄바꿈/마침표를 잡을 수 있습니다. + +6. **`handleProjectsV2ItemEvent`의 race condition** — [webhooks.js:165-184](handlers/webhooks.js#L165-L184) + `created` 액션 처리 시 곧바로 `handleWeekComment`로 Week 값을 GraphQL 조회하는데, 프로젝트에 막 추가된 직후에는 Week 필드가 아직 비어 있을 가능성이 매우 높습니다. 그러면 경고 댓글이 달리고, 그 직후 사용자가 Week를 설정하면 또 `edited` 이벤트가 와서 댓글이 지워집니다. 사용자 경험상 잠깐 경고가 깜빡이는 문제가 있습니다. `pull_request opened` 핸들러처럼 짧은 sleep이 있으면 좋습니다. + +7. **`fetchPRSubmissions`/`tag-patterns`의 100개 페이지 한계** — [learningData.js:130-134](utils/learningData.js#L130-L134), [tag-patterns.js:50](handlers/tag-patterns.js#L50) + `per_page=100`만 호출하고 페이지네이션을 안 합니다. PR이 100개 넘는 파일을 가질 일은 드물지만, 공동 작업 PR에서 누락 가능. 최소한 `Link` 헤더를 보고 추가 페이지가 있을 때 처리해야 합니다. + +### 🟡 코드 품질 / 구조 개선 + +8. **라우팅이 if/else 체인** — [index.js:28-70](index.js#L28-L70) + 엔드포인트가 6개 됐고 alias도 생기고 있습니다. `const routes = { "/webhooks": handleWebhook, ... }` 객체 lookup으로 정리하면 가독성/추가 비용이 줄어듭니다. + +9. **`/webhooks` 분기 안에서 Request를 재생성하는 로직이 진입점에 노출됨** — [index.js:30-54](index.js#L30-L54) + 서명 검증과 body 보존은 `verifyAndForward` 같은 헬퍼로 빼서 `index.js`는 순수 라우터로 유지하는 게 깔끔합니다. + +10. **Webhook 전체가 동기적으로 GitHub에 응답** — [webhooks.js:212-297](handlers/webhooks.js#L212-L297) + `pull_request opened` 이벤트 하나에서 (a) 3초 sleep, (b) Week 체크 + 댓글, (c) 패턴 태깅 (파일 N개 × OpenAI 호출), (d) 학습 현황 (파일 N개 × OpenAI 호출 + tree fetch)을 **순차적으로** 실행한 뒤에야 응답합니다. GitHub webhook은 10초 안에 응답하지 않으면 retry 됩니다. 파일이 5개만 넘어가도 timeout 위험이 있습니다. + → Cloudflare Workers의 [`ctx.waitUntil()`](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/#contextwaituntil)을 사용해서 **즉시 200 응답하고 백그라운드에서 처리**하는 패턴으로 바꾸는 것이 가장 큰 안정성 개선입니다. 현재 코드는 `fetch(request, env)` 시그니처라 `ctx`를 받지 않는데, `fetch(request, env, ctx)`로 바꾸면 됩니다. + +11. **`tagPatterns`와 `postLearningStatus`는 같은 PR files API를 두 번 호출** — [tag-patterns.js:49-53](handlers/tag-patterns.js#L49-L53), [learningData.js:116-120](utils/learningData.js#L116-L120) + 동일 PR에 대해 동일 API 호출이 두 번 발생. 한 번 fetch해서 두 함수에 주입하면 절약됩니다. + +12. **"파일 단위 review comment"는 GitHub API 측에서도 비공식 기능** — [tag-patterns.js:202-217](handlers/tag-patterns.js#L202-L217) + `subject_type: "file"`은 비교적 최근의 문서화되지 않은 동작이고, 깨질 가능성이 있습니다. 파일 단위 코멘트가 깨졌을 때 fallback(예: PR issue 댓글로 전환)이 없습니다. + +13. **OpenAI 호출 응답에 대한 가벼운 schema 검증 부족** — [utils/openai.js:163-174](utils/openai.js#L163-L174) + `generatePatternAnalysis`에서 `JSON.parse`가 실패해도 try/catch가 없습니다. (`generateApproachAnalysis`에는 있음 — 일관성 부재.) `gpt-4.1-nano`가 가끔 JSON 외 텍스트를 토하면 전체 PR 처리가 throw합니다. 단일 파일 catch 덕분에 최악은 면했지만, 불필요한 분기입니다. + +14. **GitHub API 응답 본문 파싱 시 `errorData.message` 의존** — [webhooks.js:582-587](handlers/webhooks.js#L582-L587), [learningComment.js:181-186](utils/learningComment.js#L181-L186) + `errorData`가 배열일 수도 있고, `message` 외 `errors[]`만 있을 수도 있습니다. `safeJson` 같은 래퍼는 있지만 활용이 일관되지 않습니다. + +15. **`handleApprovalRequest` 안에서 "이미 승인" 체크 시 봇 토큰의 reviews만 봐야** — [webhooks.js:617-629](handlers/webhooks.js#L617-L629) + 현재 `hasApprovedReview`가 어떻게 구현됐는지에 따라 다르지만, 사람이 이미 승인한 PR에 대해서도 "이미 승인됨"으로 거절하면 좀 어색합니다 — 봇 입장에서는 승인 가능해야 합니다. (utils/prActions.js 확인 권장.) + +16. **상수 분산** — [utils/constants.js](utils/constants.js) 와 각 핸들러 + `COMMENT_MARKER`가 [tag-patterns.js:12](handlers/tag-patterns.js#L12) 와 [learningComment.js:10](utils/learningComment.js#L10) 에 따로 정의돼 있습니다. 봇 댓글 마커는 향후 늘어날 가능성이 크니 `constants.js`로 모으는 게 좋습니다. + +17. **재시도 / 백오프 없음** + GitHub / OpenAI 호출 모두 5xx에 대한 재시도가 없습니다. Workers에는 작업 시간이 짧아 큰 재시도는 어렵지만, 1회 즉시 retry 정도는 안정성에 큰 도움이 됩니다. + +18. **`generatePatternAnalysis`의 패턴 목록이 하드코딩** — [openai.js:99-115](utils/openai.js#L99-L115) + 16개 패턴이 시스템 프롬프트에 박혀 있어 추가/수정 시 코드 수정이 필요. `constants.js`로 분리하면 테스트도 쉬워집니다. + +19. **`buildCategoryProgress`의 정렬 기준** — [learning-status.js:46-53](handlers/learning-status.js#L46-L53) + 진행률 내림차순 정렬은 "이미 잘하는 카테고리"가 위로 올라옵니다. UX 관점에서는 "취약한(진행률 낮은) 카테고리"가 위에 있는 게 학습 동기부여에 더 도움될 수 있습니다 — 도메인 의도와 맞는지 확인 필요. + +20. **`MAX_FILE_SIZE`가 두 곳에 다른 값으로 정의** — [tag-patterns.js:14](handlers/tag-patterns.js#L14) (20000) / [learning-status.js:19](handlers/learning-status.js#L19) (15000) / [openai.js:200](utils/openai.js#L200) (15000 한 번 더 slice) + 학습 현황은 15K, 패턴 태깅은 20K. 큰 의미 차이 없으면 통일하고 `constants.js`로 옮기는 것이 유지보수성에 도움됩니다. + +### 🟢 사소한 점 / 스타일 + +21. 파일 이름 컨벤션이 섞여 있음 — `check-weeks.js`(kebab) vs `approve_prs.js`(snake). 통일 권장. +22. 라우팅에서 alias 두 개씩(`/approve-prs` + `/approve_prs`)을 둔 것은 URL 일관성 자체를 잡지 못해서 생긴 hack입니다. 한 쪽만 정식으로 두고 다른 쪽은 301 redirect로 안내하는 것이 좋습니다. +23. `console.log`가 매우 많습니다. `wrangler tail` 디버깅을 위한 것이지만, 운영 환경에서는 log level 분리 (`LOG_LEVEL` env 등)가 있으면 좋습니다. +24. AGENTS.md의 폴더 구조 다이어그램이 [현재 구조](handlers/)와 일치하지 않습니다 (`approve_prs.js`, `merge_prs.js`, `tag-patterns.js`, `learning-status.js` 누락). 문서 갱신 필요. + +--- + +## 8. "복잡도 분석 자동화"를 본격 구현할 때 고려할 점 + +질문 의도가 "PR이 올라오면 시간/공간 복잡도를 자동 분석해주는" 기능을 강화하는 것이라면, 다음 방향을 권장합니다. + +1. **자동 트리거**: 현재 복잡도 분석은 멘션이 있어야 동작합니다. 새 PR이 열렸을 때 자동으로 도는 별도 핸들러(`handlers/complexity-analysis.js`)를 만들고 [webhooks.js:212-297](handlers/webhooks.js#L212-L297) 에 연결하세요. `tagPatterns`, `postLearningStatus`와 같은 위치에 `try/catch` 래핑으로 추가하면 자연스럽습니다. +2. **파일별 분석**: PR diff 전체를 한 번에 OpenAI에 던지지 말고, `tag-patterns.js`의 패턴(파일 단위 fetch + truncate + review comment)을 그대로 차용하세요. 코드 재사용도 되고, 파일별로 결과가 정리돼서 사용자에게 더 명확합니다. +3. **결과 저장 위치**: 파일 단위 review comment + PR 본문 issue comment 두 가지 옵션. 학습 현황 댓글처럼 **upsert 패턴**(`` 마커)을 쓰면 push마다 댓글이 누적되지 않아 깔끔합니다. +4. **OpenAI 응답 schema**: `{ time: "O(n log n)", space: "O(n)", reasoning: "..." }` 같은 strict JSON으로 받고 `response_format: { type: "json_object" }` 사용. 현재 [openai.js](utils/openai.js)의 두 분석 함수 패턴 그대로 따라가면 됩니다. +5. **비용 통제**: 위 6번(매 push마다 재실행) 이슈를 먼저 해결하고 나서 추가하세요. 안 그러면 비용이 두 배 이상 늘어납니다. +6. **`ctx.waitUntil()` 도입**: 위 10번. 복잡도 분석까지 추가되면 동기 처리는 거의 확실하게 webhook timeout을 일으킵니다. + +--- + +## 9. 우선순위 높은 액션 아이템 요약 + +| 순위 | 항목 | 위치 | +|---|---|---| +| 1 | `ctx.waitUntil()`로 webhook 처리를 백그라운드화 | [index.js](index.js), [handlers/webhooks.js](handlers/webhooks.js) | +| 2 | `prReview.js`의 fetch 호출에 `Content-Type` + `response.ok` 검사 추가 | [utils/prReview.js:44-115](utils/prReview.js#L44-L115) | +| 3 | `tagPatterns`가 변경된 파일만 처리하도록 개선 (비용/rate limit) | [handlers/tag-patterns.js](handlers/tag-patterns.js) | +| 4 | `fetchUserSolutions`의 truncated tree 처리 | [utils/learningData.js:58-95](utils/learningData.js#L58-L95) | +| 5 | PKCS1 분기 정리 (지원하든 제거하든) + AGENTS.md 동기화 | [utils/github.js:147-174](utils/github.js#L147-L174) | +| 6 | 라우팅을 객체 lookup으로 정리 + alias 정책 결정 | [index.js:28-70](index.js#L28-L70) | +| 7 | OpenAI 응답 JSON parse 일관된 try/catch | [utils/openai.js](utils/openai.js) | +| 8 | AGENTS.md 폴더 구조/엔드포인트 목록 최신화 | [AGENTS.md](AGENTS.md) | + +--- + +조사 범위: `index.js`, `handlers/*`, `utils/github.js`, `utils/openai.js`, `utils/learningData.js`, `utils/learningComment.js`, `utils/prReview.js`, `utils/validation.js`, `AGENTS.md`. (`utils/prWeeks.js`, `utils/pullRequests.js`, `utils/prActions.js`, `utils/cors.js`, `utils/webhook.js`, `utils/constants.js`, `handlers/check-weeks.js`, `handlers/approve_prs.js`, `handlers/merge_prs.js`는 함수 시그니처와 호출 관계만 확인.) From 22214c7ed1adc51d44f8360771cb91b3f8c72d87 Mon Sep 17 00:00:00 2001 From: Gichan Kim Date: Fri, 10 Apr 2026 20:09:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan.md | 652 ---------------------------------------------------- research.md | 248 -------------------- 2 files changed, 900 deletions(-) delete mode 100644 plan.md delete mode 100644 research.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 91f0d4d..0000000 --- a/plan.md +++ /dev/null @@ -1,652 +0,0 @@ -# 시간/공간 복잡도 자동 분석 — 구현 계획 - -연관 이슈: [DaleStudy/github#8](https://github.com/DaleStudy/github/issues/8) -참고: [research.md](research.md) (단, §7 항목들은 본 작업 범위 외로 두고 건드리지 않음) - ---- - -## 1. 목표 & 스코프 - -PR `opened` / `reopened` / `synchronize` 시, 솔루션 파일들의 시간/공간 복잡도를 OpenAI로 분석하여 **PR에 단 하나의 issue 댓글**(upsert)을 작성한다. 댓글에는 PR에 포함된 모든 솔루션 파일이 섹션별로 누적된다. - -처리 케이스: - -**단일 풀이** -- **케이스 1**: 사용자가 TC/SC 주석을 달았고 분석 결과와 일치 → ✅ 비교 표 -- **케이스 2**: 사용자 주석이 있지만 불일치 → ❌ 비교 표 + 다시 풀이 권장 톤 피드백 -- **케이스 3**: 사용자 주석 없음 → 분석 결과만 + 주석 작성 권장 안내 - -**멀티 풀이** (하나의 솔루션 파일에 여러 접근법이 포함된 경우) -- **케이스 4**: 멀티 풀이 + 유저 주석 있음 → 풀이별 `
` 접기, summary에 결과(✅/❌) 표시 -- **케이스 5**: 멀티 풀이 + 유저 주석 없음 → 풀이별 `
` 접기, summary에 복잡도 표시 - ---- - -## 2. 아키텍처 결정 - -| 항목 | 결정 | 이유 | -|---|---|---| -| 진입점 | [handlers/webhooks.js](handlers/webhooks.js) `handlePullRequestEvent` 안에 등록 | 패턴 태깅/학습 현황과 동일한 위치 | -| 파일 구조 | **`handlers/complexity-analysis.js` 단일 파일** | 상수, OpenAI 호출, 댓글 포맷터, upsert 모두 한 파일에 응집. 다른 기능 파일에 영향 없음 | -| 댓글 종류 | **PR issue comment** (review comment 아님) | 솔루션이 여러 개여도 한 댓글에 합쳐서 보여줘야 하므로 | -| 댓글 식별 | HTML 마커 `` | learningComment.js 와 동일 패턴 | -| 작성 방식 | upsert (있으면 PATCH, 없으면 POST) | learningComment.js 의 `upsertLearningStatusComment` 와 동일 패턴 | -| OpenAI 호출 | **모든 솔루션 파일을 구분자로 합쳐 1회 호출** | 파일별 호출 대비 API 비용/latency 대폭 절감. Workers timeout 위험도 줄어듦 | -| OpenAI 모델 | `gpt-4.1-nano`, `response_format: json_object`, `temperature: 0.2` | openai.js 의 다른 분석 함수들과 동일 | -| webhooks 통합 | 기존 try/catch 패턴과 동일하게 추가 | 다른 기능 소스코드에 영향 없음 | -| Non-blocking | 핸들러 자체가 throw 안 함, 실패는 console.error만 | 기존 패턴 동일 | -| 주석 파싱 | OpenAI에게 위임 | 자유 포맷 + 다국어 + 다언어. 정규식으로는 신뢰성 낮음 | -| 멀티 풀이 감지 | OpenAI에게 위임 | 언어/구조가 다양해 정규식으로 함수 경계를 잡기 어려움 | -| 멀티 풀이 포맷 | `
` 접기 + summary에 결과 표시 | 댓글 길이 관리 + 접힌 상태에서도 핵심 정보 확인 가능 | - -> **단일 파일 구조의 이유**: 이 기능은 자체적으로 완결된 기능이고, 다른 핸들러와 공유할 유틸이 없다. `utils/` 에 분산시키면 오히려 코드 추적이 어려워지고, 다른 팀의 파일(`constants.js`, `openai.js`)에 변경이 생긴다. 한 파일에 모아두면 기능 전체를 한눈에 파악할 수 있고, 삭제/수정 시에도 한 곳만 건드리면 된다. - -> **OpenAI 1회 호출의 이유**: PR에 솔루션 파일이 5개 있으면 기존 설계는 OpenAI 호출 5회. 각 호출은 ~1-3초이므로 최대 15초. Workers timeout(10초) 위험이 크다. 모든 파일을 구분자(`===== {problemName} =====`)로 합쳐 1회 호출하면 latency는 1회분, 비용도 입출력 토큰 합산으로 비슷하거나 더 저렴하다. - ---- - -## 3. 파일 변경 목록 - -### 신규 -- `handlers/complexity-analysis.js` — 상수, OpenAI 호출, 댓글 포맷터, upsert, 오케스트레이션 **모두 포함** - -### 수정 -- `handlers/webhooks.js` — `handlePullRequestEvent`에 호출 추가 (기존 try/catch 패턴과 동일하게) - -### 건드리지 않음 -- `utils/openai.js` — 수정 없음 -- `utils/constants.js` — 수정 없음 -- `handlers/tag-patterns.js` — 다른 팀 스코프 -- `handlers/learning-status.js` — 기존 기능 -- index.js — 신규 엔드포인트 없음 -- 인증/Webhook 검증 흐름 - ---- - -## 4. 데이터 흐름 - -``` -pull_request (opened/reopened/synchronize) - │ - ▼ -handlePullRequestEvent ── (기존) Week 체크 - │ - ├── (기존) try { tagPatterns(...) } catch { ... } - ├── (기존) try { postLearningStatus(...) } catch { ... } - └── (신규) try { analyzeComplexity(...) } catch { ... } - │ - ▼ - analyzeComplexity - 1) GET /pulls/{n}/files?per_page=100 - 2) SOLUTION_PATH_REGEX 로 솔루션 파일 필터 - 3) 각 파일의 raw_url 다운로드 + trim - 4) 모든 파일을 구분자로 합쳐 OpenAI 1회 호출 - ┌─────────────────────────────┐ - │ ===== problem-a ===== │ - │ <코드 A> │ - │ │ - │ ===== problem-b ===== │ - │ <코드 B> │ - └─────────────────────────────┘ - ↓ (1회 API 호출) - ┌─────────────────────────────┐ - │ { "files": [ │ - │ { "problemName": "problem-a",│ - │ "solutions": [...] }, │ - │ { "problemName": "problem-b",│ - │ "solutions": [...] } │ - │ ]} │ - └─────────────────────────────┘ - 5) formatComplexityCommentBody(parsedResult) - - 풀이 1개: 기존 테이블 포맷 - - 풀이 2개+:
접기 포맷 - 6) upsertComplexityComment(prNumber, body) -``` - ---- - -## 5. 상세 구현 - -### 5.1 `handlers/complexity-analysis.js` (신규 — 단일 파일) - -이 파일 하나에 다음 요소를 모두 포함한다: - -1. **상수**: `SOLUTION_PATH_REGEX`, `COMPLEXITY_COMMENT_MARKER`, `MAX_FILE_SIZE`, `MAX_TOTAL_SIZE` -2. **OpenAI 호출**: `callComplexityAnalysis()` — 모든 솔루션을 한 번에 분석 -3. **댓글 포맷터**: `formatComplexityCommentBody()` — 단일/멀티 풀이 분기 -4. **댓글 upsert**: `upsertComplexityComment()` — 마커 기반 생성/수정 -5. **오케스트레이션**: `analyzeComplexity()` — export, webhooks.js에서 호출 - -```js -/** - * 시간/공간 복잡도 자동 분석. - * PR opened/reopened/synchronize 시 호출된다. - * - * 모든 로직(상수, OpenAI 호출, 댓글 포맷, upsert)을 이 파일에 응집한다. - */ - -import { getGitHubHeaders } from "../utils/github.js"; -import { hasMaintenanceLabel } from "../utils/validation.js"; - -// ── 상수 ────────────────────────────────────────── - -const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; -const COMPLEXITY_COMMENT_MARKER = ""; -const MAX_FILE_SIZE = 15000; -const MAX_TOTAL_SIZE = 60000; // 모든 파일 합산 최대 (OpenAI 입력 제한 고려) -const FILE_DELIMITER = "====="; - -// ── OpenAI 호출 ─────────────────────────────────── - -const SYSTEM_PROMPT = `당신은 알고리즘 풀이의 시간/공간 복잡도를 분석하는 전문가입니다. - -여러 문제의 솔루션 코드가 구분자(===== {문제명} =====)로 나뉘어 제공됩니다. -각 문제별로 독립적으로 분석하세요. - -하나의 문제 안에 같은 문제를 여러 가지 방식으로 푼 풀이가 포함될 수 있습니다. -각 문제마다 코드에서 독립된 풀이가 몇 개인지 판별하세요. (함수/클래스/메서드 단위로 구분) - -각 풀이에 대해: -1. name: 함수명 또는 식별 가능한 이름 (예: "twoSum_bruteForce", "Solution.maxArea") -2. description: 접근 방식 한 줄 설명 (예: "이진 탐색", "HashMap 활용") -3. 코드의 실제 시간/공간 복잡도를 Big-O 표기로 계산 (actualTime, actualSpace). -4. 해당 풀이 바로 위/근처에 사용자가 남긴 시간복잡도/공간복잡도 주석을 찾으세요. - 주석은 자유 포맷이며 언어별 주석 스타일(//, #, /* */, --, """)과 한/영 키워드가 섞일 수 있습니다. - 예: "// TC: O(n)", "# 시간복잡도: O(n log n)", "/* Space: O(1) */", "// Time: O(n^2)" - - 찾았으면 hasUserAnnotation=true, userTime/userSpace에 사용자 값 그대로. - - 한쪽만 적혀 있으면 다른 쪽은 null. - - 전혀 없으면 hasUserAnnotation=false, userTime=null, userSpace=null. -5. matches.time / matches.space: - - hasUserAnnotation=false면 둘 다 false. - - 사용자 값이 있는 항목만 actual과 비교하여 일치 여부를 boolean으로 반환. -6. feedback (한국어 1-3문장): - - 일치하면: 칭찬 + 핵심 근거 짧게. - - 불일치하면: 어디가 왜 다른지 설명 + "다시 분석해보시는 것을 권장드립니다" 톤. - - 주석이 없으면: 풀이 핵심 근거만 설명. -7. suggestion (한국어, 항상 string): - - 의미 있는 한 단계 이상 개선 여지가 있을 때만 제안 (예: O(n^2) → O(n)). - - 문제 제약을 모를 수 있으므로 단정 금지. "고려해볼 만한 대안:" 톤. - - 개선 여지 없으면 "현재 구현이 적절해 보입니다." - -반드시 아래 JSON 스키마로만 응답: -{ - "files": [ - { - "problemName": string, - "solutions": [ - { - "name": string, - "description": string, - "hasUserAnnotation": boolean, - "userTime": string|null, - "userSpace": string|null, - "actualTime": string, - "actualSpace": string, - "matches": { "time": boolean, "space": boolean }, - "feedback": string, - "suggestion": string - } - ] - } - ] -}`; - -/** - * 모든 솔루션 파일을 한 번의 OpenAI 호출로 분석한다. - * - * @param {Array<{ problemName: string, content: string }>} fileEntries - * @param {string} apiKey - * @returns {Promise }>>} - */ -async function callComplexityAnalysis(fileEntries, apiKey) { - // 구분자로 모든 파일을 하나의 프롬프트로 합침 - const userPrompt = fileEntries - .map((f) => `${FILE_DELIMITER} ${f.problemName} ${FILE_DELIMITER}\n\`\`\`\n${f.content}\n\`\`\``) - .join("\n\n"); - - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: "gpt-4.1-nano", - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - max_tokens: 4000, - temperature: 0.2, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${error}`); - } - - const data = await response.json(); - const content = data.choices[0]?.message?.content; - if (!content) throw new Error("Empty response from OpenAI"); - - let parsed; - try { - parsed = JSON.parse(content); - } catch { - throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`); - } - - const files = Array.isArray(parsed.files) ? parsed.files : []; - - return files.map((file) => ({ - problemName: typeof file.problemName === "string" ? file.problemName : "unknown", - solutions: (Array.isArray(file.solutions) ? file.solutions : []).map((s) => ({ - name: typeof s.name === "string" ? s.name : "unknown", - description: typeof s.description === "string" ? s.description : "", - hasUserAnnotation: s.hasUserAnnotation === true, - userTime: typeof s.userTime === "string" ? s.userTime : null, - userSpace: typeof s.userSpace === "string" ? s.userSpace : null, - actualTime: typeof s.actualTime === "string" ? s.actualTime : "?", - actualSpace: typeof s.actualSpace === "string" ? s.actualSpace : "?", - matches: { - time: s.matches?.time === true, - space: s.matches?.space === true, - }, - feedback: typeof s.feedback === "string" ? s.feedback : "", - suggestion: typeof s.suggestion === "string" ? s.suggestion : "", - })), - })); -} - -// ── 댓글 포맷터 ────────────────────────────────── - -function buildSummaryResult(solution) { - if (!solution.hasUserAnnotation) { - return `Time: ${solution.actualTime} / Space: ${solution.actualSpace}`; - } - const timePart = solution.userTime - ? `Time: ${solution.matches.time ? "✅" : "❌"} ${solution.userTime} → ${solution.actualTime}` - : `Time: ${solution.actualTime}`; - const spacePart = solution.userSpace - ? `Space: ${solution.matches.space ? "✅" : "❌"} ${solution.userSpace} → ${solution.actualSpace}` - : `Space: ${solution.actualSpace}`; - return `${timePart} / ${spacePart}`; -} - -function buildSolutionBody(solution) { - const lines = []; - - if (solution.hasUserAnnotation) { - const timeMark = solution.matches.time ? "✅" : "❌"; - const spaceMark = solution.matches.space ? "✅" : "❌"; - lines.push("| | 유저 분석 | 실제 분석 | 결과 |"); - lines.push("|---|---|---|---|"); - lines.push(`| **Time** | ${solution.userTime ?? "-"} | ${solution.actualTime} | ${solution.userTime ? timeMark : "-"} |`); - lines.push(`| **Space** | ${solution.userSpace ?? "-"} | ${solution.actualSpace} | ${solution.userSpace ? spaceMark : "-"} |`); - } else { - lines.push("| | 복잡도 |"); - lines.push("|---|---|"); - lines.push(`| **Time** | ${solution.actualTime} |`); - lines.push(`| **Space** | ${solution.actualSpace} |`); - } - - lines.push(""); - if (solution.feedback) { - lines.push(`**피드백**: ${solution.feedback}`); - lines.push(""); - } - if (solution.suggestion) { - lines.push(`**개선 제안**: ${solution.suggestion}`); - lines.push(""); - } - - return lines; -} - -/** - * @param {Array<{ problemName: string, solutions: Array }>} entries - * @returns {string} - */ -function formatComplexityCommentBody(entries) { - const lines = []; - lines.push(COMPLEXITY_COMMENT_MARKER); - lines.push("### 📊 시간/공간 복잡도 분석"); - lines.push(""); - - for (const { problemName, solutions } of entries) { - lines.push(`### ${problemName}`); - lines.push(""); - - if (!solutions || solutions.length === 0) { - lines.push(`> ⚠️ 분석 결과가 없습니다.`); - lines.push(""); - continue; - } - - const isMulti = solutions.length > 1; - const hasAnyAnnotationMissing = solutions.some((s) => !s.hasUserAnnotation); - - if (isMulti) { - lines.push(`> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.`); - lines.push(""); - - solutions.forEach((sol, idx) => { - const summaryResult = buildSummaryResult(sol); - lines.push(`
`); - lines.push(`풀이 ${idx + 1}: ${sol.name} — ${summaryResult}`); - lines.push(""); - lines.push(...buildSolutionBody(sol)); - lines.push(`
`); - lines.push(""); - }); - } else { - lines.push(...buildSolutionBody(solutions[0])); - } - - if (hasAnyAnnotationMissing) { - lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); - lines.push(""); - } - } - - lines.push("---"); - lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다."); - - return lines.join("\n") + "\n"; -} - -// ── 댓글 upsert ────────────────────────────────── - -async function upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken) { - const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`; - - const listResponse = await fetch( - `${baseUrl}/issues/${prNumber}/comments?per_page=100`, - { headers: getGitHubHeaders(appToken) } - ); - if (!listResponse.ok) { - throw new Error(`Failed to list comments: ${listResponse.status} ${listResponse.statusText}`); - } - - const comments = await listResponse.json(); - const existing = comments.find( - (c) => c.user?.type === "Bot" && c.body?.includes(COMPLEXITY_COMMENT_MARKER) - ); - - const headers = { - ...getGitHubHeaders(appToken), - "Content-Type": "application/json", - }; - - if (existing) { - const res = await fetch(`${baseUrl}/issues/comments/${existing.id}`, { - method: "PATCH", - headers, - body: JSON.stringify({ body }), - }); - if (!res.ok) { - throw new Error(`Failed to update complexity comment ${existing.id}: ${res.status}`); - } - console.log(`[complexity] Updated comment ${existing.id} on PR #${prNumber}`); - } else { - const res = await fetch(`${baseUrl}/issues/${prNumber}/comments`, { - method: "POST", - headers, - body: JSON.stringify({ body }), - }); - if (!res.ok) { - throw new Error(`Failed to post complexity comment: ${res.status}`); - } - console.log(`[complexity] Created complexity comment on PR #${prNumber}`); - } -} - -// ── 오케스트레이션 (export) ─────────────────────── - -/** - * @param {string} repoOwner - * @param {string} repoName - * @param {number} prNumber - * @param {object} prData - PR 객체 (draft, labels) - * @param {string} appToken - * @param {string} openaiApiKey - */ -export async function analyzeComplexity( - repoOwner, - repoName, - prNumber, - prData, - appToken, - openaiApiKey -) { - if (prData.draft === true) { - console.log(`[complexity] Skipping PR #${prNumber}: draft`); - return { skipped: "draft" }; - } - const labels = (prData.labels || []).map((l) => l.name); - if (hasMaintenanceLabel(labels)) { - console.log(`[complexity] Skipping PR #${prNumber}: maintenance`); - return { skipped: "maintenance" }; - } - - // 1) PR files - const filesRes = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`, - { headers: getGitHubHeaders(appToken) } - ); - if (!filesRes.ok) { - throw new Error(`Failed to list PR files: ${filesRes.status} ${filesRes.statusText}`); - } - const allFiles = await filesRes.json(); - - const solutionFiles = allFiles.filter( - (f) => - (f.status === "added" || f.status === "modified") && - SOLUTION_PATH_REGEX.test(f.filename) - ); - - console.log( - `[complexity] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solutions` - ); - - if (solutionFiles.length === 0) { - return { skipped: "no-solution-files" }; - } - - // 2) 모든 솔루션 파일 다운로드 - const fileEntries = []; - let totalSize = 0; - - for (const file of solutionFiles) { - const problemName = file.filename.split("/")[0]; - try { - const rawRes = await fetch(file.raw_url); - if (!rawRes.ok) { - console.error(`[complexity] Failed to fetch ${file.filename}: ${rawRes.status}`); - continue; - } - let content = await rawRes.text(); - if (content.length > MAX_FILE_SIZE) { - content = content.slice(0, MAX_FILE_SIZE); - } - - // 합산 크기 제한 - if (totalSize + content.length > MAX_TOTAL_SIZE) { - console.log(`[complexity] Reached MAX_TOTAL_SIZE, skipping remaining files`); - break; - } - - totalSize += content.length; - fileEntries.push({ problemName, content }); - } catch (error) { - console.error(`[complexity] Failed to download ${file.filename}: ${error.message}`); - } - } - - if (fileEntries.length === 0) { - return { skipped: "all-downloads-failed" }; - } - - // 3) OpenAI 1회 호출로 모든 파일 분석 - const analysisResults = await callComplexityAnalysis(fileEntries, openaiApiKey); - - // 4) OpenAI 결과를 fileEntries 순서에 맞춰 매핑 - // (OpenAI가 problemName을 반환하므로 매칭, 매칭 실패 시 순서 기반 fallback) - const entries = fileEntries.map((fe) => { - const match = analysisResults.find((r) => r.problemName === fe.problemName); - return match || { problemName: fe.problemName, solutions: [] }; - }); - - // 5) 본문 빌드 + upsert - const body = formatComplexityCommentBody(entries); - await upsertComplexityComment(repoOwner, repoName, prNumber, body, appToken); - - return { - analyzed: entries.filter((e) => e.solutions.length > 0).length, - total: fileEntries.length, - }; -} -``` - -### 5.2 `handlers/webhooks.js` 통합 - -[handlers/webhooks.js:256-289](handlers/webhooks.js#L256-L289) 의 기존 두 try/catch 블록 뒤에, 동일한 패턴으로 신규 호출을 추가한다. **기존 코드는 수정하지 않는다.** - -```js -// import 추가 (파일 상단) -import { analyzeComplexity } from "./complexity-analysis.js"; - -// handlePullRequestEvent 안, 기존 학습 현황 try/catch 블록(L274-289) 바로 뒤에 추가: - - // 시간/공간 복잡도 분석 (OPENAI_API_KEY 있을 때만) - if (env.OPENAI_API_KEY) { - try { - await analyzeComplexity( - repoOwner, - repoName, - prNumber, - pr, - appToken, - env.OPENAI_API_KEY - ); - } catch (error) { - console.error(`[handlePullRequestEvent] complexity analysis failed: ${error.message}`); - // 복잡도 분석 실패는 전체 흐름을 중단시키지 않음 - } - } -``` - -> 기존 tagPatterns, postLearningStatus 호출은 그대로 둔다. 동일한 try/catch 순차 패턴을 유지하여 다른 기능에 영향을 주지 않는다. - ---- - -## 6. 케이스별 출력 검증 - -### 케이스 1 (단일 풀이, 주석 일치) -```json -{ "files": [{ - "problemName": "container-with-most-water", - "solutions": [{ - "name": "maxArea", "description": "투 포인터", - "hasUserAnnotation": true, - "userTime": "O(n)", "userSpace": "O(1)", - "actualTime": "O(n)", "actualSpace": "O(1)", - "matches": { "time": true, "space": true } - }] -}]} -``` -포맷터: 풀이 1개 → 접기 없이 비교 표 + ✅ 두 개 출력 → 이슈 케이스 1과 일치. - -### 케이스 2 (단일 풀이, 불일치) -`matches.time=false`, `feedback`에 "다시 분석해보시는 것을 권장드립니다" 톤 → 이슈 케이스 2와 일치. - -### 케이스 3 (단일 풀이, 주석 없음) -`hasUserAnnotation=false` → 단일 컬럼 표 + 안내 quote → 이슈 케이스 3과 일치. - -### 케이스 4 (멀티 풀이, 주석 있음) -```json -{ "files": [{ - "problemName": "find-minimum-in-rotated-sorted-array", - "solutions": [ - { "name": "findMin_use_math_min", "description": "Math.min 활용", "hasUserAnnotation": true, ... }, - { "name": "findMin_naive", "description": "선형 탐색", "hasUserAnnotation": true, ... }, - { "name": "findMin", "description": "이진 탐색", "hasUserAnnotation": true, ... } - ] -}]} -``` -포맷터: 풀이 3개 → `
` 접기 적용, 각 summary에 결과 표시 → 이슈 케이스 4와 일치. - -### 케이스 5 (멀티 풀이, 주석 없음) -`hasUserAnnotation=false` → `
` 접기, summary에 복잡도만 표시 + 주석 작성 권장 → 이슈 케이스 5와 일치. - -### 다중 파일 PR (1회 호출로 처리) -PR에 파일 3개 → 구분자로 합쳐 OpenAI 1회 호출 → `files[]` 배열에 3개 항목 → `### {problemName}` 섹션 3개의 단일 댓글. - ---- - -## 7. 비용 / 안정성 메모 - -- **OpenAI 호출 횟수**: PR당 항상 1회. 파일 수와 무관. 기존 설계(파일당 1회) 대비 대폭 절감. -- **max_tokens**: 4000. 파일 5개 × 멀티 풀이까지 커버. -- **입력 크기 제한**: `MAX_TOTAL_SIZE = 60000`으로 모든 파일 합산 제한. 초과 시 남은 파일 skip. -- **개별 파일 다운로드 실패**: 해당 파일만 skip, 나머지 정상 진행. OpenAI 호출은 성공한 파일들만 대상. -- **OpenAI 호출 실패**: 전체 분석 실패 → try/catch에서 잡혀 console.error만 출력. 다른 기능에 영향 없음. -- **댓글 upsert**: 마커 기반이므로 PR에 push가 반복돼도 댓글은 1개로 유지. - ---- - -## 8. 테스트 방법 - -### 로컬 -```bash -wrangler dev # http://localhost:8787 - -# webhook payload 시뮬레이션 -curl -X POST http://localhost:8787/webhooks \ - -H "Content-Type: application/json" \ - -H "X-GitHub-Event: pull_request" \ - --data @fixtures/pull_request_opened.json -``` - -### 수동 시나리오 (DaleStudy/leetcode-study 의 테스트 PR) -1. **케이스 1**: 솔루션 상단에 정확한 `// TC: O(n)` / `// SC: O(1)` 주석을 단 파일 하나 PR. -2. **케이스 2**: `// TC: O(n)` 인데 실제로는 정렬을 쓰는 코드 PR. -3. **케이스 3**: 주석 없는 솔루션 파일 PR. -4. **케이스 4**: 하나의 파일에 여러 풀이 + 각각 주석이 달린 PR (test.js 참고). -5. **케이스 5**: 하나의 파일에 여러 풀이 + 주석 없는 PR. -6. **다중 파일**: 두 폴더 이상 변경하는 PR → OpenAI 1회 호출로 모두 분석되는지. -7. **재푸시**: 같은 PR에 force push → 댓글이 PATCH 되는지. - -각 시나리오에서 [tag-patterns.js](handlers/tag-patterns.js) 와 [learning-status.js](handlers/learning-status.js) 가 여전히 정상 동작하는지도 같이 확인 (회귀 방지). - ---- - -## 9. 작업 순서 (체크리스트) - -- [ ] `handlers/complexity-analysis.js` 신규 작성 (상수 + OpenAI 호출 + 댓글 포맷터 + upsert + 오케스트레이션) -- [ ] `handlers/webhooks.js`에 import + try/catch 블록 추가 -- [ ] `wrangler dev`로 케이스 1~5 / 다중 파일 / 재푸시 수동 검증 -- [ ] AGENTS.md 의 폴더 구조 / 기능 목록에 "시간/공간 복잡도 자동 분석" 추가 (간단히 한 줄) - ---- - -## 10. 본 작업 범위 외 (의도적 제외) - -research.md §7의 다음 항목들은 이번 PR에 **포함하지 않는다**: -- `ctx.waitUntil()` 도입 -- `prReview.js` Content-Type / response.ok 일괄 보강 -- PKCS1 분기 정리 -- truncated tree 처리 -- 라우팅 객체화 -- 패턴 태깅의 변경 파일 한정 처리 - -기존 기능 소스코드 수정도 **포함하지 않는다**: -- `utils/openai.js` — 수정하지 않음 -- `utils/constants.js` — 수정하지 않음 -- `handlers/tag-patterns.js` — 다른 팀 스코프 -- `handlers/learning-status.js` — 기존 기능 유지 -- 기존 tagPatterns/postLearningStatus 호출 패턴 변경 - -이번 작업은 **신규 파일 1개 추가 + webhooks.js에 호출 1개 추가**만 한다. diff --git a/research.md b/research.md deleted file mode 100644 index 8395bd8..0000000 --- a/research.md +++ /dev/null @@ -1,248 +0,0 @@ -# DaleStudy GitHub App – 코드베이스 분석 보고서 - -조사 일자: 2026-04-08 -대상 저장소: `/Users/lkhoony/Desktop/github` (Cloudflare Worker 기반 GitHub App) - ---- - -## 1. 한눈에 보는 개요 - -DaleStudy 조직의 LeetCode 스터디 저장소(`DaleStudy/leetcode-study`)를 자동화하기 위한 **Cloudflare Workers** 기반 GitHub App입니다. PR이 올라오면 다음 4가지 자동화를 수행합니다. - -1. **Week 라벨(프로젝트 필드) 누락 체크 / 경고 댓글** -2. **알고리즘 패턴 태깅** (각 솔루션 파일에 OpenAI로 패턴 분석 → 파일 단위 review comment) -3. **학습 현황 댓글** (사용자의 누적 풀이 + 이번 PR 분석을 표 형태로 issue comment) -4. **AI 코드 리뷰** (`@dalestudy` 멘션 시 OpenAI로 PR diff 리뷰, 또는 `approve` 시 자동 승인) - -추가로 운영자 수동 호출용 엔드포인트(`/check-weeks`, `/approve-prs`, `/merge-prs`)도 제공합니다. - -질문에서 말씀하신 "PR이 올라오면 파일을 가져와 시간/공간 복잡도를 분석" 부분은 [utils/openai.js](utils/openai.js#L15) 의 `generateCodeReview` 시스템 프롬프트에 명시되어 있습니다. 단, **자동으로 매번 실행되는 것은 아니고**, 실제로는 멘션(`@dalestudy`)이 있을 때만 작동합니다 — 자동 실행되는 것은 패턴 태깅과 학습 현황입니다 ([handlers/webhooks.js:257-289](handlers/webhooks.js#L257-L289)). **여기가 첫 번째로 의도와 코드가 어긋나는 지점입니다.** - ---- - -## 2. 진입점 (Entry Point) - -### [index.js](index.js) -- Cloudflare Workers의 표준 `export default { fetch }` 구조. -- 모든 요청은 `POST`만 허용 (`OPTIONS`는 CORS preflight 처리). -- 라우팅 테이블 (전부 `if pathname ===` 분기): - | Pathname | 핸들러 | 용도 | - |---|---|---| - | `/webhooks` | `handleWebhook` | GitHub Webhook 수신 (서명 검증 포함) | - | `/check-weeks` | `checkWeeks` | 모든 Open PR Week 검사 (수동) | - | `/approve-prs`, `/approve_prs` | `approvePrs` | 일괄 승인 (수동) | - | `/merge-prs`, `/merge_prs` | `mergePrs` | 일괄 병합 (수동) | -- `/webhooks`는 `request.text()`로 raw body를 받아 HMAC 검증 후, **새 Request 객체를 만들어서** 핸들러에 전달합니다 (body가 한 번 read되면 다시 못 읽기 때문). - ---- - -## 3. 폴더 구조 및 역할 - -``` -. -├── index.js # 라우터 (엔트리포인트) -├── wrangler.jsonc # Cloudflare Workers 설정 -├── test-migration.sh # 마이그레이션 테스트 스크립트 -├── handlers/ # 엔드포인트별 비즈니스 로직 -│ ├── webhooks.js # GitHub webhook 이벤트 디스패처 (메인) -│ ├── check-weeks.js # Week 일괄 검사 -│ ├── approve_prs.js # 일괄 승인 -│ ├── merge_prs.js # 일괄 병합 -│ ├── tag-patterns.js # 알고리즘 패턴 태깅 오케스트레이션 -│ └── learning-status.js # 학습 현황 오케스트레이션 -└── utils/ # 재사용 유틸 - ├── github.js # GitHub App 인증 (JWT/Installation Token), GraphQL, 헤더 - ├── webhook.js # HMAC SHA-256 webhook 서명 검증 - ├── cors.js # corsResponse / errorResponse / preflightResponse - ├── constants.js # ALLOWED_ORG, ALLOWED_REPO, MAINTENANCE_LABEL 등 - ├── validation.js # validateOrganization, hasMaintenanceLabel, isClosedPR - ├── pullRequests.js # PR 목록/메타 fetch - ├── prActions.js # hasApprovedReview, safeJson 등 PR 액션 헬퍼 - ├── prReview.js # AI 코드 리뷰 (diff fetch + OpenAI + 댓글 작성) - ├── prWeeks.js # ensureWarningComment / removeWarningComment / handleWeekComment - ├── openai.js # OpenAI Chat Completions 래퍼 3종 - ├── learningData.js # problem-categories.json, repo tree, PR files fetch - └── learningComment.js # 학습 현황 댓글 포맷터 + upsert -``` - ---- - -## 4. 실행 흐름 (Webhook 기준) - -[handlers/webhooks.js](handlers/webhooks.js) 의 `handleWebhook`이 디스패처입니다. - -1. **공통 게이트** ([webhooks.js:37-49](handlers/webhooks.js#L37-L49)) - - `payload.organization?.login !== "DaleStudy"` → 무시 - - `payload.repository?.name !== ALLOWED_REPO("leetcode-study")` → 무시 -2. **이벤트 분기** (`X-GitHub-Event` 헤더) - - `projects_v2_item` → `handleProjectsV2ItemEvent` - - `pull_request` → `handlePullRequestEvent` - - `issue_comment` → `handleIssueCommentEvent` - - `pull_request_review_comment` → `handlePullRequestReviewCommentEvent` - -### 4.1 `pull_request` 이벤트 — 우리가 가장 신경 써야 할 흐름 -[webhooks.js:212-297](handlers/webhooks.js#L212-L297) - -- `opened`/`reopened`/`synchronize`만 처리. -- maintenance 라벨이 있으면 early exit. -- App token 발급. -- **opened/reopened일 때만** Week 체크 (3초 sleep 후 — 프로젝트 자동 추가 race 회피). -- `OPENAI_API_KEY`가 있으면: - 1. `tagPatterns(...)` — 솔루션 파일별 패턴 태깅 (try/catch로 무시) - 2. `postLearningStatus(...)` — 학습 현황 댓글 (try/catch로 무시) - -### 4.2 패턴 태깅 — [handlers/tag-patterns.js](handlers/tag-patterns.js) -1. PR draft / maintenance 라벨이면 skip. -2. `GET /pulls/{n}/files?per_page=100`로 변경 파일 목록 조회. -3. `^[^/]+/[^/]+\.[^.]+$` 정규식으로 `{문제폴더}/{사용자}.ext` 형태만 필터. -4. 기존 Bot이 단 패턴 코멘트(``)를 모두 삭제. -5. 각 파일을 `raw_url`로 다운로드 → 20K자 trim → `generatePatternAnalysis`로 OpenAI 호출 → 파일 단위 review comment(`subject_type: "file"`) 작성. - -### 4.3 학습 현황 — [handlers/learning-status.js](handlers/learning-status.js) -1. 저장소 루트의 `problem-categories.json`을 raw로 fetch (없으면 조용히 skip). -2. `git/trees/main?recursive=1`로 사용자가 푼 모든 문제 추출 (`{문제}/{username}.ext`). -3. PR files API로 이번 PR 제출 파일 추출. -4. 각 제출 파일에 대해 `generateApproachAnalysis` 호출 (의도된 접근법과 일치하는지 boolean + 1문장 설명). -5. `buildCategoryProgress`로 카테고리별 진행도(정렬: 진행률 내림차순) 계산. -6. `formatLearningStatusComment`로 마크다운 표 작성. -7. `upsertLearningStatusComment`로 기존 봇 댓글이 있으면 PATCH, 없으면 POST. - -### 4.4 issue_comment / review_comment — 멘션 기반 -- `@dalestudy` 멘션 감지 → `extractMentionAndRequest` -- 텍스트가 `approve`/`승인`이면 → `handleApprovalRequest` (closed/draft/maintenance/이미 승인 체크 후 `event: "APPROVE"` POST) -- 그 외이면 → `performAIReview` (PR diff fetch → OpenAI → 댓글 작성, line comment에는 thread reply로) -- 시작/성공/실패 시 `eyes`/`+1`/`-1` reaction을 댓글에 답니다. - ---- - -## 5. 인증 (utils/github.js) - -`generateGitHubAppToken(env)`이 핵심: -1. `createJWT(APP_ID, PRIVATE_KEY)` — RS256 JWT 직접 서명 (Web Crypto API). `iat = now-60`, `exp = now+10min`. -2. `GET /app/installations`로 전체 설치 목록 조회 → `account.login === "DaleStudy"` 필터. -3. `POST /app/installations/{id}/access_tokens` → installation token (1시간 유효). - -`getGitHubHeaders(token)` — 모든 REST 호출에서 공유하는 헤더 빌더. -`getPRInfoFromNodeId(nodeId, token)` — `projects_v2_item` 이벤트에서 PR 번호/리포지토리를 GraphQL로 역조회. - ---- - -## 6. OpenAI 사용 (utils/openai.js) - -세 가지 함수 모두 `gpt-4.1-nano` 모델 사용. 모두 직접 fetch (SDK 미사용 — Workers 호환). - -| 함수 | 용도 | 응답 형식 | max_tokens | -|---|---|---|---| -| `generateCodeReview` | 멘션 기반 PR 리뷰 (Q&A or 전체) | 자유 텍스트 | 2000 | -| `generatePatternAnalysis` | 단일 파일 알고리즘 패턴 분류 | JSON `{patterns, description}` | 500 | -| `generateApproachAnalysis` | 풀이가 의도한 접근법과 맞는지 | JSON `{matches, explanation}` | 200 | - -`generateCodeReview` 시스템 프롬프트에 시간/공간 복잡도 분석 요구가 들어 있습니다 ([openai.js:31-36](utils/openai.js#L31-L36)). - ---- - -## 7. 발견된 버그 / 개선사항 - -### 🔴 버그 (실제 동작에 영향 있음) - -1. **PKCS1 Private Key 분기는 사실상 죽은 코드 / 동작 안 함** — [utils/github.js:147-174](utils/github.js#L147-L174) - `importPrivateKey`가 `BEGIN RSA PRIVATE KEY` 헤더(=PKCS1)를 detect하긴 하지만, `crypto.subtle.importKey("pkcs8", ...)`로 무조건 PKCS8로 import합니다. PKCS1 키를 넣으면 무조건 실패합니다. AGENTS.md는 "PKCS8/PKCS1 모두 지원"이라고 적혀 있는데 실제론 PKCS8만 지원됨. 둘 중 하나로 정리 필요 (문서를 고치든, ASN.1 wrapping 코드를 추가하든). - -2. **`postReviewComment`/`postThreadReply`/`addReactionToComment`에 `Content-Type` 헤더 누락** — [utils/prReview.js:51-59](utils/prReview.js#L51-L59), [prReview.js:79-87](utils/prReview.js#L79-L87), [prReview.js:111-115](utils/prReview.js#L111-L115) - `getGitHubHeaders`는 `Content-Type`을 포함하지 않는데, JSON body를 보내면서 `Content-Type: application/json`을 추가하지 않습니다. GitHub REST API는 너그러워서 보통 통과하지만, 표준 위반이고 일부 엔드포인트에서 거부될 수 있습니다. 또한 응답 상태를 **확인하지 않습니다** (`if (!response.ok)` 없음) — 댓글 작성이 실패해도 조용히 성공한 것처럼 동작합니다. 비교 대상으로 [handlers/webhooks.js:632-645](handlers/webhooks.js#L632-L645) 의 승인 코드는 명시적으로 `Content-Type`을 추가하고 `response.ok`를 검사합니다 — 이 패턴을 prReview.js에도 적용해야 합니다. - -3. **`pull_request.synchronize` 시 매번 패턴 태깅이 다시 돌아감** — [handlers/webhooks.js:257-272](handlers/webhooks.js#L257-L272) + [tag-patterns.js:75-77](handlers/tag-patterns.js#L75-L77) - `tagPatterns`는 진입할 때마다 기존 봇 패턴 코멘트를 **모두 삭제하고 다시 작성**합니다. PR에 커밋이 푸시될 때마다 이 작업이 발생하는데, 변경되지 않은 파일까지 다시 OpenAI 호출이 일어납니다. 비용 + rate limit 리스크가 있습니다. 최소한 "이번 push에서 변경된 파일만" 처리하거나, file SHA를 코멘트 본문에 박아두고 동일하면 skip하는 로직이 필요합니다. - -4. **`fetchUserSolutions`의 truncated 트리** — [utils/learningData.js:72-76](utils/learningData.js#L72-L76) - `git/trees/main?recursive=1`은 7MB / 100k 엔트리 제한이 있어 큰 저장소에서 `truncated: true`가 나옵니다. 현재는 console.warn만 하고 그대로 진행해서, 사용자의 누적 풀이 수가 **실제보다 적게** 카운트됩니다. leetcode-study는 충분히 큰 저장소이므로 곧 부딪힙니다. Tree API를 디렉터리별로 재귀 호출하거나 GraphQL로 대체해야 합니다. - -5. **`extractMentionAndRequest`의 `@dalestudy bot` 같은 입력 처리** — [handlers/webhooks.js:313-314](handlers/webhooks.js#L313-L314) - `/@dalestudy\s*(.*)/i`는 멘션 뒤 텍스트를 통째로 잡습니다. `@dalestudy bot please review`처럼 봇 닉네임이 끼면 `userRequest`가 `"bot please review"`가 되어 `genericReviewKeywords` 매칭이 안 되고 Q&A 모드로 동작합니다. 또한 멘션이 댓글 끝에 있으면 빈 문자열이 아니라 줄바꿈/마침표를 잡을 수 있습니다. - -6. **`handleProjectsV2ItemEvent`의 race condition** — [webhooks.js:165-184](handlers/webhooks.js#L165-L184) - `created` 액션 처리 시 곧바로 `handleWeekComment`로 Week 값을 GraphQL 조회하는데, 프로젝트에 막 추가된 직후에는 Week 필드가 아직 비어 있을 가능성이 매우 높습니다. 그러면 경고 댓글이 달리고, 그 직후 사용자가 Week를 설정하면 또 `edited` 이벤트가 와서 댓글이 지워집니다. 사용자 경험상 잠깐 경고가 깜빡이는 문제가 있습니다. `pull_request opened` 핸들러처럼 짧은 sleep이 있으면 좋습니다. - -7. **`fetchPRSubmissions`/`tag-patterns`의 100개 페이지 한계** — [learningData.js:130-134](utils/learningData.js#L130-L134), [tag-patterns.js:50](handlers/tag-patterns.js#L50) - `per_page=100`만 호출하고 페이지네이션을 안 합니다. PR이 100개 넘는 파일을 가질 일은 드물지만, 공동 작업 PR에서 누락 가능. 최소한 `Link` 헤더를 보고 추가 페이지가 있을 때 처리해야 합니다. - -### 🟡 코드 품질 / 구조 개선 - -8. **라우팅이 if/else 체인** — [index.js:28-70](index.js#L28-L70) - 엔드포인트가 6개 됐고 alias도 생기고 있습니다. `const routes = { "/webhooks": handleWebhook, ... }` 객체 lookup으로 정리하면 가독성/추가 비용이 줄어듭니다. - -9. **`/webhooks` 분기 안에서 Request를 재생성하는 로직이 진입점에 노출됨** — [index.js:30-54](index.js#L30-L54) - 서명 검증과 body 보존은 `verifyAndForward` 같은 헬퍼로 빼서 `index.js`는 순수 라우터로 유지하는 게 깔끔합니다. - -10. **Webhook 전체가 동기적으로 GitHub에 응답** — [webhooks.js:212-297](handlers/webhooks.js#L212-L297) - `pull_request opened` 이벤트 하나에서 (a) 3초 sleep, (b) Week 체크 + 댓글, (c) 패턴 태깅 (파일 N개 × OpenAI 호출), (d) 학습 현황 (파일 N개 × OpenAI 호출 + tree fetch)을 **순차적으로** 실행한 뒤에야 응답합니다. GitHub webhook은 10초 안에 응답하지 않으면 retry 됩니다. 파일이 5개만 넘어가도 timeout 위험이 있습니다. - → Cloudflare Workers의 [`ctx.waitUntil()`](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/#contextwaituntil)을 사용해서 **즉시 200 응답하고 백그라운드에서 처리**하는 패턴으로 바꾸는 것이 가장 큰 안정성 개선입니다. 현재 코드는 `fetch(request, env)` 시그니처라 `ctx`를 받지 않는데, `fetch(request, env, ctx)`로 바꾸면 됩니다. - -11. **`tagPatterns`와 `postLearningStatus`는 같은 PR files API를 두 번 호출** — [tag-patterns.js:49-53](handlers/tag-patterns.js#L49-L53), [learningData.js:116-120](utils/learningData.js#L116-L120) - 동일 PR에 대해 동일 API 호출이 두 번 발생. 한 번 fetch해서 두 함수에 주입하면 절약됩니다. - -12. **"파일 단위 review comment"는 GitHub API 측에서도 비공식 기능** — [tag-patterns.js:202-217](handlers/tag-patterns.js#L202-L217) - `subject_type: "file"`은 비교적 최근의 문서화되지 않은 동작이고, 깨질 가능성이 있습니다. 파일 단위 코멘트가 깨졌을 때 fallback(예: PR issue 댓글로 전환)이 없습니다. - -13. **OpenAI 호출 응답에 대한 가벼운 schema 검증 부족** — [utils/openai.js:163-174](utils/openai.js#L163-L174) - `generatePatternAnalysis`에서 `JSON.parse`가 실패해도 try/catch가 없습니다. (`generateApproachAnalysis`에는 있음 — 일관성 부재.) `gpt-4.1-nano`가 가끔 JSON 외 텍스트를 토하면 전체 PR 처리가 throw합니다. 단일 파일 catch 덕분에 최악은 면했지만, 불필요한 분기입니다. - -14. **GitHub API 응답 본문 파싱 시 `errorData.message` 의존** — [webhooks.js:582-587](handlers/webhooks.js#L582-L587), [learningComment.js:181-186](utils/learningComment.js#L181-L186) - `errorData`가 배열일 수도 있고, `message` 외 `errors[]`만 있을 수도 있습니다. `safeJson` 같은 래퍼는 있지만 활용이 일관되지 않습니다. - -15. **`handleApprovalRequest` 안에서 "이미 승인" 체크 시 봇 토큰의 reviews만 봐야** — [webhooks.js:617-629](handlers/webhooks.js#L617-L629) - 현재 `hasApprovedReview`가 어떻게 구현됐는지에 따라 다르지만, 사람이 이미 승인한 PR에 대해서도 "이미 승인됨"으로 거절하면 좀 어색합니다 — 봇 입장에서는 승인 가능해야 합니다. (utils/prActions.js 확인 권장.) - -16. **상수 분산** — [utils/constants.js](utils/constants.js) 와 각 핸들러 - `COMMENT_MARKER`가 [tag-patterns.js:12](handlers/tag-patterns.js#L12) 와 [learningComment.js:10](utils/learningComment.js#L10) 에 따로 정의돼 있습니다. 봇 댓글 마커는 향후 늘어날 가능성이 크니 `constants.js`로 모으는 게 좋습니다. - -17. **재시도 / 백오프 없음** - GitHub / OpenAI 호출 모두 5xx에 대한 재시도가 없습니다. Workers에는 작업 시간이 짧아 큰 재시도는 어렵지만, 1회 즉시 retry 정도는 안정성에 큰 도움이 됩니다. - -18. **`generatePatternAnalysis`의 패턴 목록이 하드코딩** — [openai.js:99-115](utils/openai.js#L99-L115) - 16개 패턴이 시스템 프롬프트에 박혀 있어 추가/수정 시 코드 수정이 필요. `constants.js`로 분리하면 테스트도 쉬워집니다. - -19. **`buildCategoryProgress`의 정렬 기준** — [learning-status.js:46-53](handlers/learning-status.js#L46-L53) - 진행률 내림차순 정렬은 "이미 잘하는 카테고리"가 위로 올라옵니다. UX 관점에서는 "취약한(진행률 낮은) 카테고리"가 위에 있는 게 학습 동기부여에 더 도움될 수 있습니다 — 도메인 의도와 맞는지 확인 필요. - -20. **`MAX_FILE_SIZE`가 두 곳에 다른 값으로 정의** — [tag-patterns.js:14](handlers/tag-patterns.js#L14) (20000) / [learning-status.js:19](handlers/learning-status.js#L19) (15000) / [openai.js:200](utils/openai.js#L200) (15000 한 번 더 slice) - 학습 현황은 15K, 패턴 태깅은 20K. 큰 의미 차이 없으면 통일하고 `constants.js`로 옮기는 것이 유지보수성에 도움됩니다. - -### 🟢 사소한 점 / 스타일 - -21. 파일 이름 컨벤션이 섞여 있음 — `check-weeks.js`(kebab) vs `approve_prs.js`(snake). 통일 권장. -22. 라우팅에서 alias 두 개씩(`/approve-prs` + `/approve_prs`)을 둔 것은 URL 일관성 자체를 잡지 못해서 생긴 hack입니다. 한 쪽만 정식으로 두고 다른 쪽은 301 redirect로 안내하는 것이 좋습니다. -23. `console.log`가 매우 많습니다. `wrangler tail` 디버깅을 위한 것이지만, 운영 환경에서는 log level 분리 (`LOG_LEVEL` env 등)가 있으면 좋습니다. -24. AGENTS.md의 폴더 구조 다이어그램이 [현재 구조](handlers/)와 일치하지 않습니다 (`approve_prs.js`, `merge_prs.js`, `tag-patterns.js`, `learning-status.js` 누락). 문서 갱신 필요. - ---- - -## 8. "복잡도 분석 자동화"를 본격 구현할 때 고려할 점 - -질문 의도가 "PR이 올라오면 시간/공간 복잡도를 자동 분석해주는" 기능을 강화하는 것이라면, 다음 방향을 권장합니다. - -1. **자동 트리거**: 현재 복잡도 분석은 멘션이 있어야 동작합니다. 새 PR이 열렸을 때 자동으로 도는 별도 핸들러(`handlers/complexity-analysis.js`)를 만들고 [webhooks.js:212-297](handlers/webhooks.js#L212-L297) 에 연결하세요. `tagPatterns`, `postLearningStatus`와 같은 위치에 `try/catch` 래핑으로 추가하면 자연스럽습니다. -2. **파일별 분석**: PR diff 전체를 한 번에 OpenAI에 던지지 말고, `tag-patterns.js`의 패턴(파일 단위 fetch + truncate + review comment)을 그대로 차용하세요. 코드 재사용도 되고, 파일별로 결과가 정리돼서 사용자에게 더 명확합니다. -3. **결과 저장 위치**: 파일 단위 review comment + PR 본문 issue comment 두 가지 옵션. 학습 현황 댓글처럼 **upsert 패턴**(`` 마커)을 쓰면 push마다 댓글이 누적되지 않아 깔끔합니다. -4. **OpenAI 응답 schema**: `{ time: "O(n log n)", space: "O(n)", reasoning: "..." }` 같은 strict JSON으로 받고 `response_format: { type: "json_object" }` 사용. 현재 [openai.js](utils/openai.js)의 두 분석 함수 패턴 그대로 따라가면 됩니다. -5. **비용 통제**: 위 6번(매 push마다 재실행) 이슈를 먼저 해결하고 나서 추가하세요. 안 그러면 비용이 두 배 이상 늘어납니다. -6. **`ctx.waitUntil()` 도입**: 위 10번. 복잡도 분석까지 추가되면 동기 처리는 거의 확실하게 webhook timeout을 일으킵니다. - ---- - -## 9. 우선순위 높은 액션 아이템 요약 - -| 순위 | 항목 | 위치 | -|---|---|---| -| 1 | `ctx.waitUntil()`로 webhook 처리를 백그라운드화 | [index.js](index.js), [handlers/webhooks.js](handlers/webhooks.js) | -| 2 | `prReview.js`의 fetch 호출에 `Content-Type` + `response.ok` 검사 추가 | [utils/prReview.js:44-115](utils/prReview.js#L44-L115) | -| 3 | `tagPatterns`가 변경된 파일만 처리하도록 개선 (비용/rate limit) | [handlers/tag-patterns.js](handlers/tag-patterns.js) | -| 4 | `fetchUserSolutions`의 truncated tree 처리 | [utils/learningData.js:58-95](utils/learningData.js#L58-L95) | -| 5 | PKCS1 분기 정리 (지원하든 제거하든) + AGENTS.md 동기화 | [utils/github.js:147-174](utils/github.js#L147-L174) | -| 6 | 라우팅을 객체 lookup으로 정리 + alias 정책 결정 | [index.js:28-70](index.js#L28-L70) | -| 7 | OpenAI 응답 JSON parse 일관된 try/catch | [utils/openai.js](utils/openai.js) | -| 8 | AGENTS.md 폴더 구조/엔드포인트 목록 최신화 | [AGENTS.md](AGENTS.md) | - ---- - -조사 범위: `index.js`, `handlers/*`, `utils/github.js`, `utils/openai.js`, `utils/learningData.js`, `utils/learningComment.js`, `utils/prReview.js`, `utils/validation.js`, `AGENTS.md`. (`utils/prWeeks.js`, `utils/pullRequests.js`, `utils/prActions.js`, `utils/cors.js`, `utils/webhook.js`, `utils/constants.js`, `handlers/check-weeks.js`, `handlers/approve_prs.js`, `handlers/merge_prs.js`는 함수 시그니처와 호출 관계만 확인.)