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