From c54e1820ca5d7fe957485b7522a114910a5cfbf7 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Wed, 29 Jul 2026 02:11:56 +0900 Subject: [PATCH 1/4] =?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); }); }); From 31ac045071678518fa7ab50c3b5b2303c6429514 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Sun, 2 Aug 2026 02:30:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?webhooks.js=20resolveChangedFilenames=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=8C=A8=ED=84=B4=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/webhooks.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 24034d2..b43be3c 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -240,10 +240,8 @@ async function resolveChangedFilenames(payload, repoOwner, repoName, appToken) { return null; } - let changedFilenames = null; - try { - changedFilenames = await getChangedFilenames( + return await getChangedFilenames( repoOwner, repoName, payload.before, @@ -252,9 +250,8 @@ async function resolveChangedFilenames(payload, repoOwner, repoName, appToken) { ); } catch (error) { console.error(`[resolveChangedFilenames] failed: ${error.message}`); + return null; } - - return changedFilenames; } /** From f99b314e771d8a4e69cbce31e8dbfbe455703ef5 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Sun, 2 Aug 2026 12:09:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C=EC=9D=84=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=ED=95=B4=20=EC=9E=98=EB=A6=B0=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=83=9D=EB=9E=B5=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit downloadFileEntries가 isContentTruncated 값을 함께 반환하고, 해당 값을 분석 댓글 렌더링에 사용하도록 변경한다. MAX_FILE_SIZE는 파일의 바이트 단위 크기로 오해할 수 있어 문자열 길이 제한임이 드러나도록 MAX_FILE_CONTENT_LENGTH로 변경한다. 생략 문구 표시에 대한 경계값 테스트를 추가한다. --- handlers/tag-patterns.js | 27 +++++++++++++++++---------- tests/tag-patterns.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 02195fe..11002a3 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -23,7 +23,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의 솔루션 파일들에 알고리즘 패턴 태그 달기 @@ -139,7 +139,7 @@ export async function tagPatterns( /** * 단일 파일 분석 + 코멘트 작성 * - * @param {{file: object, problemName: string, content: string}} fileEntry + * @param {{file: object, problemName: string, content: string, isContentTruncated: boolean}} fileEntry * @param {Promise} complexityPromise - 모든 파일의 복잡도 분석 결과 (병렬 진행) */ async function tagSingleFile( @@ -152,7 +152,12 @@ async function tagSingleFile( appToken, openaiApiKey ) { - const { file, problemName, content: fileContent } = fileEntry; + const { + file, + problemName, + content: fileContent, + isContentTruncated, + } = fileEntry; // OpenAI 패턴 분석 const analysis = await generatePatternAnalysis( @@ -167,7 +172,7 @@ async function tagSingleFile( let body = `${COMMENT_MARKER} ### 🏷️ 알고리즘 패턴 분석 -${renderAnalyzedSource(file.filename, fileContent)} +${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} - **패턴**: ${patternsText} - **설명**: ${analysis.description || "(설명 없음)"}`; @@ -209,15 +214,15 @@ ${renderAnalyzedSource(file.filename, fileContent)} return { patterns: analysis.patterns }; } -function renderAnalyzedSource(filename, content) { +function renderAnalyzedSource(filename, content, isContentTruncated) { const language = filename.includes(".") ? filename.split(".").pop() : ""; - const truncated = content.length >= MAX_FILE_SIZE ? "\n... (이하 생략)" : ""; + const truncationNotice = isContentTruncated ? "\n... (이하 생략)" : ""; return `
${filename} \`\`\`${language} -${content}${truncated} +${content}${truncationNotice} \`\`\`
`; @@ -237,16 +242,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/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index fc5e230..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; }"; @@ -248,6 +249,43 @@ ${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({ From 0a3e5d80f19e2f5c60fedf6cca4b1ef872387da3 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Sun, 2 Aug 2026 16:49:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=EC=9D=98=20=EB=B0=B1=ED=8B=B1=EA=B3=BC=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20fence=20=EA=B8=B8=EC=9D=B4=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 내용에 포함된 가장 긴 연속 백틱보다 긴 fence를 생성하여 분석 댓글의 코드 블록이 중간에 닫히지 않도록 한다. --- handlers/tag-patterns.js | 6 ++++-- utils/markdown.js | 22 ++++++++++++++++++++++ utils/markdown.test.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 utils/markdown.js create mode 100644 utils/markdown.test.js diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 11002a3..db9fe0c 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -12,6 +12,7 @@ */ 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 { @@ -217,13 +218,14 @@ ${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} function renderAnalyzedSource(filename, content, isContentTruncated) { const language = filename.includes(".") ? filename.split(".").pop() : ""; const truncationNotice = isContentTruncated ? "\n... (이하 생략)" : ""; + const codeFence = createCodeFence(content); return `
${filename} -\`\`\`${language} +${codeFence}${language} ${content}${truncationNotice} -\`\`\` +${codeFence}
`; } 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)); + } + ); +});