diff --git a/AGENTS.md b/AGENTS.md index 269f4bb..ebbf146 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` 20회, `postLearningStatus` 31회)를 회귀 테스트로 박아둔다. ## 보안 및 권한 diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index f78f1c0..f060b78 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -58,7 +58,13 @@ 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, + } = payload; const result = await tagPatterns( repoOwner, repoName, diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 36ed773..dde7e73 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -5,13 +5,18 @@ * 파일별 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 { + parseFileShaMarker, + renderFileShaMarker, +} from "../utils/commentMarker.js"; 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 +28,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의 솔루션 파일들에 알고리즘 패턴 태그 달기 @@ -35,7 +40,6 @@ const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치) * @param {object} prData - PR 객체 (draft, labels 포함) * @param {string} appToken - GitHub App installation token * @param {string} openaiApiKey - * @param {string[]|null} [changedFilenames=null] - synchronize 시 변경된 파일명 목록 (null이면 전체 분석) */ export async function tagPatterns( repoOwner, @@ -44,8 +48,7 @@ export async function tagPatterns( headSha, prData, appToken, - openaiApiKey, - changedFilenames = null + openaiApiKey ) { // 2-1. Skip 조건 if (prData.draft === true) { @@ -78,13 +81,20 @@ export async function tagPatterns( SOLUTION_PATH_REGEX.test(f.filename) ); - // changedFilenames가 제공되면 해당 파일만 대상으로 좁힘 (synchronize 최적화) - if (changedFilenames !== null) { - const changedSet = new Set(changedFilenames); - solutionFiles = solutionFiles.filter((f) => changedSet.has(f.filename)); - console.log( - `[tagPatterns] PR #${prNumber}: narrowed to ${solutionFiles.length} changed solution files` - ); + if (solutionFiles.length === 0) { + return { skipped: "no-solution-files" }; + } + + solutionFiles = await getUnanalyzedOrChangedFiles( + repoOwner, + repoName, + prNumber, + appToken, + solutionFiles + ); + + if (solutionFiles === null) { + return { skipped: "review-comments-unavailable" }; } console.log( @@ -95,23 +105,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 +138,7 @@ export async function tagPatterns( } } - // 2-7. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 + // 2-6. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 await deleteLegacyComplexityIssueComment( repoOwner, repoName, prNumber, appToken ); @@ -142,70 +146,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 +162,12 @@ async function tagSingleFile( appToken, openaiApiKey ) { - const { file, problemName, content: fileContent } = fileEntry; + const { + file, + problemName, + content: fileContent, + isContentTruncated, + } = fileEntry; // OpenAI 패턴 분석 const analysis = await generatePatternAnalysis( @@ -231,7 +180,9 @@ async function tagSingleFile( const patternsText = analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음"; let body = `${COMMENT_MARKER} -### 🏷️ 알고리즘 패턴 분석 +${file.sha ? `${renderFileShaMarker(file.sha)}\n` : ""}### 🏷️ 알고리즘 패턴 분석 + +${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} - **패턴**: ${patternsText} - **설명**: ${analysis.description || "(설명 없음)"}`; @@ -273,6 +224,108 @@ 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} + +
`; +} + +async function fetchReviewCommentsNewestFirst( + repoOwner, + repoName, + prNumber, + appToken +) { + try { + const response = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?sort=created&direction=desc&per_page=100`, + { headers: getGitHubHeaders(appToken) } + ); + + if (!response.ok) { + throw new Error(`GitHub API responded with ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error( + `[tagPatterns] Failed to load review comments: ${error.message}` + ); + return null; + } +} + +function extractLatestAnalyzedFileShas(commentsNewestFirst) { + const latestAnalyzedFileShas = new Map(); + + for (const comment of commentsNewestFirst) { + const hasLatestAnalysisForFile = latestAnalyzedFileShas.has(comment.path); + const isTopLevelComment = comment.in_reply_to_id == null; + const isBotPatternAnalysisComment = + comment.user?.type === "Bot" && + comment.body?.includes(COMMENT_MARKER); + + if ( + !hasLatestAnalysisForFile && + isTopLevelComment && + isBotPatternAnalysisComment + ) { + latestAnalyzedFileShas.set( + comment.path, + parseFileShaMarker(comment.body) + ); + } + } + + return latestAnalyzedFileShas; +} + +/** + * 이전에 분석하지 않았거나 파일 내용이 변경된 파일만 반환한다. + * 기존 분석 댓글을 조회하지 못하면 null을 반환한다. + */ +async function getUnanalyzedOrChangedFiles( + repoOwner, + repoName, + prNumber, + appToken, + solutionFiles +) { + const reviewCommentsNewestFirst = await fetchReviewCommentsNewestFirst( + repoOwner, + repoName, + prNumber, + appToken + ); + + if (reviewCommentsNewestFirst === null) { + return null; + } + + const latestAnalyzedFileShas = extractLatestAnalyzedFileShas( + reviewCommentsNewestFirst + ); + + function needsAnalysis(file) { + const latestAnalyzedFileSha = latestAnalyzedFileShas.get(file.filename); + const isFileShaMissing = file.sha == null; + const hasFileChanged = file.sha !== latestAnalyzedFileSha; + + return isFileShaMissing || hasFileChanged; + } + + return solutionFiles.filter(needsAnalysis); +} + /** * 솔루션 파일들의 raw 내용을 한 번에 다운로드한다. * 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다. @@ -287,16 +340,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..39058e9 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -210,35 +210,10 @@ async function handleProjectsV2ItemEvent(payload, env) { }); } -/** - * Compare API로 두 커밋 사이에 변경된 파일명 목록 조회 - * - * @param {string} repoOwner - * @param {string} repoName - * @param {string} baseSha - 이전 커밋 SHA - * @param {string} headSha - 새 커밋 SHA - * @param {string} appToken - * @returns {Promise} 변경된 파일명 배열 (실패 시 null → 전체 분석 fallback) - */ -async function getChangedFilenames(repoOwner, repoName, baseSha, headSha, appToken) { - const response = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/compare/${baseSha}...${headSha}`, - { headers: getGitHubHeaders(appToken) } - ); - - if (!response.ok) { - console.error(`[getChangedFilenames] Compare API failed: ${response.status}`); - return null; - } - - const data = await response.json(); - return (data.files || []).map((f) => f.filename); -} - /** * Pull Request 이벤트 처리 - * - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일) - * - synchronize: 알고리즘 패턴 태깅만 (변경된 파일만, Week 체크 스킵) + * - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅(분석하지 않았거나 변경된 파일만) + * - synchronize: 알고리즘 패턴 태깅만 (분석하지 않았거나 변경된 파일만, Week 체크 스킵) */ async function handlePullRequestEvent(payload, env, ctx) { const action = payload.action; @@ -330,21 +305,6 @@ async function handlePullRequestEvent(payload, env, ctx) { 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)}` - ); - } - await tagPatterns( repoOwner, repoName, @@ -352,8 +312,7 @@ async function handlePullRequestEvent(payload, env, ctx) { pr.head.sha, pr, appToken, - env.OPENAI_API_KEY, - changedFilenames + env.OPENAI_API_KEY ); } catch (error) { console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`); diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 8064a82..5598bf3 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -260,7 +260,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { vi.clearAllMocks(); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ files: [] }), + json: () => Promise.resolve({}), }); }); @@ -357,4 +357,5 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(tagPatterns).not.toHaveBeenCalled(); expect(postLearningStatus).not.toHaveBeenCalled(); }); + }); diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index ca1dbee..f923bbf 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -11,11 +11,14 @@ 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", + sha: "a".repeat(40), + raw_url: `https://raw.example.com/problem-${i + 1}/${USERNAME}.ts`, + })); +} function okJson(data) { return Promise.resolve({ @@ -36,33 +39,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를 호출한다 (예상 50: files 1 + 리뷰 코멘트 목록 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 +70,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( { message: { content: JSON.stringify({ - files: SOLUTION_FILES.map((_, i) => ({ + files: solutionFiles.map((_, i) => ({ problemName: `problem-${i + 1}`, solutions: [ { @@ -114,6 +104,10 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( }); } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson([]); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { return okJson({ id: 999 }); } @@ -148,14 +142,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(50); + 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 +214,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..759883d 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -9,6 +9,7 @@ vi.mock("../utils/github.js", () => ({ })); import { tagPatterns } from "../handlers/tag-patterns.js"; +import { renderFileShaMarker } from "../utils/commentMarker.js"; const REPO_OWNER = "DaleStudy"; const REPO_NAME = "leetcode-study"; @@ -19,6 +20,8 @@ const OPENAI_KEY = "fake-openai-key"; const PATTERN_MARKER = ""; const LEGACY_COMPLEXITY_MARKER = ""; +const MAX_FILE_CONTENT_LENGTH = 20000; +const FILE_SHA = "a".repeat(40); const PLAIN_SOURCE = "function solution() { return 0; }"; @@ -51,10 +54,16 @@ function failResponse(status = 500) { }); } -function makeSolutionFile(problemName, username = "testuser") { +function makeSolutionFile( + problemName, + username = "testuser", + status = "added", + sha = FILE_SHA +) { return { filename: `${problemName}/${username}.js`, - status: "added", + status, + sha, raw_url: `https://raw.example.com/${problemName}/${username}.js`, }; } @@ -73,7 +82,8 @@ function makeFetchMock({ patternResponse = { patterns: ["Two Pointers"], description: "test" }, complexityFiles = null, complexityFails = false, - existingPatternComments = [], + existingReviewComments = [], + reviewCommentsResponse = () => okJson(existingReviewComments), existingIssueComments = [], postCapture = null, } = {}) { @@ -111,11 +121,7 @@ function makeFetchMock({ } if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return okJson(existingPatternComments); - } - - if (urlStr.includes("/pulls/comments/") && method === "DELETE") { - return okJson({}); + return reviewCommentsResponse(); } if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { @@ -221,6 +227,7 @@ describe("tagPatterns — 합본 댓글 (패턴 + 복잡도)", () => { expect(posts).toHaveLength(1); expect(posts[0].path).toBe("two-sum/testuser.js"); expect(posts[0].body).toContain(PATTERN_MARKER); + expect(posts[0].body).toContain(renderFileShaMarker(FILE_SHA)); expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); expect(posts[0].body).toContain("### 📊 시간/공간 복잡도 분석"); expect(posts[0].body).toContain("정확합니다!"); @@ -228,6 +235,85 @@ describe("tagPatterns — 합본 댓글 (패턴 + 복잡도)", () => { expect(posts[0].body).not.toContain(LEGACY_COMPLEXITY_MARKER); }); + it("file SHA가 없으면 file-sha 마커를 표시하지 않는다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum", "testuser", "added", null)], + postCapture: posts, + }); + + await tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + + expect(posts[0].body).toContain(PATTERN_MARKER); + expect(posts[0].body).not.toContain("file-sha"); + }); + + 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({ @@ -369,9 +455,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 }); } @@ -531,85 +614,215 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 }); }); -describe("tagPatterns — 기존 패턴 review comment 정리", () => { +describe("tagPatterns — 분석 대상 파일 선택", () => { + const modifiedSolutionFile = makeSolutionFile("a", "user", "modified"); + + function runTagPatterns() { + return tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + makePrData(), + APP_TOKEN, OPENAI_KEY + ); + } + + function makePatternCommentBody(fileSha) { + return `${PATTERN_MARKER}\n${renderFileShaMarker(fileSha)}`; + } + + function makeReviewComment(file, overrides = {}) { + return { + path: file.filename, + user: { type: "Bot" }, + body: makePatternCommentBody(file.sha), + ...overrides, + }; + } + + async function analyzeWithExistingReviewComments( + solutionFile, + existingReviewComments + ) { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [solutionFile], + existingReviewComments, + postCapture: posts, + }); + + await runTagPatterns(); + + return posts; + } + 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"; + it("기존 분석 댓글을 최신순으로 조회한다", async () => { + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("a")], + }); - if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { - return okJson([makeSolutionFile("two-sum")]); - } - if (urlStr.startsWith("https://raw.example.com/")) { - return okText(PLAIN_SOURCE); - } - if (urlStr.includes("openai.com")) { - const body = JSON.parse(opts.body); - const isComplexity = body.messages[0].content.includes( - "시간/공간 복잡도를 분석" - ); - if (isComplexity) { - return okJson({ - choices: [ - { message: { content: JSON.stringify({ files: [] }) } }, - ], - }); - } - return okJson({ - choices: [ - { - message: { - content: JSON.stringify({ - patterns: [], - description: "", - }), - }, - }, - ], - }); - } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([ - { - id: 100, - user: { type: "Bot" }, - body: PATTERN_MARKER, - path: "two-sum/testuser.js", - }, - { - id: 101, - user: { type: "Bot" }, - body: PATTERN_MARKER, - path: "valid-parentheses/testuser.js", // 다른 파일 - }, - ]); - } - if (urlStr.includes("/pulls/comments/") && method === "DELETE") { - const m = urlStr.match(/\/comments\/(\d+)/); - if (m) deletedIds.push(Number(m[1])); - return okJson({}); - } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { - return okJson({ id: 1 }); - } - if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([]); - } - throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + await runTagPatterns(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining( + `/pulls/${PR_NUMBER}/comments?sort=created&direction=desc&per_page=100` + ), + expect.anything() + ); + }); + + it("삭제된 파일은 분석하지 않는다", async () => { + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [makeSolutionFile("a", "user", "removed")], + postCapture: posts, }); - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY + await runTagPatterns(); + + expect(posts).toHaveLength(0); + }); + + it.each([ + ["기존 분석 댓글이 없으면", []], + [ + "가장 최근 분석 댓글에 파일 SHA가 없으면", + [ + makeReviewComment(modifiedSolutionFile, { body: PATTERN_MARKER }), + makeReviewComment(modifiedSolutionFile), + ], + ], + [ + "기존 분석 댓글의 파일 SHA가 현재 파일 SHA와 다르면", + [ + makeReviewComment(modifiedSolutionFile, { + body: makePatternCommentBody("b".repeat(40)), + }), + ], + ], + ])("%s 분석한다", async (_, existingReviewComments) => { + const posts = await analyzeWithExistingReviewComments( + modifiedSolutionFile, + existingReviewComments + ); + + expect(posts.map(({ path }) => path)).toEqual([ + modifiedSolutionFile.filename, + ]); + }); + + it("현재 파일 SHA가 없으면 분석한다", async () => { + const solutionFile = makeSolutionFile("a", "user", "modified", null); + const posts = await analyzeWithExistingReviewComments(solutionFile, [ + makeReviewComment(solutionFile, { body: PATTERN_MARKER }), + ]); + + expect(posts.map(({ path }) => path)).toEqual([solutionFile.filename]); + }); + + it.each([ + [ + "Bot이 아닌 댓글은", + makeReviewComment(modifiedSolutionFile, { + user: { type: "User" }, + }), + ], + [ + "최상위가 아닌 댓글은", + makeReviewComment(modifiedSolutionFile, { + in_reply_to_id: 123, + }), + ], + [ + "패턴 분석 마커가 없는 댓글은", + makeReviewComment(modifiedSolutionFile, { + body: renderFileShaMarker(modifiedSolutionFile.sha), + }), + ], + ])("%s 기존 분석 댓글로 판단하지 않는다", async (_, comment) => { + const posts = await analyzeWithExistingReviewComments( + modifiedSolutionFile, + [comment] + ); + + expect(posts.map(({ path }) => path)).toEqual([ + modifiedSolutionFile.filename, + ]); + }); + + it.each([ + [ + "네트워크 오류", + () => Promise.reject(new Error("network failure")), + ], + ["HTTP 오류", () => failResponse(500)], + [ + "응답 파싱 오류", + () => + Promise.resolve({ + ok: true, + json: () => Promise.reject(new Error("invalid JSON")), + }), + ], + ])( + "기존 분석 댓글 조회 중 %s가 발생하면 분석하지 않는다", + async (_, reviewCommentsResponse) => { + const solutionFile = makeSolutionFile("a", "user", "modified"); + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [solutionFile], + reviewCommentsResponse, + postCapture: posts, + }); + + const result = await runTagPatterns(); + + expect(result).toEqual({ skipped: "review-comments-unavailable" }); + expect(posts).toHaveLength(0); + } + ); + + it("기존 분석 댓글의 파일 SHA와 현재 파일 SHA가 다른 파일만 분석한다", async () => { + const unchangedFile = makeSolutionFile( + "a", + "user", + "modified", + "a".repeat(40) + ); + const changedFile = makeSolutionFile( + "b", + "user", + "modified", + "b".repeat(40) ); + const posts = []; + globalThis.fetch = makeFetchMock({ + solutionFiles: [unchangedFile, changedFile], + existingReviewComments: [ + makeReviewComment(unchangedFile), + makeReviewComment(changedFile, { + body: makePatternCommentBody("c".repeat(40)), + }), + ], + postCapture: posts, + }); + + await runTagPatterns(); + + expect(posts.map(({ path }) => path)).toEqual([changedFile.filename]); + }); - // 대상 파일(two-sum)의 기존 댓글만 삭제, 다른 파일은 보존 - expect(deletedIds).toEqual([100]); + it("기존 분석 댓글의 파일 SHA와 현재 파일 SHA가 같으면 분석하지 않는다", async () => { + const solutionFile = makeSolutionFile("a", "user", "modified"); + globalThis.fetch = makeFetchMock({ + solutionFiles: [solutionFile], + existingReviewComments: [makeReviewComment(solutionFile)], + }); + + const result = await runTagPatterns(); + + expect(result).toEqual({ skipped: "no-solution-files" }); }); }); diff --git a/utils/commentMarker.js b/utils/commentMarker.js new file mode 100644 index 0000000..f0021bb --- /dev/null +++ b/utils/commentMarker.js @@ -0,0 +1,25 @@ +/** + * GitHub 댓글 숨김 마커 유틸리티 + */ + +const FILE_SHA_MARKER = "file-sha"; +const FILE_SHA_PATTERN = new RegExp( + ``, + "i" +); + +/** + * @param {string} fileSha + * @returns {string} + */ +export function renderFileShaMarker(fileSha) { + return ``; +} + +/** + * @param {string} body + * @returns {string|null} + */ +export function parseFileShaMarker(body) { + return body.match(FILE_SHA_PATTERN)?.[1] ?? null; +} diff --git a/utils/commentMarker.test.js b/utils/commentMarker.test.js new file mode 100644 index 0000000..11c981c --- /dev/null +++ b/utils/commentMarker.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + parseFileShaMarker, + renderFileShaMarker, +} from "./commentMarker.js"; + +const FILE_SHA = "a".repeat(40); + +describe("패턴 분석 댓글 마커", () => { + it("file SHA를 숨김 댓글로 렌더링한다", () => { + expect(renderFileShaMarker(FILE_SHA)).toBe( + `` + ); + }); + + it("마커에서 file SHA를 파싱한다", () => { + const body = ``; + + expect(parseFileShaMarker(body)).toBe(FILE_SHA); + }); + + it.each([ + ["마커가 없는 경우", "분석 내용"], + ["file SHA가 없는 경우", ""], + ["file SHA가 16진수가 아닌 경우", ""], + ])("%s null을 반환한다", (_, body) => { + expect(parseFileShaMarker(body)).toBeNull(); + }); +}); 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)); + } + ); +});