diff --git a/AGENTS.md b/AGENTS.md index 269f4bb..ef3e2d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,7 +266,7 @@ GitHub webhook - `INTERNAL_SECRET`과 `WORKER_URL`이 모두 설정되어야 활성화된다. 둘 중 하나라도 없으면 기존처럼 같은 invocation에서 순차 실행(subrequest 예산 공유)되어 파일이 많은 PR에서 예산을 초과할 수 있다. - 내부 엔드포인트는 `/internal/tag-patterns`, `/internal/learning-status`이며 `X-Internal-Secret` 헤더로 인증한다. -- 참고: `tests/subrequest-budget.test.js`가 5개 파일 변경 시나리오에서 각 핸들러의 fetch 호출 수(각각 22, 15회)를 회귀 테스트로 박아둔다. +- 참고: `tests/subrequest-budget.test.js`가 5개 파일 변경 시나리오에서 각 핸들러의 fetch 호출 수(`tagPatterns` 19회, `postLearningStatus` 31회)를 회귀 테스트로 박아둔다. ## 보안 및 권한 diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index f78f1c0..47a4e0c 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -58,7 +58,14 @@ export async function handleInternalDispatch(request, env, pathname) { } async function handleTagPatterns(payload, appToken, env) { - const { repoOwner, repoName, prNumber, headSha, prData } = payload; + const { + repoOwner, + repoName, + prNumber, + headSha, + prData, + changedFilenames, + } = payload; const result = await tagPatterns( repoOwner, repoName, @@ -66,7 +73,8 @@ async function handleTagPatterns(payload, appToken, env) { headSha, prData, appToken, - env.OPENAI_API_KEY + env.OPENAI_API_KEY, + changedFilenames ); return corsResponse({ handler: "tag-patterns", result }); } diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index 6522889..5ba8fef 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -96,6 +96,10 @@ describe("handleInternalDispatch — 라우팅", () => { it("/internal/tag-patterns 요청을 tagPatterns 로 payload 필드와 함께 라우팅한다", async () => { const prData = { number: 42, head: { sha: "abc123" } }; + const changedFilenames = [ + "two-sum/testuser.js", + "valid-parentheses/testuser.js", + ]; const request = makeRequest("/internal/tag-patterns", { secret: VALID_SECRET, body: { @@ -104,6 +108,7 @@ describe("handleInternalDispatch — 라우팅", () => { prNumber: 42, headSha: "abc123", prData, + changedFilenames, }, }); @@ -123,7 +128,8 @@ describe("handleInternalDispatch — 라우팅", () => { "abc123", prData, "fake-token", - "fake-openai" + "fake-openai", + changedFilenames ); expect(postLearningStatus).not.toHaveBeenCalled(); }); diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 36ed773..db9fe0c 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -5,13 +5,14 @@ * 파일별 review comment로 남긴다. 복잡도 분석은 패턴 분석 루프와 병렬로 * OpenAI 1콜에서 모든 파일을 한 번에 처리하고, 그 결과를 파일별 댓글 * 본문에 한 섹션 더 붙이는 형태로 묻어간다. + * 재실행 시 기존 패턴 댓글과 답글은 보존하고, 변경된 파일에 새 분석 댓글을 추가한다. * - * 주의: 솔루션 파일이 12개를 넘으면 subrequest 한도(50)에 가까워진다. - * 기존 패턴 태깅 자체도 13파일 이상에서 한도를 넘는 cliff 가 있으니 - * 복잡도 합본은 그 cliff 를 1파일분(13→12) 당기는 정도다. + * 주의: Workers Free 플랜의 외부 subrequest 한도는 invocation당 50회이므로 준수해야 한다. + * @see https://developers.cloudflare.com/workers/platform/limits/#subrequests */ import { getGitHubHeaders } from "../utils/github.js"; +import { createCodeFence } from "../utils/markdown.js"; import { hasMaintenanceLabel } from "../utils/validation.js"; import { generatePatternAnalysis } from "../utils/openai.js"; import { @@ -23,7 +24,7 @@ const COMMENT_MARKER = ""; // 레거시 단독 복잡도 issue comment 식별용. 새 합본 댓글에는 박지 않는다. const LEGACY_COMPLEXITY_MARKER = ""; const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; -const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치) +const MAX_FILE_CONTENT_LENGTH = 20000; // OpenAI 입력 크기 안전장치 /** * PR의 솔루션 파일들에 알고리즘 패턴 태그 달기 @@ -95,23 +96,17 @@ export async function tagPatterns( return { skipped: "no-solution-files" }; } - // 2-3. 기존 Bot 패턴 태그 코멘트 삭제 (변경 파일만) - const targetFilenames = solutionFiles.map((f) => f.filename); - await deletePreviousPatternComments( - repoOwner, repoName, prNumber, appToken, targetFilenames - ); - - // 2-4. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유) + // 2-3. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유) const fileEntries = await downloadFileEntries(solutionFiles); - // 2-5. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성. + // 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성. const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey) .catch((err) => { console.error(`[tagPatterns] complexity analysis failed: ${err.message}`); return []; }); - // 2-6. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑) + // 2-5. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑) const results = []; for (const fe of fileEntries) { try { @@ -134,7 +129,7 @@ export async function tagPatterns( } } - // 2-7. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 + // 2-6. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 await deleteLegacyComplexityIssueComment( repoOwner, repoName, prNumber, appToken ); @@ -142,70 +137,10 @@ export async function tagPatterns( return { tagged: results.filter((r) => !r.error).length, results }; } -/** - * 기존 Bot 패턴 태그 코멘트 삭제 (대상 파일만, 다른 사용자 코멘트는 절대 건드리지 않음) - * - * @param {string[]} targetFilenames - 삭제 대상 파일명 목록 - */ -async function deletePreviousPatternComments( - repoOwner, - repoName, - prNumber, - appToken, - targetFilenames -) { - const response = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?per_page=100`, - { headers: getGitHubHeaders(appToken) } - ); - - if (!response.ok) { - console.error( - `[tagPatterns] Failed to fetch review comments: ${response.status}` - ); - return; - } - - const comments = await response.json(); - const targetSet = new Set(targetFilenames); - const botPatternComments = comments.filter( - (c) => - c.user?.type === "Bot" && - c.body?.includes(COMMENT_MARKER) && - targetSet.has(c.path) - ); - - for (const comment of botPatternComments) { - try { - const deleteResponse = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/comments/${comment.id}`, - { - method: "DELETE", - headers: getGitHubHeaders(appToken), - } - ); - - if (!deleteResponse.ok) { - console.error( - `[tagPatterns] Failed to delete comment ${comment.id}: ${deleteResponse.status}` - ); - } - } catch (error) { - console.error( - `[tagPatterns] Error deleting comment ${comment.id}: ${error.message}` - ); - } - } - - console.log( - `[tagPatterns] Deleted ${botPatternComments.length} previous pattern comments for ${targetFilenames.length} files` - ); -} - /** * 단일 파일 분석 + 코멘트 작성 * - * @param {{file: object, problemName: string, content: string}} fileEntry + * @param {{file: object, problemName: string, content: string, isContentTruncated: boolean}} fileEntry * @param {Promise} complexityPromise - 모든 파일의 복잡도 분석 결과 (병렬 진행) */ async function tagSingleFile( @@ -218,7 +153,12 @@ async function tagSingleFile( appToken, openaiApiKey ) { - const { file, problemName, content: fileContent } = fileEntry; + const { + file, + problemName, + content: fileContent, + isContentTruncated, + } = fileEntry; // OpenAI 패턴 분석 const analysis = await generatePatternAnalysis( @@ -233,6 +173,8 @@ async function tagSingleFile( let body = `${COMMENT_MARKER} ### 🏷️ 알고리즘 패턴 분석 +${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} + - **패턴**: ${patternsText} - **설명**: ${analysis.description || "(설명 없음)"}`; @@ -273,6 +215,21 @@ async function tagSingleFile( return { patterns: analysis.patterns }; } +function renderAnalyzedSource(filename, content, isContentTruncated) { + const language = filename.includes(".") ? filename.split(".").pop() : ""; + const truncationNotice = isContentTruncated ? "\n... (이하 생략)" : ""; + const codeFence = createCodeFence(content); + + return `
+${filename} + +${codeFence}${language} +${content}${truncationNotice} +${codeFence} + +
`; +} + /** * 솔루션 파일들의 raw 내용을 한 번에 다운로드한다. * 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다. @@ -287,16 +244,18 @@ async function downloadFileEntries(solutionFiles) { ); } let content = await res.text(); - if (content.length > MAX_FILE_SIZE) { - content = content.slice(0, MAX_FILE_SIZE); + const isContentTruncated = content.length > MAX_FILE_CONTENT_LENGTH; + if (isContentTruncated) { + content = content.slice(0, MAX_FILE_CONTENT_LENGTH); console.log( - `[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars` + `[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_CONTENT_LENGTH} chars` ); } return { file, problemName: file.filename.split("/")[0], content, + isContentTruncated, }; }) ); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 01abbe6..b43be3c 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -235,6 +235,25 @@ async function getChangedFilenames(repoOwner, repoName, baseSha, headSha, appTok return (data.files || []).map((f) => f.filename); } +async function resolveChangedFilenames(payload, repoOwner, repoName, appToken) { + if (payload.action !== "synchronize" || !payload.before || !payload.after) { + return null; + } + + try { + return await getChangedFilenames( + repoOwner, + repoName, + payload.before, + payload.after, + appToken + ); + } catch (error) { + console.error(`[resolveChangedFilenames] failed: ${error.message}`); + return null; + } +} + /** * Pull Request 이벤트 처리 * - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일) @@ -288,6 +307,13 @@ async function handlePullRequestEvent(payload, env, ctx) { if (env.OPENAI_API_KEY && env.INTERNAL_SECRET && env.WORKER_URL) { const baseUrl = env.WORKER_URL; + const changedFilenames = await resolveChangedFilenames( + payload, + repoOwner, + repoName, + appToken + ); + const dispatchHeaders = { "Content-Type": "application/json", "X-Internal-Secret": env.INTERNAL_SECRET, @@ -304,6 +330,7 @@ async function handlePullRequestEvent(payload, env, ctx) { ...commonPayload, headSha: pr.head.sha, prData: pr, + changedFilenames, }), }).catch((err) => console.error(`[dispatch] tagPatterns failed: ${err.message}`) @@ -329,22 +356,14 @@ async function handlePullRequestEvent(payload, env, ctx) { // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); - try { - // synchronize일 때만 변경 파일 목록 추출 (최적화: #7) - let changedFilenames = null; - if (action === "synchronize" && payload.before && payload.after) { - changedFilenames = await getChangedFilenames( - repoOwner, - repoName, - payload.before, - payload.after, - appToken - ); - console.log( - `[handlePullRequestEvent] synchronize: ${changedFilenames?.length ?? "fallback(all)"} files changed between ${payload.before.slice(0, 7)}...${payload.after.slice(0, 7)}` - ); - } + const changedFilenames = await resolveChangedFilenames( + payload, + repoOwner, + repoName, + appToken + ); + try { await tagPatterns( repoOwner, repoName, diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 8064a82..88bfcdb 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -239,6 +239,8 @@ describe("webhook 저장소 필터링", () => { describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { const basePRPayload = { action: "synchronize", + before: "base-sha", + after: "head-sha", organization: { login: "DaleStudy" }, repository: { name: "leetcode-study", @@ -260,7 +262,13 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { vi.clearAllMocks(); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ files: [] }), + json: () => + Promise.resolve({ + files: [ + { filename: "two-sum/testuser.js" }, + { filename: "valid-parentheses/testuser.js" }, + ], + }), }); }); @@ -291,6 +299,10 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { url.endsWith("/internal/tag-patterns") ); expect(dispatchCall[1].headers["X-Internal-Secret"]).toBe("fake-secret"); + expect(JSON.parse(dispatchCall[1].body).changedFilenames).toEqual([ + "two-sum/testuser.js", + "valid-parentheses/testuser.js", + ]); expect(tagPatterns).not.toHaveBeenCalled(); expect(postLearningStatus).not.toHaveBeenCalled(); @@ -357,4 +369,34 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(tagPatterns).not.toHaveBeenCalled(); expect(postLearningStatus).not.toHaveBeenCalled(); }); + + it("변경 파일 목록 조회에 실패하면 null로 폴백한다", async () => { + const ctx = makeCtx(); + const env = { + OPENAI_API_KEY: "fake-openai", + INTERNAL_SECRET: "fake-secret", + WORKER_URL: "https://worker.test", + }; + + globalThis.fetch = vi.fn().mockImplementation((url) => { + if (url.includes("/compare/")) { + return Promise.reject(new Error("network failure")); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + await handleWebhook( + makeRequest("pull_request", basePRPayload), + env, + ctx + ); + + const tagPatternsDispatch = globalThis.fetch.mock.calls.find(([url]) => + url.endsWith("/internal/tag-patterns") + ); + expect(tagPatternsDispatch).toBeDefined(); + + const payload = JSON.parse(tagPatternsDispatch[1].body); + expect(payload.changedFilenames).toBeNull(); + }); }); diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index ca1dbee..f5593dd 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -11,11 +11,13 @@ const USERNAME = "testuser"; const APP_TOKEN = "fake-app-token"; const OPENAI_KEY = "fake-openai-key"; -const SOLUTION_FILES = Array.from({ length: 5 }, (_, i) => ({ - filename: `problem-${i + 1}/${USERNAME}.ts`, - status: "added", - raw_url: `https://raw.example.com/problem-${i + 1}/${USERNAME}.ts`, -})); +function makeSolutionFiles(count) { + return Array.from({ length: count }, (_, i) => ({ + filename: `problem-${i + 1}/${USERNAME}.ts`, + status: "added", + raw_url: `https://raw.example.com/problem-${i + 1}/${USERNAME}.ts`, + })); +} function okJson(data) { return Promise.resolve({ @@ -36,33 +38,20 @@ function okText(text) { }); } -describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", () => { +describe("subrequest 예산 — 핸들러별 invocation", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("tagPatterns 는 50 회 이하 subrequest 를 호출한다 (예상 25: files 1 + raw 5 + 패턴 코멘트 목록 1 + DELETE 5 + 패턴 OpenAI 5 + 복잡도 OpenAI 1 + POST 5 + 레거시 issue 코멘트 목록 1 + 레거시 DELETE 1)", async () => { + it("tagPatterns 는 변경 파일 15개에서 50회 이하 subrequest를 호출한다 (예상 49: files 1 + raw 15 + 패턴 OpenAI 15 + 복잡도 OpenAI 1 + POST 15 + 레거시 issue 코멘트 목록 1 + 레거시 DELETE 1)", async () => { + const solutionFiles = makeSolutionFiles(15); + 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.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return okJson( - SOLUTION_FILES.map((f, i) => ({ - id: 1000 + i, - user: { type: "Bot" }, - body: "", - path: f.filename, - })) - ); - } - - if (urlStr.includes("/pulls/comments/") && method === "DELETE") { - return okJson({}); + return okJson(solutionFiles); } if (urlStr.startsWith("https://raw.example.com/")) { @@ -80,7 +69,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( { message: { content: JSON.stringify({ - files: SOLUTION_FILES.map((_, i) => ({ + files: solutionFiles.map((_, i) => ({ problemName: `problem-${i + 1}`, solutions: [ { @@ -148,14 +137,15 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( const fetchCount = globalThis.fetch.mock.calls.length; - expect(result.tagged).toBe(5); - expect(fetchCount).toBe(25); - expect(fetchCount).toBeLessThan(50); + expect(result.tagged).toBe(15); + expect(fetchCount).toBe(49); + expect(fetchCount).toBeLessThanOrEqual(50); }); it("postLearningStatus 는 50 회 이하 subrequest 를 호출한다 (예상 31: categories 1 + GraphQL project 1 + GraphQL items 1 + cohort PR files 15 + PR files 1 + 5×(raw+openai) + 이슈 코멘트 목록 1 + POST 1)", async () => { + const solutionFiles = makeSolutionFiles(5); const categories = Object.fromEntries( - SOLUTION_FILES.map((_, i) => [ + solutionFiles.map((_, i) => [ `problem-${i + 1}`, { difficulty: "Easy", @@ -219,11 +209,11 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( } if (COHORT_PR_NUMBERS.some((n) => urlStr.includes(`/pulls/${n}/files`))) { - return okJson(SOLUTION_FILES); + return okJson(solutionFiles); } if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { - return okJson(SOLUTION_FILES); + return okJson(solutionFiles); } if (urlStr.startsWith("https://raw.example.com/")) { diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index 30b4c49..2a331dd 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -19,6 +19,7 @@ const OPENAI_KEY = "fake-openai-key"; const PATTERN_MARKER = ""; const LEGACY_COMPLEXITY_MARKER = ""; +const MAX_FILE_CONTENT_LENGTH = 20000; const PLAIN_SOURCE = "function solution() { return 0; }"; @@ -51,10 +52,14 @@ function failResponse(status = 500) { }); } -function makeSolutionFile(problemName, username = "testuser") { +function makeSolutionFile( + problemName, + username = "testuser", + status = "added" +) { return { filename: `${problemName}/${username}.js`, - status: "added", + status, raw_url: `https://raw.example.com/${problemName}/${username}.js`, }; } @@ -73,7 +78,6 @@ function makeFetchMock({ patternResponse = { patterns: ["Two Pointers"], description: "test" }, complexityFiles = null, complexityFails = false, - existingPatternComments = [], existingIssueComments = [], postCapture = null, } = {}) { @@ -110,14 +114,6 @@ function makeFetchMock({ }); } - 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 }); @@ -228,6 +224,68 @@ describe("tagPatterns — 합본 댓글 (패턴 + 복잡도)", () => { expect(posts[0].body).not.toContain(LEGACY_COMPLEXITY_MARKER); }); + it("분석 대상 코드를 첨부한다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + postCapture: posts, + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + const body = posts[0].body; + + expect(body).toContain(`
+two-sum/testuser.js + +\`\`\`js +${PLAIN_SOURCE} +\`\`\` + +
`); + }); + + it.each([MAX_FILE_CONTENT_LENGTH - 1, MAX_FILE_CONTENT_LENGTH])( + "파일 내용 길이가 제한값 이하인 경우(%i자) 생략 문구를 표시하지 않는다", + async (contentLength) => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + rawContent: "a".repeat(contentLength), + postCapture: posts, + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(posts[0].body).not.toContain("... (이하 생략)"); + } + ); + + it("파일 내용 길이가 제한값을 초과하면 생략 문구를 표시한다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + rawContent: "a".repeat(MAX_FILE_CONTENT_LENGTH + 1), + postCapture: posts, + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(posts[0].body).toContain("... (이하 생략)"); + }); + it("복잡도 OpenAI 가 실패해도 패턴 댓글은 정상 작성된다", async () => { const posts = []; globalThis.fetch = makeFetchMock({ @@ -366,12 +424,6 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 ], }); } - 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 }); } @@ -499,9 +551,6 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 ], }); } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([]); - } if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { return okJson({ id: 1 }); } @@ -531,85 +580,61 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 }); }); -describe("tagPatterns — 기존 패턴 review comment 정리", () => { +describe("tagPatterns — 분석 대상 파일 선택", () => { 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}`); + it.each([ + [ + "변경 파일 목록이 전달되지 않으면 PR의 모든 풀이 파일을 분석한다", + { + solutionFiles: [ + makeSolutionFile("a", "user"), + makeSolutionFile("b", "user"), + ], + changedFilenames: null, + expectedFilenames: ["a/user.js", "b/user.js"], + }, + ], + [ + "변경 파일 목록이 있으면 해당 파일만 분석한다", + { + solutionFiles: [ + makeSolutionFile("a", "user"), + makeSolutionFile("b", "user"), + ], + changedFilenames: ["b/user.js"], + expectedFilenames: ["b/user.js"], + }, + ], + [ + "변경 파일 중 삭제된 파일은 분석하지 않는다", + { + solutionFiles: [makeSolutionFile("a", "user", "removed")], + changedFilenames: ["a/user.js"], + expectedFilenames: [], + }, + ], + ])("%s", async (_, testCase) => { + const { + solutionFiles, + changedFilenames, + expectedFilenames, + } = testCase; + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles, + postCapture: posts, }); await tagPatterns( REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, makePrData(), - APP_TOKEN, OPENAI_KEY + APP_TOKEN, OPENAI_KEY, + changedFilenames ); - // 대상 파일(two-sum)의 기존 댓글만 삭제, 다른 파일은 보존 - expect(deletedIds).toEqual([100]); + expect(posts.map(({ path }) => path)).toEqual(expectedFilenames); }); }); diff --git a/utils/markdown.js b/utils/markdown.js new file mode 100644 index 0000000..11f29f0 --- /dev/null +++ b/utils/markdown.js @@ -0,0 +1,22 @@ +/** + * GitHub Flavored Markdown(GFM) 유틸리티 + * + * @see https://github.github.com/gfm/ + */ + +/** + * 콘텐츠 내부의 연속된 백틱보다 긴 GFM 코드 fence를 생성한다. + * + * @param {string} content + * @returns {string} + * @see https://github.github.com/gfm/#fenced-code-blocks + */ +export function createCodeFence(content) { + const longestBacktickLength = (content.match(/`+/g) ?? []).reduce( + (maxLength, backticks) => Math.max(maxLength, backticks.length), + 0 + ); + + const fenceLength = Math.max(3, longestBacktickLength + 1); + return "`".repeat(fenceLength); +} diff --git a/utils/markdown.test.js b/utils/markdown.test.js new file mode 100644 index 0000000..677c6a2 --- /dev/null +++ b/utils/markdown.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; + +import { createCodeFence } from "./markdown.js"; + +const backticks = (count) => "`".repeat(count); + +describe("createCodeFence", () => { + it.each([ + ["없으면", "plain text"], + ["1개면", backticks(1)], + ["2개면", backticks(2)], + ])("연속 백틱이 %s 길이 3인 fence를 반환한다", (_case, content) => { + expect(createCodeFence(content)).toBe(backticks(3)); + }); + + it.each([3, 4, 5, 6])( + "연속 백틱이 %i개면 하나 더 긴 fence를 반환한다", + (count) => { + expect(createCodeFence(backticks(count))).toBe(backticks(count + 1)); + } + ); + + it.each([ + `${backticks(5)}\n\n${backticks(3)}`, + `${backticks(3)}\n\n${backticks(5)}\n\n${backticks(3)}`, + `${backticks(3)}\n\n${backticks(5)}`, + ])( + "여러 묶음은 위치와 상관없이 가장 긴 연속 백틱을 기준으로 한다", + (content) => { + expect(createCodeFence(content)).toBe(backticks(6)); + } + ); +});