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);
+ });
});