diff --git a/AGENTS.md b/AGENTS.md index 0187be9..e985ac2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ wrangler.jsonc Worker 설정과 공개 값(vars) | 이벤트 | 하는 일 | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `projects_v2_item` | Week 설정이 바뀌면 경고 댓글을 달거나 지운다 | -| `pull_request` (`opened`, `reopened`, `synchronize`) | Week 검사, 풀이 패턴 태깅(`tagPatterns`), 학습 현황 댓글(`postLearningStatus`) | +| `pull_request` (`opened`, `reopened`, `synchronize`) | Week 검사, AI 풀이 분석 check run(`tagPatterns`), 학습 현황 댓글(`postLearningStatus`) | | `issue_comment`, `pull_request_review_comment` (`created`) | `@dalestudy` 멘션에 응답한다. `approve`·`승인` 이면 PR 을 승인하고, 그 밖에는 AI 리뷰를 단다 | Week 검사는 제목이 `WEEK NN Solutions` 형태인 풀이 PR 만 대상으로 하고, 경고 댓글은 봇이 단 것만 지운다. @@ -69,7 +69,7 @@ GitHub webhook ``` `INTERNAL_SECRET` 과 `WORKER_URL` 이 둘 다 있어야 켜진다. 하나라도 없으면 같은 invocation 에서 차례로 실행된다. -`tests/subrequest-budget.test.js` 가 파일 5 개 시나리오의 fetch 횟수(`tagPatterns` 20, `postLearningStatus` 31)를 회귀 테스트로 고정한다. +`tests/subrequest-budget.test.js` 가 fetch 횟수(`tagPatterns` 파일 15 개에 36, `postLearningStatus` 파일 5 개에 31)를 회귀 테스트로 고정한다. ## 배포와 시크릿 @@ -91,7 +91,7 @@ GitHub secret 은 배포할 때마다 Worker secret 으로 들어가므로 `wran https://github.com/organizations/DaleStudy/settings/apps/dalestudy - Webhook URL: `https://github.dalestudy.com/webhooks`, Webhook secret 은 저장소 secret `WEBHOOK_SECRET` 과 같은 값. `/webhooks` 는 서명(`X-Hub-Signature-256`)이 맞지 않거나 `WEBHOOK_SECRET` 이 없으면 받지 않는다 -- Repository permissions: Contents(Read), Issues(Read & write), Metadata(Read), Pull requests(Read & write), Projects(Read & write) +- Repository permissions: Checks(Read & write), Contents(Read), Issues(Read & write), Metadata(Read), Pull requests(Read & write), Projects(Read & write) - 이벤트: Issue comments, Pull request review comments, Projects v2 item, Pull requests - DaleStudy 조직에 설치돼 있고 leetcode-study 에 접근할 수 있어야 한다 diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index d574d47..2647a64 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -1,7 +1,7 @@ /** * 시간/공간 복잡도 분석 (분석 함수 + 순수 헬퍼). * - * 오케스트레이션과 댓글 게시는 tag-patterns 핸들러가 담당하며, 이 모듈은 + * 오케스트레이션과 check run 게시는 tag-patterns 핸들러가 담당하며, 이 모듈은 * OpenAI 호출(`callComplexityAnalysis`)과 사용자 주석 처리/매칭 헬퍼, * 한 파일분 섹션 렌더러(`renderComplexitySection`)만 제공한다. * @@ -353,7 +353,7 @@ function buildSolutionBody(solution) { /** * 한 파일분 복잡도 섹션을 렌더링한다. - * 패턴 태그 코멘트에 묻어가는 형태이므로 마커/푸터 없이 + * check run 의 파일별 섹션에 묻어가는 형태이므로 마커/푸터 없이 * `### 📊 ...` 헤더부터 시작한다. * * @param {{problemName: string, solutions: Array}} entry diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index 0a335da..2ede116 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -5,7 +5,7 @@ vi.mock("../utils/github.js", () => ({ })); vi.mock("./tag-patterns.js", () => ({ - tagPatterns: vi.fn().mockResolvedValue({ tagged: true }), + tagPatterns: vi.fn().mockResolvedValue({ analyzed: 1 }), })); vi.mock("./learning-status.js", () => ({ diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 91f244e..1abddab 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -1,36 +1,33 @@ /** - * 알고리즘 패턴 태깅 핸들러 + * AI 풀이 분석 핸들러 * - * PR의 솔루션 파일들을 분석하여 사용된 알고리즘 패턴 + 시간/공간 복잡도를 - * 파일별 review comment로 남긴다. 복잡도 분석은 패턴 분석 루프와 병렬로 - * OpenAI 1콜에서 모든 파일을 한 번에 처리하고, 그 결과를 파일별 댓글 - * 본문에 한 섹션 더 붙이는 형태로 묻어간다. - * 재실행 시 기존 패턴 댓글과 답글은 보존하고, 변경된 파일에 새 분석 댓글을 추가한다. + * PR의 솔루션 파일들을 분석하여 알고리즘 패턴 + 시간/공간 복잡도를 + * PR head 커밋의 check run 하나로 남긴다. 사람 리뷰와 섞이지 않도록 리뷰 댓글은 + * 달지 않고, 대화 탭에는 check run 링크 댓글 하나만 유지한다. + * 복잡도 분석은 패턴 분석 루프와 병렬로 OpenAI 1콜에서 모든 파일을 한 번에 처리한다. * * 주의: 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 { generatePatternAnalysis } from "../utils/openai.js"; import { callComplexityAnalysis, renderComplexitySection, } from "./complexity-analysis.js"; -const COMMENT_MARKER = ""; -// 레거시 단독 복잡도 issue comment 식별용. 새 합본 댓글에는 박지 않는다. +const CHECK_RUN_NAME = "AI 풀이 분석"; +const LINK_COMMENT_MARKER = ""; +// 레거시 단독 복잡도 issue comment 식별용 const LEGACY_COMPLEXITY_MARKER = ""; const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/; const MAX_FILE_CONTENT_LENGTH = 20000; // OpenAI 입력 크기 안전장치 +// check run output.summary / output.text 의 최대 길이 +const MAX_CHECK_RUN_OUTPUT_LENGTH = 65535; /** - * PR의 솔루션 파일들에 알고리즘 패턴 태그 달기 + * PR의 솔루션 파일들을 분석해 check run 으로 게시하기 * * @param {string} repoOwner * @param {string} repoName @@ -69,28 +66,12 @@ export async function tagPatterns( } const allFiles = await filesResponse.json(); - let solutionFiles = allFiles.filter( + const solutionFiles = allFiles.filter( (f) => (f.status === "added" || f.status === "modified") && SOLUTION_PATH_REGEX.test(f.filename) ); - 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( `[tagPatterns] PR #${prNumber}: ${allFiles.length} total files, ${solutionFiles.length} solution files to analyze` ); @@ -102,97 +83,143 @@ export async function tagPatterns( // 2-3. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유) const fileEntries = await downloadFileEntries(solutionFiles); - // 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성. + // 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 분석은 게시. const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey) .catch((err) => { console.error(`[tagPatterns] complexity analysis failed: ${err.message}`); return []; }); - // 2-5. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑) - const results = []; + // 2-5. 파일별 OpenAI 패턴 분석 (실패한 파일은 analysis 가 null) + const analyses = []; for (const fe of fileEntries) { try { - const result = await tagSingleFile( - fe, - complexityPromise, - repoOwner, - repoName, - prNumber, - headSha, - appToken, + const analysis = await generatePatternAnalysis( + fe.content, + fe.problemName, openaiApiKey ); - results.push({ path: fe.file.filename, ...result }); + analyses.push({ fileEntry: fe, analysis }); } catch (error) { console.error( - `[tagPatterns] Failed to tag ${fe.file.filename}: ${error.message}` + `[tagPatterns] Failed to analyze ${fe.file.filename}: ${error.message}` ); - results.push({ path: fe.file.filename, error: error.message }); + analyses.push({ fileEntry: fe, analysis: null }); } } - // 2-6. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제 - await deleteLegacyComplexityIssueComment( - repoOwner, repoName, prNumber, appToken + const complexityResults = await complexityPromise; + + // 2-6. check run 게시 + const checkRun = await createCheckRun( + repoOwner, + repoName, + headSha, + renderCheckRunOutput(analyses, complexityResults), + appToken ); - return { tagged: results.filter((r) => !r.error).length, results }; + // 2-7. 대화 탭의 링크 댓글 갱신 + 레거시 단독 복잡도 issue comment 삭제 + await syncIssueComments( + repoOwner, + repoName, + prNumber, + checkRun.html_url, + appToken + ); + + return { + analyzed: analyses.filter((a) => a.analysis !== null).length, + checkRunUrl: checkRun.html_url, + }; } /** - * 단일 파일 분석 + 코멘트 작성 + * check run output(title, summary, text)을 렌더링한다. + * summary 에는 파일별 패턴 요약표를, text 에는 파일별 상세 분석을 넣는다. * - * @param {{file: object, problemName: string, content: string, isContentTruncated: boolean}} fileEntry - * @param {Promise} complexityPromise - 모든 파일의 복잡도 분석 결과 (병렬 진행) + * @param {Array<{fileEntry: object, analysis: object|null}>} analyses + * @param {Array<{problemName: string, solutions: Array}>} complexityResults */ -async function tagSingleFile( - fileEntry, - complexityPromise, - repoOwner, - repoName, - prNumber, - headSha, - appToken, - openaiApiKey -) { - const { - file, - problemName, - content: fileContent, - isContentTruncated, - } = fileEntry; - - // OpenAI 패턴 분석 - const analysis = await generatePatternAnalysis( - fileContent, - problemName, - openaiApiKey - ); +function renderCheckRunOutput(analyses, complexityResults) { + const summaryLines = [ + "> 🤖 AI가 생성한 분석이라 틀릴 수 있어요. 리뷰어의 피드백을 우선해 주세요.", + "", + "| 파일 | 패턴 |", + "|---|---|", + ...analyses.map( + ({ fileEntry, analysis }) => + `| \`${fileEntry.file.filename}\` | ${renderPatterns(analysis)} |` + ), + ]; + + const text = analyses + .map(({ fileEntry, analysis }) => + renderFileSection( + fileEntry, + analysis, + complexityResults.find((r) => r.problemName === fileEntry.problemName) + ) + ) + .join("\n\n"); + + return { + title: `풀이 파일 ${analyses.length}개 분석`, + summary: truncateOutput(summaryLines.join("\n")), + text: truncateOutput(text), + }; +} + +function renderPatterns(analysis) { + if (analysis === null) return "⚠️ 분석 실패"; + return analysis.patterns.length > 0 + ? analysis.patterns.join(", ") + : "감지된 패턴 없음"; +} - // 코멘트 본문 작성 - const patternsText = - analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음"; - let body = `${COMMENT_MARKER} -${file.sha ? `${renderFileShaMarker(file.sha)}\n` : ""}### 🏷️ 알고리즘 패턴 분석 +function renderFileSection(fileEntry, analysis, complexityForFile) { + const lines = [`## \`${fileEntry.file.filename}\``, ""]; -${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} + if (fileEntry.isContentTruncated) { + lines.push( + `> ⚠️ 파일이 길어 앞부분 ${MAX_FILE_CONTENT_LENGTH}자만 분석했습니다.`, + "" + ); + } -- **패턴**: ${patternsText} -- **설명**: ${analysis.description || "(설명 없음)"}`; + lines.push("### 🏷️ 알고리즘 패턴 분석", ""); + if (analysis === null) { + lines.push("> ⚠️ 패턴 분석에 실패했습니다."); + } else { + lines.push( + `- **패턴**: ${renderPatterns(analysis)}`, + `- **설명**: ${analysis.description || "(설명 없음)"}` + ); + } - // 복잡도 섹션을 한 블록 더 붙인다 (해당 파일 결과가 있을 때만, 실패 시 스킵) - const complexityResults = await complexityPromise; - const complexityForFile = complexityResults.find( - (r) => r.problemName === problemName - ); + // 복잡도 섹션은 해당 파일 결과가 있을 때만 붙인다 if (complexityForFile && complexityForFile.solutions.length > 0) { - body += "\n\n" + renderComplexitySection(complexityForFile); + lines.push("", renderComplexitySection(complexityForFile)); } - // 파일 단위 review comment 작성 - const commentResponse = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments`, + return lines.join("\n"); +} + +function truncateOutput(value) { + const notice = "\n\n... (이하 생략)"; + if (value.length <= MAX_CHECK_RUN_OUTPUT_LENGTH) return value; + return value.slice(0, MAX_CHECK_RUN_OUTPUT_LENGTH - notice.length) + notice; +} + +/** + * PR head 커밋에 완료 상태의 check run 을 만든다. + * conclusion 은 neutral 이라 statusCheckRollup 을 실패로 만들지 않는다. + * + * @see https://docs.github.com/en/rest/checks/runs#create-a-check-run + */ +async function createCheckRun(repoOwner, repoName, headSha, output, appToken) { + const response = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/check-runs`, { method: "POST", headers: { @@ -200,124 +227,23 @@ ${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)} "Content-Type": "application/json", }, body: JSON.stringify({ - body, - commit_id: headSha, - path: file.filename, - subject_type: "file", + name: CHECK_RUN_NAME, + head_sha: headSha, + status: "completed", + conclusion: "neutral", + output, }), } ); - if (!commentResponse.ok) { - const errorText = await commentResponse.text(); + if (!response.ok) { + const errorText = await response.text(); throw new Error( - `Failed to post review comment: ${commentResponse.status} ${errorText}` + `Failed to create check run: ${response.status} ${errorText}` ); } - 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); + return response.json(); } /** @@ -352,13 +278,16 @@ async function downloadFileEntries(solutionFiles) { } /** - * 구버전 단독 복잡도 issue comment 가 있으면 삭제한다 (마이그레이션). - * 새 코드는 review comment 에 합본으로 작성하므로 단독 issue comment 는 중복. + * issue comment 목록을 한 번 읽어 두 가지를 처리한다. + * - 링크 댓글: 봇이 단 것이 있으면 최신 check run 링크로 고치고, 없으면 새로 단다. + * - 레거시 단독 복잡도 issue comment: 있으면 삭제한다 (마이그레이션). + * 실패해도 check run 은 이미 게시됐으므로 로그만 남긴다. */ -async function deleteLegacyComplexityIssueComment( +async function syncIssueComments( repoOwner, repoName, prNumber, + checkRunUrl, appToken ) { const listResponse = await fetch( @@ -368,41 +297,90 @@ async function deleteLegacyComplexityIssueComment( if (!listResponse.ok) { console.error( - `[tagPatterns] Failed to list issue comments for legacy cleanup: ${listResponse.status}` + `[tagPatterns] Failed to list issue comments: ${listResponse.status}` ); return; } const comments = await listResponse.json(); - const legacy = comments.find( - (c) => - c.user?.type === "Bot" && c.body?.includes(LEGACY_COMPLEXITY_MARKER) + const findBotComment = (marker) => + comments.find((c) => c.user?.type === "Bot" && c.body?.includes(marker)); + + await upsertLinkComment( + repoOwner, + repoName, + prNumber, + findBotComment(LINK_COMMENT_MARKER), + checkRunUrl, + appToken ); - if (!legacy) return; + const legacy = findBotComment(LEGACY_COMPLEXITY_MARKER); + if (legacy) { + await deleteIssueComment(repoOwner, repoName, legacy.id, appToken); + } +} + +async function upsertLinkComment( + repoOwner, + repoName, + prNumber, + existing, + checkRunUrl, + appToken +) { + const body = `${LINK_COMMENT_MARKER} +🤖 AI 풀이 분석(알고리즘 패턴, 시간/공간 복잡도) 결과는 [Checks 탭](${checkRunUrl})에서 볼 수 있어요.`; + + const url = existing + ? `https://api.github.com/repos/${repoOwner}/${repoName}/issues/comments/${existing.id}` + : `https://api.github.com/repos/${repoOwner}/${repoName}/issues/${prNumber}/comments`; + + try { + const response = await fetch(url, { + method: existing ? "PATCH" : "POST", + headers: { + ...getGitHubHeaders(appToken), + "Content-Type": "application/json", + }, + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + console.error( + `[tagPatterns] Failed to upsert link comment on PR #${prNumber}: ${response.status}` + ); + } + } catch (error) { + console.error( + `[tagPatterns] Error upserting link comment on PR #${prNumber}: ${error.message}` + ); + } +} +async function deleteIssueComment(repoOwner, repoName, commentId, appToken) { try { - const deleteResponse = await fetch( - `https://api.github.com/repos/${repoOwner}/${repoName}/issues/comments/${legacy.id}`, + const response = await fetch( + `https://api.github.com/repos/${repoOwner}/${repoName}/issues/comments/${commentId}`, { method: "DELETE", headers: getGitHubHeaders(appToken), } ); - if (!deleteResponse.ok) { + if (!response.ok) { console.error( - `[tagPatterns] Failed to delete legacy complexity comment ${legacy.id}: ${deleteResponse.status}` + `[tagPatterns] Failed to delete legacy complexity comment ${commentId}: ${response.status}` ); return; } console.log( - `[tagPatterns] Deleted legacy complexity issue comment ${legacy.id} on PR #${prNumber}` + `[tagPatterns] Deleted legacy complexity issue comment ${commentId}` ); } catch (error) { console.error( - `[tagPatterns] Error deleting legacy complexity comment ${legacy.id}: ${error.message}` + `[tagPatterns] Error deleting legacy complexity comment ${commentId}: ${error.message}` ); } } diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index 362d3c8..894a136 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -44,7 +44,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { vi.clearAllMocks(); }); - it("tagPatterns 는 변경 파일 15개에서 50회 이하 subrequest를 호출한다 (예상 50: files 1 + 리뷰 코멘트 목록 1 + raw 15 + 패턴 OpenAI 15 + 복잡도 OpenAI 1 + POST 15 + 레거시 issue 코멘트 목록 1 + 레거시 DELETE 1)", async () => { + it("tagPatterns 는 변경 파일 15개에서 50회 이하 subrequest를 호출한다 (예상 36: files 1 + raw 15 + 패턴 OpenAI 15 + 복잡도 OpenAI 1 + check run 1 + issue 코멘트 목록 1 + 링크 댓글 POST 1 + 레거시 DELETE 1)", async () => { const solutionFiles = makeSolutionFiles(15); globalThis.fetch = vi.fn().mockImplementation((url, opts) => { @@ -104,12 +104,12 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { }); } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([]); + if (urlStr.endsWith("/check-runs") && method === "POST") { + return okJson({ id: 777, html_url: "https://github.com/runs/777" }); } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { - return okJson({ id: 999 }); + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 500 }); } // 레거시 단독 복잡도 issue comment 마이그레이션 @@ -142,8 +142,8 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { const fetchCount = globalThis.fetch.mock.calls.length; - expect(result.tagged).toBe(15); - expect(fetchCount).toBe(50); + expect(result.analyzed).toBe(15); + expect(fetchCount).toBe(36); expect(fetchCount).toBeLessThanOrEqual(50); }); diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index 21b6344..b8bea5a 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -9,7 +9,6 @@ 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"; @@ -18,10 +17,10 @@ const HEAD_SHA = "head-sha"; const APP_TOKEN = "fake-app-token"; const OPENAI_KEY = "fake-openai-key"; -const PATTERN_MARKER = ""; +const LINK_COMMENT_MARKER = ""; const LEGACY_COMPLEXITY_MARKER = ""; const MAX_FILE_CONTENT_LENGTH = 20000; -const FILE_SHA = "a".repeat(40); +const CHECK_RUN_URL = "https://github.com/DaleStudy/leetcode-study/runs/777"; const PLAIN_SOURCE = "function solution() { return 0; }"; @@ -54,16 +53,10 @@ function failResponse(status = 500) { }); } -function makeSolutionFile( - problemName, - username = "testuser", - status = "added", - sha = FILE_SHA -) { +function makeSolutionFile(problemName, username = "testuser", status = "added") { return { filename: `${problemName}/${username}.js`, status, - sha, raw_url: `https://raw.example.com/${problemName}/${username}.js`, }; } @@ -72,25 +65,42 @@ function makePrData(overrides = {}) { return { draft: false, labels: [], ...overrides }; } +function runTagPatterns(prData = makePrData()) { + return tagPatterns( + REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, + prData, + APP_TOKEN, OPENAI_KEY + ); +} + /** - * 두 OpenAI 엔드포인트(패턴 분석 N콜 + 복잡도 분석 1콜)를 분기한다. + * 두 OpenAI 엔드포인트(패턴 분석 N콜 + 복잡도 분석 1콜)를 분기하고, + * 쓰기 요청(POST/PATCH/DELETE)은 requests 에 모은다. * 복잡도 호출은 system_prompt 에 "시간/공간 복잡도를 분석" 이 포함되는지로 식별. */ function makeFetchMock({ solutionFiles, rawContent = PLAIN_SOURCE, patternResponse = { patterns: ["Two Pointers"], description: "test" }, + failingPatternProblems = [], complexityFiles = null, complexityFails = false, - existingReviewComments = [], - reviewCommentsResponse = () => okJson(existingReviewComments), + checkRunFails = false, existingIssueComments = [], - postCapture = null, } = {}) { - return vi.fn().mockImplementation((url, opts) => { + const requests = []; + const fetchMock = vi.fn().mockImplementation((url, opts) => { const urlStr = typeof url === "string" ? url : url.url; const method = opts?.method ?? "GET"; + if (method !== "GET" && !urlStr.includes("/openai/")) { + requests.push({ + method, + url: urlStr, + body: opts.body ? JSON.parse(opts.body) : null, + }); + } + if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { return okJson(solutionFiles); } @@ -113,6 +123,10 @@ function makeFetchMock({ ], }); } + const userPrompt = body.messages[1].content; + if (failingPatternProblems.some((p) => userPrompt.includes(p))) { + return failResponse(500); + } return okJson({ choices: [ { message: { content: JSON.stringify(patternResponse) } }, @@ -120,26 +134,57 @@ function makeFetchMock({ }); } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - return reviewCommentsResponse(); - } - - 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 }); - return okJson({ id: 999 }); + if (urlStr.endsWith("/check-runs") && method === "POST") { + if (checkRunFails) return failResponse(403); + return okJson({ id: 777, html_url: CHECK_RUN_URL }); } if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { return okJson(existingIssueComments); } + if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 500 }); + } + + if (urlStr.includes("/issues/comments/") && method === "PATCH") { + return okJson({}); + } + if (urlStr.includes("/issues/comments/") && method === "DELETE") { return okJson({}); } throw new Error(`Unexpected fetch: ${method} ${urlStr}`); }); + + return { fetchMock, requests }; +} + +function setupFetch(options) { + const { fetchMock, requests } = makeFetchMock(options); + globalThis.fetch = fetchMock; + return requests; +} + +function findCheckRun(requests) { + return requests.find((r) => r.url.endsWith("/check-runs"))?.body; +} + +function complexityFile(problemName, feedback) { + return { + problemName, + solutions: [ + { + name: "solution", + headerLine: 1, + actualTime: "O(n)", + actualSpace: "O(1)", + feedback, + suggestion: "현재 구현이 적절해 보입니다.", + }, + ], + }; } describe("tagPatterns — skip 조건", () => { @@ -150,666 +195,310 @@ describe("tagPatterns — skip 조건", () => { it("draft PR 은 skip 한다", async () => { globalThis.fetch = vi.fn(); - const result = await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData({ draft: true }), - APP_TOKEN, OPENAI_KEY - ); + const result = await runTagPatterns(makePrData({ draft: true })); expect(result).toEqual({ skipped: "draft" }); expect(globalThis.fetch).not.toHaveBeenCalled(); }); it("솔루션 파일이 없으면 skip 한다", async () => { - globalThis.fetch = makeFetchMock({ + const requests = setupFetch({ solutionFiles: [ - { filename: "README.md", status: "modified", raw_url: "x" }, + { filename: "README.md", status: "modified", raw_url: "" }, ], }); - const result = await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const result = await runTagPatterns(); expect(result).toEqual({ skipped: "no-solution-files" }); + expect(requests).toHaveLength(0); + }); + + it("삭제된 파일은 분석하지 않는다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum", "testuser", "removed")], + }); + + const result = await runTagPatterns(); + + expect(result).toEqual({ skipped: "no-solution-files" }); + expect(requests).toHaveLength(0); }); }); -describe("tagPatterns — 합본 댓글 (패턴 + 복잡도)", () => { +describe("tagPatterns — check run", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("패턴과 복잡도가 모두 성공하면 합본 댓글에 두 섹션이 모두 들어간다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + it("PR head 커밋에 neutral 로 완료된 check run 을 만들고 리뷰 댓글은 달지 않는다", async () => { + const requests = setupFetch({ solutionFiles: [makeSolutionFile("two-sum")], - complexityFiles: [ - { - problemName: "two-sum", - solutions: [ - { - name: "solution", - headerLine: 1, - actualTime: "O(n)", - actualSpace: "O(1)", - feedback: "정확합니다!", - suggestion: "현재 구현이 적절해 보입니다.", - }, - ], - }, - ], - postCapture: posts, + complexityFiles: [complexityFile("two-sum", "정확합니다!")], }); - const result = await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const result = await runTagPatterns(); - expect(result.tagged).toBe(1); - 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("정확합니다!"); - // 합본 댓글에는 LEGACY_COMPLEXITY_MARKER 가 들어가지 않는다 - expect(posts[0].body).not.toContain(LEGACY_COMPLEXITY_MARKER); - }); + expect(result).toEqual({ analyzed: 1, checkRunUrl: CHECK_RUN_URL }); - it("file SHA가 없으면 file-sha 마커를 표시하지 않는다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ - solutionFiles: [makeSolutionFile("two-sum", "testuser", "added", null)], - postCapture: posts, + const checkRun = findCheckRun(requests); + expect(checkRun).toMatchObject({ + name: "AI 풀이 분석", + head_sha: HEAD_SHA, + status: "completed", + conclusion: "neutral", }); - - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY + expect(checkRun.output.title).toBe("풀이 파일 1개 분석"); + expect(checkRun.output.summary).toContain( + "| `two-sum/testuser.js` | Two Pointers |" ); + expect(checkRun.output.text).toContain("## `two-sum/testuser.js`"); + expect(checkRun.output.text).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(checkRun.output.text).toContain("### 📊 시간/공간 복잡도 분석"); + expect(checkRun.output.text).toContain("정확합니다!"); - expect(posts[0].body).toContain(PATTERN_MARKER); - expect(posts[0].body).not.toContain("file-sha"); + expect(requests.some((r) => r.url.includes("/pulls/"))).toBe(false); }); - it("분석 대상 코드를 첨부한다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + it("분석 대상 코드는 첨부하지 않는다", async () => { + const requests = setupFetch({ 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} -\`\`\` + await runTagPatterns(); -
`); + const { output } = findCheckRun(requests); + expect(output.text).not.toContain(PLAIN_SOURCE); + expect(output.text).not.toContain("
"); }); it.each([MAX_FILE_CONTENT_LENGTH - 1, MAX_FILE_CONTENT_LENGTH])( - "파일 내용 길이가 제한값 이하인 경우(%i자) 생략 문구를 표시하지 않는다", + "파일 내용 길이가 제한값 이하인 경우(%i자) 생략 안내를 표시하지 않는다", async (contentLength) => { - const posts = []; - globalThis.fetch = makeFetchMock({ + const requests = setupFetch({ 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 - ); + await runTagPatterns(); - expect(posts[0].body).not.toContain("... (이하 생략)"); + expect(findCheckRun(requests).output.text).not.toContain( + "앞부분만 분석" + ); } ); - it("파일 내용 길이가 제한값을 초과하면 생략 문구를 표시한다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + it("파일 내용 길이가 제한값을 초과하면 생략 안내를 표시한다", async () => { + const requests = setupFetch({ 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 - ); + await runTagPatterns(); - expect(posts[0].body).toContain("... (이하 생략)"); + expect(findCheckRun(requests).output.text).toContain( + `앞부분 ${MAX_FILE_CONTENT_LENGTH}자만 분석했습니다.` + ); }); - it("복잡도 OpenAI 가 실패해도 패턴 댓글은 정상 작성된다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + it("복잡도 OpenAI 가 실패해도 패턴 분석은 게시된다", async () => { + const requests = setupFetch({ solutionFiles: [makeSolutionFile("two-sum")], complexityFails: true, - postCapture: posts, }); - const result = await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + const result = await runTagPatterns(); - expect(result.tagged).toBe(1); - expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); - expect(posts[0].body).not.toContain("### 📊 시간/공간 복잡도 분석"); + const { output } = findCheckRun(requests); + expect(result.analyzed).toBe(1); + expect(output.text).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(output.text).not.toContain("### 📊 시간/공간 복잡도 분석"); }); it("복잡도 결과가 비어 있으면 복잡도 섹션 없이 패턴만 게시된다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + const requests = setupFetch({ solutionFiles: [makeSolutionFile("two-sum")], - complexityFiles: [], // OpenAI 가 빈 files 반환 - postCapture: posts, + complexityFiles: [], }); - const result = await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + await runTagPatterns(); - expect(result.tagged).toBe(1); - expect(posts[0].body).toContain("### 🏷️ 알고리즘 패턴 분석"); - expect(posts[0].body).not.toContain("### 📊 시간/공간 복잡도 분석"); + const { output } = findCheckRun(requests); + expect(output.text).toContain("### 🏷️ 알고리즘 패턴 분석"); + expect(output.text).not.toContain("### 📊 시간/공간 복잡도 분석"); }); - it("여러 파일 — 각자 자기 problemName 의 복잡도 결과가 매핑된다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ + it("한 파일의 패턴 분석이 실패해도 나머지 파일과 함께 게시한다", async () => { + const requests = setupFetch({ solutionFiles: [ makeSolutionFile("two-sum"), makeSolutionFile("valid-parentheses"), ], - complexityFiles: [ - { - problemName: "two-sum", - solutions: [ - { - name: "twoSum", - headerLine: 1, - actualTime: "O(n)", - actualSpace: "O(n)", - feedback: "two-sum 피드백", - suggestion: "", - }, - ], - }, - { - problemName: "valid-parentheses", - solutions: [ - { - name: "isValid", - headerLine: 1, - actualTime: "O(n)", - actualSpace: "O(n)", - feedback: "valid-parentheses 피드백", - suggestion: "", - }, - ], - }, - ], - postCapture: posts, + failingPatternProblems: ["valid-parentheses"], }); - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); - - expect(posts).toHaveLength(2); + const result = await runTagPatterns(); - const twoSumPost = posts.find((p) => p.path === "two-sum/testuser.js"); - const validPost = posts.find( - (p) => p.path === "valid-parentheses/testuser.js" + const { output } = findCheckRun(requests); + expect(result.analyzed).toBe(1); + expect(output.title).toBe("풀이 파일 2개 분석"); + expect(output.summary).toContain( + "| `two-sum/testuser.js` | Two Pointers |" ); - - expect(twoSumPost.body).toContain("two-sum 피드백"); - expect(twoSumPost.body).not.toContain("valid-parentheses 피드백"); - expect(validPost.body).toContain("valid-parentheses 피드백"); - expect(validPost.body).not.toContain("two-sum 피드백"); - }); -}); - -describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레이션", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("LEGACY_COMPLEXITY_MARKER 가 있는 Bot issue comment 를 삭제한다", async () => { - let deletedId = null; - 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/chat/completions")) { - 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: ["Two Pointers"], - description: "x", - }), - }, - }, - ], - }); - } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { - 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([ - { - id: 777, - user: { type: "Bot" }, - body: `${LEGACY_COMPLEXITY_MARKER}\n구버전 댓글`, - }, - ]); - } - if (urlStr.includes("/issues/comments/777") && method === "DELETE") { - deletedId = 777; - return okJson({}); - } - throw new Error(`Unexpected fetch: ${method} ${urlStr}`); - }); - - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY + expect(output.summary).toContain( + "| `valid-parentheses/testuser.js` | ⚠️ 분석 실패 |" ); - - expect(deletedId).toBe(777); + expect(output.text).toContain("> ⚠️ 패턴 분석에 실패했습니다."); }); - it("레거시 댓글이 없으면 DELETE 호출 없음", async () => { - let deleteCalled = false; - 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/chat/completions")) { - 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([]); - } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { - return okJson({ id: 1 }); - } - if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([]); - } - if (urlStr.includes("/issues/comments/") && method === "DELETE") { - deleteCalled = true; - return okJson({}); - } - throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + it("여러 파일 — 각 섹션에 자기 problemName 의 복잡도 결과가 매핑된다", async () => { + const requests = setupFetch({ + solutionFiles: [ + makeSolutionFile("two-sum"), + makeSolutionFile("valid-parentheses"), + ], + complexityFiles: [ + complexityFile("two-sum", "two-sum 피드백"), + complexityFile("valid-parentheses", "valid-parentheses 피드백"), + ], }); - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY - ); + await runTagPatterns(); - expect(deleteCalled).toBe(false); - }); + const [twoSumSection, validSection] = findCheckRun(requests) + .output.text.split("## `") + .slice(1); - it("Bot 이 아닌 사용자가 LEGACY_COMPLEXITY_MARKER 를 포함한 댓글은 건드리지 않는다", async () => { - let deleteCalled = false; - globalThis.fetch = vi.fn().mockImplementation((url, opts) => { - const urlStr = typeof url === "string" ? url : url.url; - const method = opts?.method ?? "GET"; + expect(twoSumSection).toContain("two-sum 피드백"); + expect(twoSumSection).not.toContain("valid-parentheses 피드백"); + expect(validSection).toContain("valid-parentheses 피드백"); + expect(validSection).not.toContain("two-sum 피드백"); + }); - 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/chat/completions")) { - 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([]); - } - if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { - return okJson({ id: 1 }); - } - if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([ - { - id: 555, - user: { type: "User" }, - body: `${LEGACY_COMPLEXITY_MARKER}\n수동 작성 인용`, - }, - ]); - } - if (urlStr.includes("/issues/comments/") && method === "DELETE") { - deleteCalled = true; - return okJson({}); - } - throw new Error(`Unexpected fetch: ${method} ${urlStr}`); + it("check run 생성이 실패하면 throw 하고 링크 댓글은 달지 않는다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + checkRunFails: true, }); - await tagPatterns( - REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, - makePrData(), - APP_TOKEN, OPENAI_KEY + await expect(runTagPatterns()).rejects.toThrow( + "Failed to create check run: 403" ); - - expect(deleteCalled).toBe(false); + expect(requests.some((r) => r.url.includes("/issues/"))).toBe(false); }); }); -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; - } - +describe("tagPatterns — 대화 탭 링크 댓글", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("기존 분석 댓글을 최신순으로 조회한다", async () => { - globalThis.fetch = makeFetchMock({ - solutionFiles: [makeSolutionFile("a")], + it("링크 댓글이 없으면 check run 링크를 담아 새로 단다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], }); await runTagPatterns(); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining( - `/pulls/${PR_NUMBER}/comments?sort=created&direction=desc&per_page=100` - ), - expect.anything() + const post = requests.find( + (r) => r.method === "POST" && r.url.includes(`/issues/${PR_NUMBER}/comments`) ); + expect(post.body.body).toContain(LINK_COMMENT_MARKER); + expect(post.body.body).toContain(`[Checks 탭](${CHECK_RUN_URL})`); }); - it("삭제된 파일은 분석하지 않는다", async () => { - const posts = []; - globalThis.fetch = makeFetchMock({ - solutionFiles: [makeSolutionFile("a", "user", "removed")], - postCapture: posts, + it("봇이 단 링크 댓글이 있으면 새로 달지 않고 그 댓글을 고친다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + existingIssueComments: [ + { + id: 321, + user: { type: "Bot" }, + body: `${LINK_COMMENT_MARKER}\n이전 링크`, + }, + ], }); await runTagPatterns(); - expect(posts).toHaveLength(0); + const commentWrites = requests.filter((r) => r.url.includes("/issues/")); + expect(commentWrites).toHaveLength(1); + expect(commentWrites[0].method).toBe("PATCH"); + expect(commentWrites[0].url).toContain("/issues/comments/321"); + expect(commentWrites[0].body.body).toContain(CHECK_RUN_URL); }); - it.each([ - ["기존 분석 댓글이 없으면", []], - [ - "가장 최근 분석 댓글에 파일 SHA가 없으면", - [ - makeReviewComment(modifiedSolutionFile, { body: PATTERN_MARKER }), - makeReviewComment(modifiedSolutionFile), - ], - ], - [ - "기존 분석 댓글의 파일 SHA가 현재 파일 SHA와 다르면", - [ - makeReviewComment(modifiedSolutionFile, { - body: makePatternCommentBody("b".repeat(40)), - }), + it("사람이 마커를 포함해 단 댓글은 링크 댓글로 보지 않는다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + existingIssueComments: [ + { + id: 321, + user: { type: "User" }, + body: `${LINK_COMMENT_MARKER}\n복사한 댓글`, + }, ], - ], - ])("%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 }), - ]); + await runTagPatterns(); - expect(posts.map(({ path }) => path)).toEqual([solutionFile.filename]); + const commentWrites = requests.filter((r) => r.url.includes("/issues/")); + expect(commentWrites).toHaveLength(1); + expect(commentWrites[0].method).toBe("POST"); }); +}); - 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, - ]); +describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레이션", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - 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, - }); + function deletes(requests) { + return requests.filter((r) => r.method === "DELETE"); + } - const result = await runTagPatterns(); + it("LEGACY_COMPLEXITY_MARKER 가 있는 Bot issue comment 를 삭제한다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + existingIssueComments: [ + { + id: 1234, + user: { type: "Bot" }, + body: `${LEGACY_COMPLEXITY_MARKER}\n레거시`, + }, + ], + }); - expect(result).toEqual({ skipped: "review-comments-unavailable" }); - expect(posts).toHaveLength(0); - } - ); + await runTagPatterns(); - 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, + expect(deletes(requests)).toHaveLength(1); + expect(deletes(requests)[0].url).toContain("/issues/comments/1234"); + }); + + it("레거시 댓글이 없으면 DELETE 호출 없음", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], }); await runTagPatterns(); - expect(posts.map(({ path }) => path)).toEqual([changedFile.filename]); + expect(deletes(requests)).toHaveLength(0); }); - it("기존 분석 댓글의 파일 SHA와 현재 파일 SHA가 같으면 분석하지 않는다", async () => { - const solutionFile = makeSolutionFile("a", "user", "modified"); - globalThis.fetch = makeFetchMock({ - solutionFiles: [solutionFile], - existingReviewComments: [makeReviewComment(solutionFile)], + it("Bot 이 아닌 사용자가 LEGACY_COMPLEXITY_MARKER 를 포함한 댓글은 건드리지 않는다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + existingIssueComments: [ + { + id: 1234, + user: { type: "User" }, + body: `${LEGACY_COMPLEXITY_MARKER}\n사람이 쓴 댓글`, + }, + ], }); - const result = await runTagPatterns(); + await runTagPatterns(); - expect(result).toEqual({ skipped: "no-solution-files" }); + expect(deletes(requests)).toHaveLength(0); }); }); diff --git a/utils/commentMarker.js b/utils/commentMarker.js deleted file mode 100644 index f0021bb..0000000 --- a/utils/commentMarker.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 deleted file mode 100644 index 11c981c..0000000 --- a/utils/commentMarker.test.js +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 11f29f0..0000000 --- a/utils/markdown.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 deleted file mode 100644 index 677c6a2..0000000 --- a/utils/markdown.test.js +++ /dev/null @@ -1,33 +0,0 @@ -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)); - } - ); -});