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