From 8bf34b198f0bbb4600fe9316d9208b7100b5b662 Mon Sep 17 00:00:00 2001 From: "lkh14011424@gmail.com" Date: Fri, 1 May 2026 12:51:00 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=B3=B5=EC=9E=A1=EB=8F=84=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=EC=9D=84=20=ED=8C=A8=ED=84=B4=20=ED=83=9C?= =?UTF-8?q?=EA=B9=85=20=ED=95=A9=EB=B3=B8=20=EB=8C=93=EA=B8=80=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 복잡도 분석 결과를 별도 issue comment 가 아니라 패턴 태깅의 파일별 review comment 한 곳에 함께 게시한다. 두 분석을 같은 invocation 안에서 병렬 실행하여 두 댓글이 시점 차이로 따로 보이던 문제와 별도 디스패치로 인한 타이밍 의존성을 모두 제거한다. - complexity-analysis: callComplexityAnalysis / renderComplexitySection 만 export 하고 오케스트레이션·issue comment upsert 는 제거 - tag-patterns: 모든 raw 를 사전에 한 번 다운로드해 패턴 분석 루프와 복잡도 OpenAI 1콜이 공유, 댓글 본문에 복잡도 섹션 append - 구버전 단독 복잡도 issue comment 는 첫 동작 시 자동 정리 - webhooks/internal-dispatch: complexity-analysis 디스패치/라우팅 제거 Co-Authored-By: Claude Opus 4.7 --- handlers/complexity-analysis.js | 252 ++----- handlers/complexity-analysis.test.js | 1015 +++++--------------------- handlers/internal-dispatch.js | 17 - handlers/internal-dispatch.test.js | 45 +- handlers/tag-patterns.js | 163 ++++- handlers/webhooks.js | 38 +- handlers/webhooks.test.js | 7 +- tests/subrequest-budget.test.js | 119 ++- tests/tag-patterns.test.js | 615 ++++++++++++++++ 9 files changed, 1021 insertions(+), 1250 deletions(-) create mode 100644 tests/tag-patterns.test.js diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index ffa1893..8fd0fc5 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -1,21 +1,17 @@ /** - * 시간/공간 복잡도 자동 분석. - * PR opened/reopened/synchronize 시 호출된다. + * 시간/공간 복잡도 분석 (분석 함수 + 순수 헬퍼). + * + * 오케스트레이션과 댓글 게시는 tag-patterns 핸들러가 담당하며, 이 모듈은 + * OpenAI 호출(`callComplexityAnalysis`)과 사용자 주석 처리/매칭 헬퍼, + * 한 파일분 섹션 렌더러(`renderComplexitySection`)만 제공한다. * * 책임 분담 (plan v4): * - 코드: 사용자 복잡도 주석 제거 / 추출 / matches 판정. * - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임. */ -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 = "====="; const COMMENT_START_PATTERN = /^(?:\/\/|#|--|;|\/\*|\*(?!\/)|"""|''')/; @@ -244,7 +240,7 @@ export function composeSolution(modelSol, originalContent) { }; } -async function callComplexityAnalysis(fileEntries, apiKey) { +export async function callComplexityAnalysis(fileEntries, apiKey) { const userPrompt = fileEntries .map( (f) => @@ -356,225 +352,59 @@ function buildSolutionBody(solution) { return lines; } -function formatComplexityCommentBody(entries) { +/** + * 한 파일분 복잡도 섹션을 렌더링한다. + * 패턴 태그 코멘트에 묻어가는 형태이므로 마커/푸터 없이 + * `### 📊 ...` 헤더부터 시작한다. + * + * @param {{problemName: string, solutions: Array}} entry + * @returns {string} 렌더링된 섹션 (마지막에 \n 없음) + */ +export function renderComplexitySection(entry) { const lines = []; - lines.push(COMPLEXITY_COMMENT_MARKER); lines.push("### 📊 시간/공간 복잡도 분석"); lines.push(""); - for (const { problemName, solutions } of entries) { - lines.push(`### ${problemName}`); - lines.push(""); + const solutions = entry?.solutions || []; - if (!solutions || solutions.length === 0) { - lines.push(`> ⚠️ 분석 결과가 없습니다.`); - lines.push(""); - continue; - } + if (solutions.length === 0) { + lines.push(`> ⚠️ 분석 결과가 없습니다.`); + return lines.join("\n"); + } - const isMulti = solutions.length > 1; - const hasAnyAnnotationMissing = solutions.some( - (s) => !s.hasUserAnnotation + const isMulti = solutions.length > 1; + const hasAnyAnnotationMissing = solutions.some((s) => !s.hasUserAnnotation); + + if (isMulti) { + lines.push( + `> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.` ); + lines.push(""); - if (isMulti) { + solutions.forEach((sol, idx) => { + const summaryResult = buildSummaryResult(sol); + lines.push(`
`); lines.push( - `> ℹ️ 이 파일에는 **${solutions.length}가지 풀이**가 포함되어 있어 각각 분석합니다.` + `풀이 ${idx + 1}: ${sol.name} — ${summaryResult}` ); 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(...buildSolutionBody(sol)); + 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" }; + lines.push(...buildSolutionBody(solutions[0])); } - // 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 (hasAnyAnnotationMissing) { + lines.push("> 💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); } - if (fileEntries.length === 0) { - return { skipped: "all-downloads-failed" }; + // trailing 빈 줄 제거 + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); } - // 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, - }; + return lines.join("\n"); } + diff --git a/handlers/complexity-analysis.test.js b/handlers/complexity-analysis.test.js index a9b0b9b..b43c1b8 100644 --- a/handlers/complexity-analysis.test.js +++ b/handlers/complexity-analysis.test.js @@ -1,15 +1,6 @@ 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, isComplexityCommentLine, stripComplexityComments, extractBigO, @@ -17,27 +8,11 @@ import { bigOEquals, cleanBigO, composeSolution, + callComplexityAnalysis, + renderComplexitySection, } 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`, - }; -} +const PLAIN_SOURCE = "function solution() { return 0; }"; function okJson(data) { return Promise.resolve({ @@ -49,15 +24,6 @@ function okJson(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, @@ -70,20 +36,10 @@ function failResponse(status = 500) { function makeOpenAIResponse(files) { return okJson({ - choices: [ - { - message: { - content: JSON.stringify({ files }), - }, - }, - ], + choices: [{ message: { content: JSON.stringify({ files }) } }], }); } -/** - * 모델 응답 스키마 v4: name, headerLine, description, actualTime, actualSpace, - * feedback, suggestion 만 사용. user-* / matches 는 코드가 source 에서 계산한다. - */ function makeSingleSolutionAnalysis(problemName, overrides = {}) { return { problemName, @@ -102,14 +58,6 @@ function makeSingleSolutionAnalysis(problemName, overrides = {}) { }; } -// 기본 솔루션 소스: 헤더가 L1 (// TC: O(n) / // SC: O(1) 주석 없음) -const PLAIN_SOURCE = "function solution() { return 0; }"; - -// 사용자 주석이 헤더 위에 있는 소스 (L1 = TC, L2 = SC, L3 = function) -const ANNOTATED_SOURCE = `// TC: O(n) -// SC: O(1) -function solution() { return 0; }`; - // ── 단위 테스트: 사용자 주석 처리 ───────────────── describe("isComplexityCommentLine", () => { @@ -318,8 +266,6 @@ function solution() {}`; class Solution: def cloneGraph(self, node): return None`; - // headerLine=9 → "def cloneGraph" 라인. class 선언(L8)이 투명 통과되어 - // L4-7 의 주석 블록이 추출 대상이 된다. expect(extractUserAnnotations(src, 9)).toEqual({ userTime: "O(V + E)", userSpace: "O(V)", @@ -503,857 +449,246 @@ function solution() {}`; }); }); -// ── skip 조건 ───────────────────────────────────── +// ── callComplexityAnalysis ──────────────────────── -describe("analyzeComplexity — skip 조건", () => { +describe("callComplexityAnalysis", () => { 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}`); - }); + it("정상 응답을 problemName 별로 매핑하여 반환한다", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + await makeOpenAIResponse([ + makeSingleSolutionAnalysis("two-sum"), + makeSingleSolutionAnalysis("valid-parentheses"), + ]) + ); - const result = await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); - - expect(result).toEqual({ skipped: "all-downloads-failed" }); - }); -}); + const fileEntries = [ + { problemName: "two-sum", content: PLAIN_SOURCE }, + { problemName: "valid-parentheses", content: PLAIN_SOURCE }, + ]; -// ── OpenAI 응답 파싱 ────────────────────────────── + const results = await callComplexityAnalysis(fileEntries, "fake-key"); -describe("analyzeComplexity — OpenAI 응답 파싱", () => { - beforeEach(() => { - vi.clearAllMocks(); + expect(results).toHaveLength(2); + expect(results[0].problemName).toBe("two-sum"); + expect(results[1].problemName).toBe("valid-parentheses"); + expect(results[0].solutions[0].actualTime).toBe("O(n)"); }); - function setupFetchWithOpenAI(openaiResponse, sourceContent = PLAIN_SOURCE) { - 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(sourceContent); - } - 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)); + it("OpenAI API 가 실패하면 throw 한다", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce(await failResponse(429)); await expect( - analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY + callComplexityAnalysis( + [{ problemName: "two-sum", content: PLAIN_SOURCE }], + "fake-key" ) ).rejects.toThrow("OpenAI API error"); }); - it("OpenAI 가 빈 choices 를 반환하면 에러를 throw 한다", async () => { - setupFetchWithOpenAI(okJson({ choices: [{ message: {} }] })); + it("빈 응답이면 throw 한다", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(await okJson({ choices: [{ message: {} }] })); await expect( - analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY + callComplexityAnalysis( + [{ problemName: "two-sum", content: PLAIN_SOURCE }], + "fake-key" ) ).rejects.toThrow("Empty response from OpenAI"); }); - it("OpenAI 가 잘못된 JSON 을 반환하면 에러를 throw 한다", async () => { - setupFetchWithOpenAI( - okJson({ choices: [{ message: { content: "not json {{{" } }] }) + it("잘못된 JSON 이면 throw 한다", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + await okJson({ + choices: [{ message: { content: "not json {{{" } }], + }) ); await expect( - analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY + callComplexityAnalysis( + [{ problemName: "two-sum", content: PLAIN_SOURCE }], + "fake-key" ) ).rejects.toThrow("OpenAI returned invalid JSON"); }); - it("OpenAI 응답에 누락된 필드가 있으면 기본값으로 대체한다", async () => { - setupFetchWithOpenAI( - okJson({ - choices: [ - { - message: { - content: JSON.stringify({ - files: [ - { - problemName: "two-sum", - solutions: [ - { - // name, headerLine, description, feedback, suggestion 누락 - actualTime: "O(n)", - actualSpace: "O(1)", - }, - ], - }, - ], - }), - }, - }, - ], - }) + it("모델이 problemName 을 줄여 써도 인덱스로 폴백한다", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + await makeOpenAIResponse([ + // 입력이 longest-... 인데 모델이 long-... 로 잘림 + makeSingleSolutionAnalysis("long-prefix"), + ]) ); - const result = await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const fileEntries = [ + { problemName: "longest-prefix", content: PLAIN_SOURCE }, + ]; - expect(result.analyzed).toBe(1); + const results = await callComplexityAnalysis(fileEntries, "fake-key"); - // 댓글이 POST 되었는지 확인 - const postCall = globalThis.fetch.mock.calls.find( - ([url, opts]) => opts?.method === "POST" && url.includes("/comments") - ); - expect(postCall).toBeDefined(); + expect(results).toHaveLength(1); + expect(results[0].problemName).toBe("longest-prefix"); }); - 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 - ); - - expect(result.analyzed).toBe(0); - expect(result.total).toBe(1); - }); -}); - -// ── User prompt 에 stripped content 가 전달되는지 ─ - -describe("analyzeComplexity — user prompt 에 복잡도 주석 제거 + 라인 번호 적용", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + it("user prompt 에 복잡도 주석 제거 + 라인 번호 prefix 가 포함된다", async () => { + let captured = null; + globalThis.fetch = vi.fn().mockImplementation(async (url, opts) => { + captured = JSON.parse(opts.body).messages[1].content; + return await makeOpenAIResponse([ + makeSingleSolutionAnalysis("two-sum", { headerLine: 3 }), + ]); + }); - it("복잡도 주석은 빈 줄로 치환되어 OpenAI 에 전달된다", async () => { const source = `// TC: O(n) // SC: O(1) function solution() { return 0; }`; - - let capturedUserContent = 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(source); - } - if (urlStr.includes("openai.com")) { - capturedUserContent = JSON.parse(opts.body).messages[1].content; - return makeOpenAIResponse([ - makeSingleSolutionAnalysis("two-sum", { headerLine: 3 }), - ]); - } - 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}`); - }); - - await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); - - // L1 / L2 가 빈 라인으로 prefix 만 있어야 함, L3 은 함수 헤더 - expect(capturedUserContent).toContain("L1: \nL2: \nL3: function solution()"); - // 복잡도 주석 텍스트는 LLM 에 노출되면 안 됨 - expect(capturedUserContent).not.toContain("TC: O(n)"); - expect(capturedUserContent).not.toContain("SC: O(1)"); - }); - - it("일반 주석은 그대로 전달된다", async () => { - const source = `// 일반 설명 주석 -function solution() { return 0; }`; - - let capturedUserContent = 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(source); - } - if (urlStr.includes("openai.com")) { - capturedUserContent = JSON.parse(opts.body).messages[1].content; - return makeOpenAIResponse([ - makeSingleSolutionAnalysis("two-sum", { headerLine: 2 }), - ]); - } - 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}`); - }); - - await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY + await callComplexityAnalysis( + [{ problemName: "two-sum", content: source }], + "fake-key" ); - expect(capturedUserContent).toContain("L1: // 일반 설명 주석"); - expect(capturedUserContent).toContain("L2: function solution()"); + expect(captured).toContain("L1: \nL2: \nL3: function solution()"); + expect(captured).not.toContain("TC: O(n)"); + expect(captured).not.toContain("SC: O(1)"); }); }); -// ── 댓글 포맷 ───────────────────────────────────── +// ── renderComplexitySection ─────────────────────── -describe("analyzeComplexity — 댓글 포맷", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function setupFetchAndCapture(openaiFiles, sourceContent = PLAIN_SOURCE) { - 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(sourceContent); - } - 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", { - headerLine: 3, +describe("renderComplexitySection", () => { + it("단일 풀이 + 유저 주석 있음 → 비교 테이블", () => { + const entry = { + problemName: "two-sum", + 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: "현재 구현이 적절해 보입니다.", + }, ], - ANNOTATED_SOURCE - ); - - 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")], - PLAIN_SOURCE - ); + }; - await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const out = renderComplexitySection(entry); - const body = getBody(); - expect(body).toContain("| | 복잡도 |"); - expect(body).not.toContain("유저 분석"); - expect(body).toContain("💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); + expect(out).toContain("### 📊 시간/공간 복잡도 분석"); + expect(out).toContain("| | 유저 분석 | 실제 분석 | 결과 |"); + expect(out).toContain("✅"); + expect(out).toContain("정확합니다!"); + expect(out).not.toContain("
"); + // 합본 댓글이라 푸터(봇 disclaimer) 가 들어가면 안 됨 + expect(out).not.toContain("자동으로 작성"); + expect(out).not.toContain("🤖"); }); - it("불일치 시 ❌ 표시", async () => { - const mismatchSource = `// tc: O(1) -// sc: O(1) -function solution() {}`; - - const getBody = setupFetchAndCapture( - [ - makeSingleSolutionAnalysis("two-sum", { - headerLine: 3, + it("단일 풀이 + 유저 주석 없음 → 복잡도 단일 테이블 + 권장 안내", () => { + const entry = { + problemName: "two-sum", + solutions: [ + { + name: "solution", + description: "", + hasUserAnnotation: false, + userTime: null, + userSpace: null, actualTime: "O(n)", actualSpace: "O(1)", - }), + matches: { time: false, space: false }, + feedback: "fb", + suggestion: "sg", + }, ], - mismatchSource - ); + }; - await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const out = renderComplexitySection(entry); - const body = getBody(); - expect(body).toContain("❌"); - expect(body).toContain("✅"); + expect(out).toContain("| | 복잡도 |"); + expect(out).not.toContain("유저 분석"); + expect(out).toContain("💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); }); - it("멀티 풀이 → details 접기 포맷", async () => { - const multiSource = `// tc: O(n^2) -// sc: O(1) -function bruteForce() {} - -// tc: O(n) -// sc: O(n) -function hashMap() {}`; - - const getBody = setupFetchAndCapture( - [ + it("멀티 풀이 → details 접기 포맷", () => { + const entry = { + problemName: "two-sum", + solutions: [ { - problemName: "two-sum", - solutions: [ - { - name: "twoSum_bruteForce", - headerLine: 3, - description: "brute force", - actualTime: "O(n^2)", - actualSpace: "O(1)", - feedback: "정확합니다!", - suggestion: "HashMap 으로 O(n) 가능", - }, - { - name: "twoSum", - headerLine: 7, - description: "HashMap", - actualTime: "O(n)", - actualSpace: "O(n)", - feedback: "최적 풀이!", - suggestion: "현재 구현이 적절해 보입니다.", - }, - ], + name: "twoSum_brute", + description: "", + hasUserAnnotation: true, + userTime: "O(n^2)", + userSpace: "O(1)", + actualTime: "O(n^2)", + actualSpace: "O(1)", + matches: { time: true, space: true }, + feedback: "fb1", + suggestion: "HashMap 으로 O(n) 가능", + }, + { + name: "twoSum", + description: "", + hasUserAnnotation: true, + userTime: "O(n)", + userSpace: "O(n)", + actualTime: "O(n)", + actualSpace: "O(n)", + matches: { time: true, space: true }, + feedback: "fb2", + suggestion: "sg2", }, ], - multiSource - ); - - 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(PLAIN_SOURCE); - } - 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"); - }); - - it("멀티 풀이에서 주석 있는 풀이와 없는 풀이가 섞여 있을 때 각자 올바르게 추출된다", async () => { - // 풀이 1: 주석 있음 (헤더 위), 풀이 2: 주석 있음, 풀이 3: 주석 없음 - const mixedSource = `// tc: O(n^4) -// sc: O(n) -const findMin_a = (nums) => Math.min(...nums); - -// tc: O(n^3) -// sc: O(1) -const findMin_b = (nums) => nums[0]; - -const findMin = (nums) => nums[0];`; - - 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("find-min")]); - } - if (urlStr.startsWith("https://raw.example.com/")) { - return okText(mixedSource); - } - if (urlStr.includes("openai.com")) { - return makeOpenAIResponse([ - { - problemName: "find-min", - solutions: [ - { - name: "findMin_a", - headerLine: 3, - description: "Math.min 사용", - actualTime: "O(n)", - actualSpace: "O(n)", - feedback: "fb1", - suggestion: "sg1", - }, - { - name: "findMin_b", - headerLine: 7, - description: "순차 탐색", - actualTime: "O(n)", - actualSpace: "O(1)", - feedback: "fb2", - suggestion: "sg2", - }, - { - name: "findMin", - headerLine: 9, - description: "이진 탐색", - actualTime: "O(log n)", - actualSpace: "O(1)", - feedback: "fb3", - suggestion: "sg3", - }, - ], - }, - ]); - } - 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}`); - }); - - await analyzeComplexity( - REPO_OWNER, REPO_NAME, PR_NUMBER, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); - - expect(capturedBody).toContain("3가지 풀이"); - expect(capturedBody).toContain("findMin_a"); - expect(capturedBody).toContain("findMin_b"); - expect(capturedBody).toContain("findMin"); - // 풀이 1: tc=O(n^4) vs actual=O(n) → 불일치 (❌) - expect(capturedBody).toContain("O(n^4)"); - // 풀이 3: 주석 없음 → "복잡도" 단일 테이블 - expect(capturedBody).toContain("| | 복잡도 |"); - // 주석 없는 풀이가 있으니 권장 안내 - expect(capturedBody).toContain("💡 풀이에 시간/공간 복잡도를 주석으로 남겨보세요!"); - }); -}); - -// ── 댓글 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(PLAIN_SOURCE); - } - 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 - ); + const out = renderComplexitySection(entry); - expect(getMethod()).toBe("POST"); + expect(out).toContain("
"); + expect(out).toContain("
"); + expect(out).toContain("2가지 풀이"); + expect(out).toContain("twoSum_brute"); + expect(out).toContain("twoSum"); }); - 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("solutions 가 비면 분석 결과 없음 메시지", () => { + const out = renderComplexitySection({ problemName: "x", solutions: [] }); + expect(out).toContain("⚠️ 분석 결과가 없습니다."); }); - 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"); + it("entry 가 null/undefined 여도 폭주하지 않는다", () => { + expect(() => renderComplexitySection(null)).not.toThrow(); + expect(() => renderComplexitySection(undefined)).not.toThrow(); }); -}); - -// ── 파일 크기 제한 ──────────────────────────────── -describe("analyzeComplexity — 파일 크기 제한", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("MAX_TOTAL_SIZE 를 초과하면 남은 파일을 건너뛴다", async () => { - const bigContent = "x".repeat(20000); - 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 - ); - - expect(result.total).toBe(4); - }); - - it("개별 파일이 MAX_FILE_SIZE 를 초과하면 잘라서 사용한다", async () => { - 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")) { - const body = JSON.parse(opts.body); - const userContent = body.messages[1].content; - 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(PLAIN_SOURCE); - } - 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"); + it("trailing 빈 줄을 정리한다 (합본 댓글에서 공백 누적 방지)", () => { + const entry = { + problemName: "x", + solutions: [ + { + name: "s", + description: "", + hasUserAnnotation: false, + userTime: null, + userSpace: null, + actualTime: "O(n)", + actualSpace: "O(1)", + matches: { time: false, space: false }, + feedback: "", + suggestion: "", + }, + ], + }; + const out = renderComplexitySection(entry); + expect(out.endsWith("\n")).toBe(false); + expect(out.endsWith("주석으로 남겨보세요!")).toBe(true); }); }); diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index 04f309b..f78f1c0 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -10,7 +10,6 @@ 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"; @@ -49,9 +48,6 @@ 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); } @@ -87,16 +83,3 @@ 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 a7fa98a..946108f 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -12,14 +12,9 @@ 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"; @@ -164,15 +159,14 @@ describe("handleInternalDispatch — 라우팅", () => { expect(tagPatterns).not.toHaveBeenCalled(); }); - it("/internal/complexity-analysis 요청을 analyzeComplexity 로 payload 필드와 함께 라우팅한다", async () => { - const prData = { number: 42, draft: false, labels: [] }; + it("/internal/complexity-analysis 경로는 더 이상 라우팅되지 않고 404 를 반환한다", async () => { const request = makeRequest("/internal/complexity-analysis", { secret: VALID_SECRET, body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 42, - prData, + prData: {}, }, }); @@ -182,17 +176,7 @@ describe("handleInternalDispatch — 라우팅", () => { "/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(response.status).toBe(404); expect(tagPatterns).not.toHaveBeenCalled(); expect(postLearningStatus).not.toHaveBeenCalled(); }); @@ -247,27 +231,4 @@ describe("handleInternalDispatch — 에러 처리", () => { 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/tag-patterns.js b/handlers/tag-patterns.js index ed77f17..36ed773 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -1,15 +1,27 @@ /** * 알고리즘 패턴 태깅 핸들러 * - * PR의 솔루션 파일들을 분석하여 사용된 알고리즘 패턴을 - * 파일별 review comment로 남긴다. + * PR의 솔루션 파일들을 분석하여 사용된 알고리즘 패턴 + 시간/공간 복잡도를 + * 파일별 review comment로 남긴다. 복잡도 분석은 패턴 분석 루프와 병렬로 + * OpenAI 1콜에서 모든 파일을 한 번에 처리하고, 그 결과를 파일별 댓글 + * 본문에 한 섹션 더 붙이는 형태로 묻어간다. + * + * 주의: 솔루션 파일이 12개를 넘으면 subrequest 한도(50)에 가까워진다. + * 기존 패턴 태깅 자체도 13파일 이상에서 한도를 넘는 cliff 가 있으니 + * 복잡도 합본은 그 cliff 를 1파일분(13→12) 당기는 정도다. */ import { getGitHubHeaders } from "../utils/github.js"; import { hasMaintenanceLabel } from "../utils/validation.js"; import { generatePatternAnalysis } from "../utils/openai.js"; +import { + callComplexityAnalysis, + renderComplexitySection, +} from "./complexity-analysis.js"; const COMMENT_MARKER = ""; +// 레거시 단독 복잡도 issue comment 식별용. 새 합본 댓글에는 박지 않는다. +const LEGACY_COMPLEXITY_MARKER = ""; const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치) @@ -89,12 +101,23 @@ export async function tagPatterns( repoOwner, repoName, prNumber, appToken, targetFilenames ); - // 2-4. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑) + // 2-4. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유) + const fileEntries = await downloadFileEntries(solutionFiles); + + // 2-5. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성. + const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey) + .catch((err) => { + console.error(`[tagPatterns] complexity analysis failed: ${err.message}`); + return []; + }); + + // 2-6. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑) const results = []; - for (const file of solutionFiles) { + for (const fe of fileEntries) { try { const result = await tagSingleFile( - file, + fe, + complexityPromise, repoOwner, repoName, prNumber, @@ -102,15 +125,20 @@ export async function tagPatterns( appToken, openaiApiKey ); - results.push({ path: file.filename, ...result }); + results.push({ path: fe.file.filename, ...result }); } catch (error) { console.error( - `[tagPatterns] Failed to tag ${file.filename}: ${error.message}` + `[tagPatterns] Failed to tag ${fe.file.filename}: ${error.message}` ); - results.push({ path: file.filename, error: error.message }); + results.push({ path: fe.file.filename, error: error.message }); } } + // 2-7. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 + await deleteLegacyComplexityIssueComment( + repoOwner, repoName, prNumber, appToken + ); + return { tagged: results.filter((r) => !r.error).length, results }; } @@ -176,9 +204,13 @@ async function deletePreviousPatternComments( /** * 단일 파일 분석 + 코멘트 작성 + * + * @param {{file: object, problemName: string, content: string}} fileEntry + * @param {Promise} complexityPromise - 모든 파일의 복잡도 분석 결과 (병렬 진행) */ async function tagSingleFile( - file, + fileEntry, + complexityPromise, repoOwner, repoName, prNumber, @@ -186,22 +218,7 @@ async function tagSingleFile( appToken, openaiApiKey ) { - // 파일 내용 가져오기 - const contentResponse = await fetch(file.raw_url); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch raw content: ${contentResponse.status}`); - } - - let fileContent = await contentResponse.text(); - if (fileContent.length > MAX_FILE_SIZE) { - fileContent = fileContent.slice(0, MAX_FILE_SIZE); - console.log( - `[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars` - ); - } - - // 폴더명(=문제 이름) 추출 - const problemName = file.filename.split("/")[0]; + const { file, problemName, content: fileContent } = fileEntry; // OpenAI 패턴 분석 const analysis = await generatePatternAnalysis( @@ -213,12 +230,21 @@ async function tagSingleFile( // 코멘트 본문 작성 const patternsText = analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음"; - const body = `${COMMENT_MARKER} + let body = `${COMMENT_MARKER} ### 🏷️ 알고리즘 패턴 분석 - **패턴**: ${patternsText} - **설명**: ${analysis.description || "(설명 없음)"}`; + // 복잡도 섹션을 한 블록 더 붙인다 (해당 파일 결과가 있을 때만, 실패 시 스킵) + const complexityResults = await complexityPromise; + const complexityForFile = complexityResults.find( + (r) => r.problemName === problemName + ); + if (complexityForFile && complexityForFile.solutions.length > 0) { + body += "\n\n" + renderComplexitySection(complexityForFile); + } + // 파일 단위 review comment 작성 const commentResponse = await fetch( `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments`, @@ -246,3 +272,88 @@ async function tagSingleFile( return { patterns: analysis.patterns }; } + +/** + * 솔루션 파일들의 raw 내용을 한 번에 다운로드한다. + * 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다. + */ +async function downloadFileEntries(solutionFiles) { + return Promise.all( + solutionFiles.map(async (file) => { + const res = await fetch(file.raw_url); + if (!res.ok) { + throw new Error( + `Failed to fetch raw content for ${file.filename}: ${res.status}` + ); + } + let content = await res.text(); + if (content.length > MAX_FILE_SIZE) { + content = content.slice(0, MAX_FILE_SIZE); + console.log( + `[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars` + ); + } + return { + file, + problemName: file.filename.split("/")[0], + content, + }; + }) + ); +} + +/** + * 구버전 단독 복잡도 issue comment 가 있으면 삭제한다 (마이그레이션). + * 새 코드는 review comment 에 합본으로 작성하므로 단독 issue comment 는 중복. + */ +async function deleteLegacyComplexityIssueComment( + repoOwner, + repoName, + prNumber, + appToken +) { + const listResponse = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/issues/${prNumber}/comments?per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + + if (!listResponse.ok) { + console.error( + `[tagPatterns] Failed to list issue comments for legacy cleanup: ${listResponse.status}` + ); + return; + } + + const comments = await listResponse.json(); + const legacy = comments.find( + (c) => + c.user?.type === "Bot" && c.body?.includes(LEGACY_COMPLEXITY_MARKER) + ); + + if (!legacy) return; + + try { + const deleteResponse = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/issues/comments/${legacy.id}`, + { + method: "DELETE", + headers: getGitHubHeaders(appToken), + } + ); + + if (!deleteResponse.ok) { + console.error( + `[tagPatterns] Failed to delete legacy complexity comment ${legacy.id}: ${deleteResponse.status}` + ); + return; + } + + console.log( + `[tagPatterns] Deleted legacy complexity issue comment ${legacy.id} on PR #${prNumber}` + ); + } catch (error) { + console.error( + `[tagPatterns] Error deleting legacy complexity comment ${legacy.id}: ${error.message}` + ); + } +} diff --git a/handlers/webhooks.js b/handlers/webhooks.js index df65a4f..01abbe6 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -23,7 +23,6 @@ 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 이벤트 처리 @@ -325,29 +324,7 @@ async function handlePullRequestEvent(payload, env, ctx) { ) ); - // 복잡도 분석 디스패치 - ctx.waitUntil( - (async () => { - try { - const res = await fetch(`${baseUrl}/internal/complexity-analysis`, { - method: "POST", - headers: dispatchHeaders, - body: JSON.stringify({ - ...commonPayload, - prData: pr, - }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - console.error(`[dispatch] complexityAnalysis HTTP ${res.status}: ${text}`); - } - } catch (err) { - console.error(`[dispatch] complexityAnalysis failed: ${err.message}`); - } - })() - ); - - console.log(`[handlePullRequestEvent] Dispatched 3 AI handlers for PR #${prNumber}`); + console.log(`[handlePullRequestEvent] Dispatched 2 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"); @@ -387,19 +364,6 @@ 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 92054b9..957042a 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 3 회를 디스패치한다", async () => { + it("OPENAI_API_KEY, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { OPENAI_API_KEY: "fake-openai", @@ -279,12 +279,13 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { ); expect(response.status).toBe(200); - expect(ctx.waitUntil).toHaveBeenCalledTimes(3); + expect(ctx.waitUntil).toHaveBeenCalledTimes(2); 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"); + // complexity-analysis 는 tag-patterns 에 합본되어 별도 디스패치 없음 + expect(fetchedUrls).not.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 64883c0..b0a1c1c 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -2,7 +2,6 @@ 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"; @@ -42,7 +41,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( vi.clearAllMocks(); }); - it("tagPatterns 는 50 회 이하 subrequest 를 호출한다 (예상 22: files 1 + 코멘트 목록 1 + DELETE 5 + 5×(raw+openai+post))", async () => { + it("tagPatterns 는 50 회 이하 subrequest 를 호출한다 (예상 25: files 1 + raw 5 + 패턴 코멘트 목록 1 + DELETE 5 + 패턴 OpenAI 5 + 복잡도 OpenAI 1 + POST 5 + 레거시 issue 코멘트 목록 1 + 레거시 DELETE 1)", async () => { globalThis.fetch = vi.fn().mockImplementation((url, opts) => { const urlStr = typeof url === "string" ? url : url.url; const method = opts?.method ?? "GET"; @@ -71,6 +70,35 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( } if (urlStr.includes("openai.com/v1/chat/completions")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + files: SOLUTION_FILES.map((_, i) => ({ + problemName: `problem-${i + 1}`, + solutions: [ + { + name: "solution", + headerLine: 1, + actualTime: "O(n)", + actualSpace: "O(1)", + feedback: "fb", + suggestion: "sg", + }, + ], + })), + }), + }, + }, + ], + }); + } return okJson({ choices: [ { @@ -90,6 +118,20 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( return okJson({ id: 999 }); } + // 레거시 단독 복잡도 issue comment 마이그레이션 + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([ + { + id: 9999, + user: { type: "Bot" }, + body: "\n레거시", + }, + ]); + } + if (urlStr.includes("/issues/comments/") && method === "DELETE") { + return okJson({}); + } + throw new Error(`Unexpected fetch in tagPatterns mock: ${method} ${urlStr}`); }); @@ -107,7 +149,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( const fetchCount = globalThis.fetch.mock.calls.length; expect(result.tagged).toBe(5); - expect(fetchCount).toBe(22); + expect(fetchCount).toBe(25); expect(fetchCount).toBeLessThan(50); }); @@ -231,75 +273,4 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( 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); - }); }); diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js new file mode 100644 index 0000000..289a60d --- /dev/null +++ b/tests/tag-patterns.test.js @@ -0,0 +1,615 @@ +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 { tagPatterns } from "../handlers/tag-patterns.js"; + +const REPO_OWNER = "DaleStudy"; +const REPO_NAME = "leetcode-study"; +const PR_NUMBER = 42; +const HEAD_SHA = "head-sha"; +const APP_TOKEN = "fake-app-token"; +const OPENAI_KEY = "fake-openai-key"; + +const PATTERN_MARKER = ""; +const LEGACY_COMPLEXITY_MARKER = ""; + +const PLAIN_SOURCE = "function solution() { return 0; }"; + +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 makeSolutionFile(problemName, username = "testuser") { + return { + filename: `${problemName}/${username}.js`, + status: "added", + raw_url: `https://raw.example.com/${problemName}/${username}.js`, + }; +} + +function makePrData(overrides = {}) { + return { draft: false, labels: [], ...overrides }; +} + +/** + * 두 OpenAI 엔드포인트(패턴 분석 N콜 + 복잡도 분석 1콜)를 분기한다. + * 복잡도 호출은 system_prompt 에 "복잡도" 가 포함되거나 max_tokens >= 1000 으로 식별. + */ +function makeFetchMock({ + solutionFiles, + rawContent = PLAIN_SOURCE, + patternResponse = { patterns: ["Two Pointers"], description: "test" }, + complexityFiles = null, + complexityFails = false, + existingPatternComments = [], + existingIssueComments = [], + postCapture = null, +} = {}) { + return 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(solutionFiles); + } + + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(rawContent); + } + + if (urlStr.includes("openai.com/v1/chat/completions")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + if (complexityFails) return failResponse(500); + const files = complexityFiles ?? []; + return okJson({ + choices: [ + { message: { content: JSON.stringify({ files }) } }, + ], + }); + } + return okJson({ + choices: [ + { message: { content: JSON.stringify(patternResponse) } }, + ], + }); + } + + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson(existingPatternComments); + } + + if (urlStr.includes("/pulls/comments/") && method === "DELETE") { + return okJson({}); + } + + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + const parsed = JSON.parse(opts.body); + if (postCapture) postCapture.push({ path: parsed.path, body: parsed.body }); + return okJson({ id: 999 }); + } + + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson(existingIssueComments); + } + + if (urlStr.includes("/issues/comments/") && method === "DELETE") { + return okJson({}); + } + + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); +} + +describe("tagPatterns — skip 조건", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("draft PR 은 skip 한다", async () => { + globalThis.fetch = vi.fn(); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData({ draft: true }), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "draft" }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("maintenance 라벨이 있으면 skip 한다", async () => { + globalThis.fetch = vi.fn(); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData({ labels: [{ name: "maintenance" }] }), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "maintenance" }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("솔루션 파일이 없으면 skip 한다", async () => { + globalThis.fetch = makeFetchMock({ + solutionFiles: [ + { filename: "README.md", status: "modified", raw_url: "x" }, + ], + }); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result).toEqual({ skipped: "no-solution-files" }); + }); +}); + +describe("tagPatterns — 합본 댓글 (패턴 + 복잡도)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("패턴과 복잡도가 모두 성공하면 합본 댓글에 두 섹션이 모두 들어간다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + complexityFiles: [ + { + problemName: "two-sum", + solutions: [ + { + name: "solution", + headerLine: 1, + actualTime: "O(n)", + actualSpace: "O(1)", + feedback: "정확합니다!", + suggestion: "현재 구현이 적절해 보입니다.", + }, + ], + }, + ], + postCapture: posts, + }); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.tagged).toBe(1); + expect(posts).toHaveLength(1); + expect(posts[0].path).toBe("two-sum/testuser.js"); + expect(posts[0].body).toContain(PATTERN_MARKER); + expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(posts[0].body).toContain("### 📊 시간/공간 복잡도 분석"); + expect(posts[0].body).toContain("정확합니다!"); + // 합본 댓글에는 LEGACY_COMPLEXITY_MARKER 가 들어가지 않는다 + expect(posts[0].body).not.toContain(LEGACY_COMPLEXITY_MARKER); + }); + + it("복잡도 OpenAI 가 실패해도 패턴 댓글은 정상 작성된다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + complexityFails: true, + postCapture: posts, + }); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.tagged).toBe(1); + expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(posts[0].body).not.toContain("### 📊 시간/공간 복잡도 분석"); + }); + + it("복잡도 결과가 비어 있으면 복잡도 섹션 없이 패턴만 게시된다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + complexityFiles: [], // OpenAI 가 빈 files 반환 + postCapture: posts, + }); + + const result = await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(result.tagged).toBe(1); + expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(posts[0].body).not.toContain("### 📊 시간/공간 복잡도 분석"); + }); + + it("여러 파일 — 각자 자기 problemName 의 복잡도 결과가 매핑된다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [ + makeSolutionFile("two-sum"), + makeSolutionFile("valid-parentheses"), + ], + complexityFiles: [ + { + problemName: "two-sum", + solutions: [ + { + name: "twoSum", + headerLine: 1, + actualTime: "O(n)", + actualSpace: "O(n)", + feedback: "two-sum 피드백", + suggestion: "", + }, + ], + }, + { + problemName: "valid-parentheses", + solutions: [ + { + name: "isValid", + headerLine: 1, + actualTime: "O(n)", + actualSpace: "O(n)", + feedback: "valid-parentheses 피드백", + suggestion: "", + }, + ], + }, + ], + postCapture: posts, + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(posts).toHaveLength(2); + + const twoSumPost = posts.find((p) => p.path === "two-sum/testuser.js"); + const validPost = posts.find( + (p) => p.path === "valid-parentheses/testuser.js" + ); + + expect(twoSumPost.body).toContain("two-sum 피드백"); + expect(twoSumPost.body).not.toContain("valid-parentheses 피드백"); + expect(validPost.body).toContain("valid-parentheses 피드백"); + expect(validPost.body).not.toContain("two-sum 피드백"); + }); +}); + +describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레이션", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("LEGACY_COMPLEXITY_MARKER 가 있는 Bot issue comment 를 삭제한다", async () => { + let deletedId = 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/${PR_NUMBER}/files`)) { + return okJson([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(PLAIN_SOURCE); + } + if (urlStr.includes("openai.com")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + return okJson({ + choices: [ + { message: { content: JSON.stringify({ files: [] }) } }, + ], + }); + } + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + patterns: ["Two Pointers"], + description: "x", + }), + }, + }, + ], + }); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + if (urlStr.includes("/pulls/comments/") && method === "DELETE") { + return okJson({}); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 1 }); + } + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([ + { + id: 777, + user: { type: "Bot" }, + body: `${LEGACY_COMPLEXITY_MARKER}\n구버전 댓글`, + }, + ]); + } + if (urlStr.includes("/issues/comments/777") && method === "DELETE") { + deletedId = 777; + return okJson({}); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(deletedId).toBe(777); + }); + + it("레거시 댓글이 없으면 DELETE 호출 없음", async () => { + let deleteCalled = false; + 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([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(PLAIN_SOURCE); + } + if (urlStr.includes("openai.com")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + return okJson({ + choices: [ + { message: { content: JSON.stringify({ files: [] }) } }, + ], + }); + } + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + patterns: [], + description: "", + }), + }, + }, + ], + }); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 1 }); + } + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + if (urlStr.includes("/issues/comments/") && method === "DELETE") { + deleteCalled = true; + return okJson({}); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(deleteCalled).toBe(false); + }); + + it("Bot 이 아닌 사용자가 LEGACY_COMPLEXITY_MARKER 를 포함한 댓글은 건드리지 않는다", async () => { + let deleteCalled = false; + 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([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(PLAIN_SOURCE); + } + if (urlStr.includes("openai.com")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + return okJson({ + choices: [ + { message: { content: JSON.stringify({ files: [] }) } }, + ], + }); + } + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + patterns: [], + description: "", + }), + }, + }, + ], + }); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 1 }); + } + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([ + { + id: 555, + user: { type: "User" }, + body: `${LEGACY_COMPLEXITY_MARKER}\n수동 작성 인용`, + }, + ]); + } + if (urlStr.includes("/issues/comments/") && method === "DELETE") { + deleteCalled = true; + return okJson({}); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(deleteCalled).toBe(false); + }); +}); + +describe("tagPatterns — 기존 패턴 review comment 정리", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("같은 파일의 기존 Bot 패턴 댓글을 DELETE 한다", async () => { + const deletedIds = []; + 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([makeSolutionFile("two-sum")]); + } + if (urlStr.startsWith("https://raw.example.com/")) { + return okText(PLAIN_SOURCE); + } + if (urlStr.includes("openai.com")) { + const body = JSON.parse(opts.body); + const isComplexity = body.messages[0].content.includes( + "시간/공간 복잡도를 분석" + ); + if (isComplexity) { + return okJson({ + choices: [ + { message: { content: JSON.stringify({ files: [] }) } }, + ], + }); + } + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + patterns: [], + description: "", + }), + }, + }, + ], + }); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([ + { + id: 100, + user: { type: "Bot" }, + body: PATTERN_MARKER, + path: "two-sum/testuser.js", + }, + { + id: 101, + user: { type: "Bot" }, + body: PATTERN_MARKER, + path: "valid-parentheses/testuser.js", // 다른 파일 + }, + ]); + } + if (urlStr.includes("/pulls/comments/") && method === "DELETE") { + const m = urlStr.match(/\/comments\/(\d+)/); + if (m) deletedIds.push(Number(m[1])); + return okJson({}); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 1 }); + } + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + // 대상 파일(two-sum)의 기존 댓글만 삭제, 다른 파일은 보존 + expect(deletedIds).toEqual([100]); + }); +});