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/complexity-analysis.test.js b/handlers/complexity-analysis.test.js new file mode 100644 index 0000000..6c517cf --- /dev/null +++ b/handlers/complexity-analysis.test.js @@ -0,0 +1,766 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +vi.mock("../utils/github.js", () => ({ + getGitHubHeaders: vi.fn((token) => ({ + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "DaleStudy-GitHub-App", + })), +})); + +import { analyzeComplexity } from "./complexity-analysis.js"; + +const REPO_OWNER = "DaleStudy"; +const REPO_NAME = "leetcode-study"; +const PR_NUMBER = 42; +const APP_TOKEN = "fake-token"; +const OPENAI_KEY = "fake-openai-key"; + +const COMMENT_MARKER = ""; + +function makePrData(overrides = {}) { + return { draft: false, labels: [], ...overrides }; +} + +function makeSolutionFile(problemName, username = "testuser", status = "added") { + return { + filename: `${problemName}/${username}.js`, + status, + raw_url: `https://raw.example.com/${problemName}/${username}.js`, + }; +} + +function okJson(data) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }); +} + +function okText(text) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(text), + }); +} + +function failResponse(status = 500) { + return Promise.resolve({ + ok: false, + status, + statusText: "Error", + json: () => Promise.resolve({ error: "fail" }), + text: () => Promise.resolve("fail"), + }); +} + +function makeOpenAIResponse(files) { + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ files }), + }, + }, + ], + }); +} + +function makeSingleSolutionAnalysis(problemName, overrides = {}) { + return { + problemName, + solutions: [ + { + name: "solution", + description: "기본 풀이", + hasUserAnnotation: true, + userTime: "O(n)", + userSpace: "O(1)", + actualTime: "O(n)", + actualSpace: "O(1)", + matches: { time: true, space: true }, + feedback: "정확합니다!", + suggestion: "현재 구현이 적절해 보입니다.", + ...overrides, + }, + ], + }; +} + +// ── skip 조건 ───────────────────────────────────── + +describe("analyzeComplexity — skip 조건", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("draft PR 은 skip 한다", async () => { + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData({ draft: true }), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "draft" }); + }); + + it("maintenance 라벨이 있으면 skip 한다", async () => { + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData({ labels: [{ name: "maintenance" }] }), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "maintenance" }); + }); + + it("솔루션 파일이 없으면 skip 한다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url.url; + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([ + { filename: "README.md", status: "modified", raw_url: "https://raw.example.com/README.md" }, + ]); + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "no-solution-files" }); + }); + + it("deleted 상태 파일은 솔루션으로 취급하지 않는다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url.url; + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([ + { filename: "two-sum/testuser.js", status: "deleted", raw_url: "https://raw.example.com/two-sum/testuser.js" }, + ]); + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "no-solution-files" }); + }); + + it("솔루션 경로 패턴에 맞지 않는 파일은 무시한다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url.url; + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([ + { filename: "deep/nested/path/file.js", status: "added", raw_url: "https://raw.example.com/a" }, + { filename: "noextension", status: "added", raw_url: "https://raw.example.com/b" }, + ]); + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "no-solution-files" }); + }); + + it("모든 파일 다운로드가 실패하면 skip 한다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url.url; + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return failResponse(404); + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "all-downloads-failed" }); + }); +}); + +// ── OpenAI 응답 파싱 ────────────────────────────── + +describe("analyzeComplexity — OpenAI 응답 파싱", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function setupFetchWithOpenAI(openaiResponse) { + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() { return 0; }"); + } + if (urlStr.includes("openai.com")) { + return openaiResponse; + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments")) { + if (method === "GET") return okJson([]); + if (method === "POST") return okJson({ id: 1 }); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + } + + it("정상적인 OpenAI 응답을 파싱하여 댓글을 작성한다", async () => { + setupFetchWithOpenAI( + makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]) + ); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.analyzed).toBe(1); + expect(result.total).toBe(1); + }); + + it("OpenAI API 호출이 실패하면 에러를 throw 한다", async () => { + setupFetchWithOpenAI(failResponse(429)); + + await expect( + analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ) + ).rejects.toThrow("OpenAI API error"); + }); + + it("OpenAI 가 빈 choices 를 반환하면 에러를 throw 한다", async () => { + setupFetchWithOpenAI(okJson({ choices: [{ message: {} }] })); + + await expect( + analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ) + ).rejects.toThrow("Empty response from OpenAI"); + }); + + it("OpenAI 가 잘못된 JSON 을 반환하면 에러를 throw 한다", async () => { + setupFetchWithOpenAI( + okJson({ choices: [{ message: { content: "not json {{{" } }] }) + ); + + await expect( + analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ) + ).rejects.toThrow("OpenAI returned invalid JSON"); + }); + + it("OpenAI 응답에 누락된 필드가 있으면 기본값으로 대체한다", async () => { + setupFetchWithOpenAI( + okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + files: [ + { + problemName: "two-sum", + solutions: [ + { + // name, description, feedback, suggestion 누락 + actualTime: "O(n)", + actualSpace: "O(1)", + }, + ], + }, + ], + }), + }, + }, + ], + }) + ); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.analyzed).toBe(1); + + // 댓글이 POST 되었는지 확인 + const postCall = globalThis.fetch.mock.calls.find( + ([url, opts]) => opts?.method === "POST" && url.includes("/comments") + ); + expect(postCall).toBeDefined(); + }); + + it("OpenAI 응답의 files 가 배열이 아니면 빈 결과로 처리한다", async () => { + setupFetchWithOpenAI( + okJson({ + choices: [ + { message: { content: JSON.stringify({ files: "not-array" }) } }, + ], + }) + ); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + // files 매핑 실패 → 빈 solutions → 여전히 댓글은 작성 (분석 결과 없음 표시) + expect(result.analyzed).toBe(0); + expect(result.total).toBe(1); + }); +}); + +// ── 댓글 포맷 ───────────────────────────────────── + +describe("analyzeComplexity — 댓글 포맷", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function setupFetchAndCapture(openaiFiles) { + let capturedBody = null; + + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + const files = openaiFiles.map((f) => makeSolutionFile(f.problemName)); + return okJson(files); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() {}"); + } + if (urlStr.includes("openai.com")) { + return makeOpenAIResponse(openaiFiles); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments")) { + if (method === "GET") return okJson([]); + if (method === "POST") { + capturedBody = JSON.parse(opts.body).body; + return okJson({ id: 1 }); + } + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + return () => capturedBody; + } + + it("단일 풀이 + 유저 주석 있음 → 비교 테이블 포맷", async () => { + const getBody = setupFetchAndCapture([ + makeSingleSolutionAnalysis("two-sum"), + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + const body = getBody(); + expect(body).toContain(COMMENT_MARKER); + expect(body).toContain("유저 분석"); + expect(body).toContain("실제 분석"); + expect(body).toContain("✅"); + expect(body).not.toContain("
"); + }); + + it("단일 풀이 + 유저 주석 없음 → 복잡도만 표시 + 주석 권장 안내", async () => { + const getBody = setupFetchAndCapture([ + makeSingleSolutionAnalysis("two-sum", { + hasUserAnnotation: false, + userTime: null, + userSpace: null, + matches: { time: false, space: false }, + }), + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + const body = getBody(); + expect(body).toContain("| | 복잡도 |"); + expect(body).not.toContain("유저 분석"); + expect(body).toContain("💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); + }); + + it("불일치 시 ❌ 표시", async () => { + const getBody = setupFetchAndCapture([ + makeSingleSolutionAnalysis("two-sum", { + userTime: "O(1)", + actualTime: "O(n)", + matches: { time: false, space: true }, + }), + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + const body = getBody(); + expect(body).toContain("❌"); + expect(body).toContain("✅"); + }); + + it("멀티 풀이 → details 접기 포맷", async () => { + const getBody = setupFetchAndCapture([ + { + problemName: "two-sum", + solutions: [ + { + name: "twoSum_bruteForce", + description: "brute force", + hasUserAnnotation: true, + userTime: "O(n²)", + userSpace: "O(1)", + actualTime: "O(n²)", + actualSpace: "O(1)", + matches: { time: true, space: true }, + feedback: "정확합니다!", + suggestion: "HashMap 으로 O(n) 가능", + }, + { + name: "twoSum", + description: "HashMap", + hasUserAnnotation: true, + userTime: "O(n)", + userSpace: "O(n)", + actualTime: "O(n)", + actualSpace: "O(n)", + matches: { time: true, space: true }, + feedback: "최적 풀이!", + suggestion: "현재 구현이 적절해 보입니다.", + }, + ], + }, + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + const body = getBody(); + expect(body).toContain("
"); + expect(body).toContain("
"); + expect(body).toContain("2가지 풀이"); + expect(body).toContain("twoSum_bruteForce"); + expect(body).toContain("twoSum"); + }); + + it("여러 문제 파일 → 각 문제별 섹션으로 출력한다", async () => { + const files = [ + makeSingleSolutionAnalysis("two-sum"), + makeSingleSolutionAnalysis("valid-parentheses"), + ]; + + let capturedBody = null; + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([ + makeSolutionFile("two-sum"), + makeSolutionFile("valid-parentheses"), + ]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() {}"); + } + if (urlStr.includes("openai.com")) { + return makeOpenAIResponse(files); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments")) { + if (method === "GET") return okJson([]); + if (method === "POST") { + capturedBody = JSON.parse(opts.body).body; + return okJson({ id: 1 }); + } + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.analyzed).toBe(2); + expect(capturedBody).toContain("### two-sum"); + expect(capturedBody).toContain("### valid-parentheses"); + }); +}); + +// ── 댓글 upsert ────────────────────────────────── + +describe("analyzeComplexity — 댓글 upsert", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function setupWithExistingComment(existingComments) { + let lastMethod = null; + + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() {}"); + } + if (urlStr.includes("openai.com")) { + return makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments") && method === "GET") { + return okJson(existingComments); + } + if (method === "POST" && urlStr.includes("/comments")) { + lastMethod = "POST"; + return okJson({ id: 999 }); + } + if (method === "PATCH" && urlStr.includes("/comments/")) { + lastMethod = "PATCH"; + return okJson({ id: 123 }); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + return () => lastMethod; + } + + it("기존 복잡도 댓글이 없으면 POST 로 새 댓글을 작성한다", async () => { + const getMethod = setupWithExistingComment([]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(getMethod()).toBe("POST"); + }); + + it("기존 복잡도 댓글이 있으면 PATCH 로 업데이트한다", async () => { + const getMethod = setupWithExistingComment([ + { + id: 123, + user: { type: "Bot" }, + body: `${COMMENT_MARKER}\n이전 분석 내용`, + }, + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(getMethod()).toBe("PATCH"); + }); + + it("Bot 이 아닌 사용자의 마커 댓글은 기존 댓글로 인식하지 않는다", async () => { + const getMethod = setupWithExistingComment([ + { + id: 456, + user: { type: "User" }, + body: `${COMMENT_MARKER}\n수동 작성`, + }, + ]); + + await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(getMethod()).toBe("POST"); + }); +}); + +// ── 파일 크기 제한 ──────────────────────────────── + +describe("analyzeComplexity — 파일 크기 제한", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("MAX_TOTAL_SIZE 를 초과하면 남은 파일을 건너뛴다", async () => { + // MAX_FILE_SIZE=15000 으로 잘리므로 각 파일 15000 바이트 + // 15000 × 4 = 60000 = MAX_TOTAL_SIZE → 4개 통과 + // 15000 × 5 = 75000 > MAX_TOTAL_SIZE → 5번째 스킵 + const bigContent = "x".repeat(20000); // → 15000 으로 잘림 + const files = Array.from({ length: 5 }, (_, i) => makeSolutionFile(`problem-${i}`)); + + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson(files); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(bigContent); + } + if (urlStr.includes("openai.com")) { + return makeOpenAIResponse( + [0, 1, 2, 3].map((i) => makeSingleSolutionAnalysis(`problem-${i}`)) + ); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments")) { + if (method === "GET") return okJson([]); + if (method === "POST") return okJson({ id: 1 }); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + // 4개만 분석됨 (5번째는 다운로드 후 총합 초과로 스킵) + expect(result.total).toBe(4); + }); + + it("개별 파일이 MAX_FILE_SIZE 를 초과하면 잘라서 사용한다", async () => { + // 16000 바이트 파일 → MAX_FILE_SIZE(15000) 으로 잘림 + const hugeContent = "y".repeat(16000); + + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(hugeContent); + } + if (urlStr.includes("openai.com")) { + // OpenAI 에 전달된 content 길이 검증 + const body = JSON.parse(opts.body); + const userContent = body.messages[1].content; + // 잘린 content 가 15000 이하여야 함 + expect(userContent.length).toBeLessThanOrEqual(16000 + 200); // 마커+코드블록 오버헤드 포함 + return makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments")) { + if (method === "GET") return okJson([]); + if (method === "POST") return okJson({ id: 1 }); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + const result = await analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.analyzed).toBe(1); + }); +}); + +// ── PR files API 실패 ───────────────────────────── + +describe("analyzeComplexity — 에러 처리", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("PR files API 가 실패하면 에러를 throw 한다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url.url; + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return failResponse(403); + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + + await expect( + analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ) + ).rejects.toThrow("Failed to list PR files"); + }); + + it("댓글 목록 조회가 실패하면 에러를 throw 한다", async () => { + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes("/pulls/") && urlStr.includes("/files")) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() {}"); + } + if (urlStr.includes("openai.com")) { + return makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]); + } + if (urlStr.includes("/issues/") && urlStr.includes("/comments") && method === "GET") { + return failResponse(500); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + await expect( + analyzeComplexity( + REPO_OWNER, REPO_NAME, PR_NUMBER, + makePrData(), + APP_TOKEN, OPENAI_KEY + ) + ).rejects.toThrow("Failed to list comments"); + }); +}); diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index f78f1c0..04f309b 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -10,6 +10,7 @@ import { generateGitHubAppToken } from "../utils/github.js"; import { errorResponse, corsResponse } from "../utils/cors.js"; import { tagPatterns } from "./tag-patterns.js"; import { postLearningStatus } from "./learning-status.js"; +import { analyzeComplexity } from "./complexity-analysis.js"; const INTERNAL_HEADER = "X-Internal-Secret"; @@ -48,6 +49,9 @@ export async function handleInternalDispatch(request, env, pathname) { case "/internal/learning-status": return await handleLearningStatus(payload, appToken, env); + case "/internal/complexity-analysis": + return await handleComplexityAnalysis(payload, appToken, env); + default: return errorResponse("Not found", 404); } @@ -83,3 +87,16 @@ async function handleLearningStatus(payload, appToken, env) { ); return corsResponse({ handler: "learning-status", result }); } + +async function handleComplexityAnalysis(payload, appToken, env) { + const { repoOwner, repoName, prNumber, prData } = payload; + const result = await analyzeComplexity( + repoOwner, + repoName, + prNumber, + prData, + appToken, + env.OPENAI_API_KEY + ); + return corsResponse({ handler: "complexity-analysis", result }); +} diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index 56e74fc..a7fa98a 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -12,9 +12,14 @@ vi.mock("./learning-status.js", () => ({ postLearningStatus: vi.fn().mockResolvedValue({ posted: true }), })); +vi.mock("./complexity-analysis.js", () => ({ + analyzeComplexity: vi.fn().mockResolvedValue({ analyzed: 1, total: 1 }), +})); + import { handleInternalDispatch } from "./internal-dispatch.js"; import { tagPatterns } from "./tag-patterns.js"; import { postLearningStatus } from "./learning-status.js"; +import { analyzeComplexity } from "./complexity-analysis.js"; import { generateGitHubAppToken } from "../utils/github.js"; const VALID_SECRET = "test-secret-123"; @@ -159,6 +164,39 @@ describe("handleInternalDispatch — 라우팅", () => { expect(tagPatterns).not.toHaveBeenCalled(); }); + it("/internal/complexity-analysis 요청을 analyzeComplexity 로 payload 필드와 함께 라우팅한다", async () => { + const prData = { number: 42, draft: false, labels: [] }; + const request = makeRequest("/internal/complexity-analysis", { + secret: VALID_SECRET, + body: { + repoOwner: "DaleStudy", + repoName: "leetcode-study", + prNumber: 42, + prData, + }, + }); + + const response = await handleInternalDispatch( + request, + env, + "/internal/complexity-analysis" + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.handler).toBe("complexity-analysis"); + expect(analyzeComplexity).toHaveBeenCalledWith( + "DaleStudy", + "leetcode-study", + 42, + prData, + "fake-token", + "fake-openai" + ); + expect(tagPatterns).not.toHaveBeenCalled(); + expect(postLearningStatus).not.toHaveBeenCalled(); + }); + it("알 수 없는 /internal/* 경로는 404 를 반환한다", async () => { const request = makeRequest("/internal/unknown", { secret: VALID_SECRET, @@ -184,7 +222,7 @@ describe("handleInternalDispatch — 에러 처리", () => { vi.clearAllMocks(); }); - it("핸들러가 throw 하면 500 을 반환한다", async () => { + it("tagPatterns 핸들러가 throw 하면 500 을 반환한다", async () => { tagPatterns.mockRejectedValueOnce(new Error("boom")); const request = makeRequest("/internal/tag-patterns", { @@ -208,4 +246,28 @@ describe("handleInternalDispatch — 에러 처리", () => { const body = await response.json(); expect(body.error).toContain("boom"); }); + + it("analyzeComplexity 핸들러가 throw 하면 500 을 반환한다", async () => { + analyzeComplexity.mockRejectedValueOnce(new Error("complexity-error")); + + const request = makeRequest("/internal/complexity-analysis", { + secret: VALID_SECRET, + body: { + repoOwner: "DaleStudy", + repoName: "leetcode-study", + prNumber: 1, + prData: {}, + }, + }); + + const response = await handleInternalDispatch( + request, + env, + "/internal/complexity-analysis" + ); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toContain("complexity-error"); + }); }); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 01abbe6..ca44525 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 이벤트 처리 @@ -324,7 +325,21 @@ async function handlePullRequestEvent(payload, env, ctx) { ) ); - console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`); + // 복잡도 분석 디스패치 + ctx.waitUntil( + fetch(`${baseUrl}/internal/complexity-analysis`, { + method: "POST", + headers: dispatchHeaders, + body: JSON.stringify({ + ...commonPayload, + prData: pr, + }), + }).catch((err) => + console.error(`[dispatch] complexityAnalysis failed: ${err.message}`) + ) + ); + + console.log(`[handlePullRequestEvent] Dispatched 3 AI handlers for PR #${prNumber}`); } else if (env.OPENAI_API_KEY) { // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); @@ -364,6 +379,19 @@ async function handlePullRequestEvent(payload, env, ctx) { } catch (error) { console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`); } + + try { + await analyzeComplexity( + repoOwner, + repoName, + prNumber, + pr, + appToken, + env.OPENAI_API_KEY + ); + } catch (error) { + console.error(`[handlePullRequestEvent] complexity analysis failed: ${error.message}`); + } } return corsResponse({ diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 3a609a8..92054b9 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -264,7 +264,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { }); }); - it("OPENAI_API_KEY, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { + it("OPENAI_API_KEY, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 3 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { OPENAI_API_KEY: "fake-openai", @@ -279,11 +279,12 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { ); expect(response.status).toBe(200); - expect(ctx.waitUntil).toHaveBeenCalledTimes(2); + expect(ctx.waitUntil).toHaveBeenCalledTimes(3); const fetchedUrls = globalThis.fetch.mock.calls.map(([url]) => url); expect(fetchedUrls).toContain("https://worker.test/internal/tag-patterns"); expect(fetchedUrls).toContain("https://worker.test/internal/learning-status"); + expect(fetchedUrls).toContain("https://worker.test/internal/complexity-analysis"); const dispatchCall = globalThis.fetch.mock.calls.find(([url]) => url.endsWith("/internal/tag-patterns") diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index c7c15c2..8013dca 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "bun:test"; import { tagPatterns } from "../handlers/tag-patterns.js"; import { postLearningStatus } from "../handlers/learning-status.js"; +import { analyzeComplexity } from "../handlers/complexity-analysis.js"; const REPO_OWNER = "DaleStudy"; const REPO_NAME = "leetcode-study"; @@ -187,4 +188,76 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( expect(fetchCount).toBe(15); expect(fetchCount).toBeLessThan(50); }); + + it("analyzeComplexity 는 50 회 이하 subrequest 를 호출한다 (예상 9: PR files 1 + 5×raw + OpenAI 1 + 이슈 코멘트 목록 1 + POST 1)", async () => { + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { + return okJson(SOLUTION_FILES); + } + + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("// TC: O(n)\n// SC: O(1)\nfunction solution() { return 0; }"); + } + + if (urlStr.includes("openai.com/v1/chat/completions")) { + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + files: SOLUTION_FILES.map((f, i) => ({ + problemName: `problem-${i + 1}`, + solutions: [ + { + name: "solution", + description: "기본 풀이", + hasUserAnnotation: true, + userTime: "O(n)", + userSpace: "O(1)", + actualTime: "O(n)", + actualSpace: "O(1)", + matches: { time: true, space: true }, + feedback: "정확합니다!", + suggestion: "현재 구현이 적절해 보입니다.", + }, + ], + })), + }), + }, + }, + ], + }); + } + + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 600 }); + } + + throw new Error(`Unexpected fetch in analyzeComplexity mock: ${method} ${urlStr}`); + }); + + const prData = { draft: false, labels: [] }; + const result = await analyzeComplexity( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + prData, + APP_TOKEN, + OPENAI_KEY + ); + + const fetchCount = globalThis.fetch.mock.calls.length; + + expect(result.analyzed).toBe(5); + expect(result.total).toBe(5); + expect(fetchCount).toBe(9); + expect(fetchCount).toBeLessThan(50); + }); });