From c54e1820ca5d7fe957485b7522a114910a5cfbf7 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Wed, 29 Jul 2026 02:11:56 +0900 Subject: [PATCH] =?UTF-8?q?=ED=92=80=EC=9D=B4=20=EC=B6=94=EA=B0=80/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=BB=A4=EB=B0=8B=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EB=B6=84=EC=84=9D=EC=9D=80=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C?= =?UTF-8?q?=EB=A7=8C=20=EB=B6=84=EC=84=9D=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 풀이를 추가하거나 수정해서 커밋하면 기존 분석 댓글을 모두 삭제하고 모든 풀이에 대한 분석 댓글을 다시 달았음. 분석 댓글에서 대화를 주고 받는 경우가 있는데 커밋이 되면 분석 내용이 삭제되고 주고 받은 댓글 일부가 Conversations 탭에서 사라지는 문제가 있었음. 변경된 방식은 풀이를 추가하거나 수정해도 기존 분석 댓글을 유지함. 추가되거나 수정된 풀이에 대해서만 분석하여 댓글을 남김. 분석했던 코드 원본을 함께 분석 댓글에 포함하여 맥락을 파악하는데 용이하도록 함. 기존과 동일한 부분 - PR 오픈과 재오픈한 경우엔 모든 풀이에 대해서 분석 댓글을 남김. - 삭제된 풀이는 분석하지 않음. Test: bun run test Issue: #48 --- AGENTS.md | 2 +- handlers/internal-dispatch.js | 12 ++- handlers/internal-dispatch.test.js | 8 +- handlers/tag-patterns.js | 96 ++++------------- handlers/webhooks.js | 52 ++++++--- handlers/webhooks.test.js | 44 +++++++- tests/subrequest-budget.test.js | 50 ++++----- tests/tag-patterns.test.js | 165 +++++++++++++---------------- 8 files changed, 217 insertions(+), 212 deletions(-) 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..02195fe 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -5,10 +5,10 @@ * 파일별 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"; @@ -95,23 +95,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 +128,7 @@ export async function tagPatterns( } } - // 2-7. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 + // 2-6. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 await deleteLegacyComplexityIssueComment( repoOwner, repoName, prNumber, appToken ); @@ -142,66 +136,6 @@ 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` - ); -} - /** * 단일 파일 분석 + 코멘트 작성 * @@ -233,6 +167,8 @@ async function tagSingleFile( let body = `${COMMENT_MARKER} ### 🏷️ 알고리즘 패턴 분석 +${renderAnalyzedSource(file.filename, fileContent)} + - **패턴**: ${patternsText} - **설명**: ${analysis.description || "(설명 없음)"}`; @@ -273,6 +209,20 @@ async function tagSingleFile( return { patterns: analysis.patterns }; } +function renderAnalyzedSource(filename, content) { + const language = filename.includes(".") ? filename.split(".").pop() : ""; + const truncated = content.length >= MAX_FILE_SIZE ? "\n... (이하 생략)" : ""; + + return `
+${filename} + +\`\`\`${language} +${content}${truncated} +\`\`\` + +
`; +} + /** * 솔루션 파일들의 raw 내용을 한 번에 다운로드한다. * 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다. diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 01abbe6..24034d2 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -235,6 +235,28 @@ 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; + } + + let changedFilenames = null; + + try { + changedFilenames = await getChangedFilenames( + repoOwner, + repoName, + payload.before, + payload.after, + appToken + ); + } catch (error) { + console.error(`[resolveChangedFilenames] failed: ${error.message}`); + } + + return changedFilenames; +} + /** * Pull Request 이벤트 처리 * - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일) @@ -288,6 +310,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 +333,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 +359,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..fc5e230 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -51,10 +51,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 +77,6 @@ function makeFetchMock({ patternResponse = { patterns: ["Two Pointers"], description: "test" }, complexityFiles = null, complexityFails = false, - existingPatternComments = [], existingIssueComments = [], postCapture = null, } = {}) { @@ -110,14 +113,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 +223,31 @@ 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("복잡도 OpenAI 가 실패해도 패턴 댓글은 정상 작성된다", async () => { const posts = []; globalThis.fetch = makeFetchMock({ @@ -366,12 +386,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 +513,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 +542,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); }); });