diff --git a/AGENTS.md b/AGENTS.md index 192b38e..9bb0897 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,25 +51,26 @@ wrangler.jsonc Worker 설정과 공개 값(vars) | 이벤트 | 하는 일 | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `projects_v2_item` | Week 설정이 바뀌면 경고 댓글을 달거나 지운다 | -| `pull_request` (`opened`, `reopened`, `synchronize`) | Week 검사, AI 풀이 분석 check run(`tagPatterns`), 학습 현황 댓글(`postLearningStatus`) | +| `pull_request` (`opened`, `reopened`, `synchronize`) | Week 검사, AI 리뷰 check run(`aiReview`) | | `issue_comment`, `pull_request_review_comment` (`created`) | `@dalestudy` 멘션에 응답한다. `approve`·`승인` 이면 PR 을 승인하고, 그 밖에는 AI 리뷰를 단다 | -Week 검사는 제목에 `Week 숫자` 또는 `Onboarding`이 포함된 풀이 PR을 대상으로 하고, 경고 댓글은 봇이 단 것만 지운다. AI 풀이 분석과 학습 현황 댓글은 제목과 상관없이 돌리고(`[user] Onboarding` 같은 PR 도 포함), 풀이 파일이 없으면 두 핸들러가 AI 를 부르지 않고 건너뛴다. +Week 검사는 제목에 `Week 숫자` 또는 `Onboarding`이 포함된 풀이 PR을 대상으로 하고, 경고 댓글은 봇이 단 것만 지운다. AI 리뷰는 제목과 상관없이 돌리고(`[user] Onboarding` 같은 PR 도 포함), 풀이 파일이 없으면 AI 를 부르지 않고 건너뛴다. -### AI 작업을 별도 invocation 으로 나누기 +AI 리뷰는 풀이 파일별 알고리즘 패턴, 의도한 접근법 일치 여부(`problem-categories.json` 기준), 시간/공간 복잡도와 작성자의 이번 기수 학습 현황을 PR head 커밋의 check run 하나(`AI 리뷰`)에 담는다. 대화 탭에는 그 check run 으로 가는 링크 댓글 하나만 유지한다. -Worker 한 번의 실행(invocation)은 subrequest 를 50 개까지 쓸 수 있다. 파일이 많은 PR 에서 넘치지 않도록, 웹훅 핸들러는 두 AI 작업을 자기 자신에게 다시 요청해 따로 실행한다. +### AI 리뷰를 별도 invocation 으로 나누기 + +Worker 한 번의 실행(invocation)은 subrequest 를 50 개까지 쓸 수 있다. 파일이 많은 PR 에서 넘치지 않도록, 웹훅 핸들러는 AI 리뷰를 자기 자신에게 다시 요청해 따로 실행한다. ``` GitHub webhook ▼ [#1] webhook 핸들러 ← 50 예산 - ├─ ctx.waitUntil(fetch("/internal/tag-patterns")) → [#2] tagPatterns ← 독립 50 예산 - └─ ctx.waitUntil(fetch("/internal/learning-status")) → [#3] postLearningStatus ← 독립 50 예산 + └─ ctx.waitUntil(fetch("/internal/ai-review")) → [#2] aiReview ← 독립 50 예산 ``` -`INTERNAL_SECRET` 과 `WORKER_URL` 이 둘 다 있어야 켜진다. 하나라도 없으면 같은 invocation 에서 차례로 실행된다. -`tests/subrequest-budget.test.js` 가 fetch 횟수(`tagPatterns` 파일 15 개에 37, `postLearningStatus` 파일 5 개에 31)를 회귀 테스트로 고정한다. +`INTERNAL_SECRET` 과 `WORKER_URL` 이 둘 다 있어야 켜진다. 하나라도 없으면 같은 invocation 에서 실행된다. +`tests/subrequest-budget.test.js` 가 fetch 횟수(`aiReview` 파일 15 개에 40, AI 호출 포함)를 회귀 테스트로 고정한다. ## 배포와 시크릿 @@ -111,7 +112,6 @@ vi.mock("../utils/github.js", () => ({ - **Workers 런타임이다.** Node.js 모듈과 Octokit 같은 npm 패키지는 대부분 동작하지 않는다. `fetch` 와 Web Crypto API 로 직접 구현한다. Private Key 는 PKCS8, PKCS1 모두 받는다 (`utils/github.js`). - **GraphQL 쿼리에 값을 문자열로 직접 끼워 넣는다.** GraphQL 변수 문법을 쓰지 않으므로 입력값 검증을 빠뜨리지 않는다. -- **댓글 숨김 마커(``)의 직렬화 형식을 바꿀 때는** 정규식, 문서 주석, 테스트를 같은 PR 에서 함께 고친다. 파싱이 빈 값으로 fallback 하면 회귀가 조용히 묻힌다. - 응답에는 CORS 헤더(`Access-Control-Allow-Origin: *`)가 붙고, 에러는 500 으로 돌려준다. 원인은 `wrangler tail` 로 본다. - 401 이 나면 `APP_ID` 와 `PRIVATE_KEY` 가 같은 App 의 것인지 확인한다. @@ -120,6 +120,5 @@ vi.mock("../utils/github.js", () => ({ - Node.js 내장 모듈이나 Octokit 같은 Workers 런타임에서 동작하지 않는 패키지를 쓰면 지적한다. - GraphQL 쿼리에 문자열로 끼워 넣는 값은 반드시 검증되어야 한다. 사용자 입력(PR 제목, 댓글 등)이 검증 없이 들어가면 심각한 문제로 지적한다. - 새 엔드포인트는 기존처럼 `repo_owner`가 `DaleStudy`가 아니면 403을 돌려야 하고, `/internal/*`는 `X-Internal-Secret`을 검사해야 한다. -- `tagPatterns`나 `postLearningStatus`에 fetch가 늘어나면 `tests/subrequest-budget.test.js`의 고정값도 함께 갱신되어야 한다. -- 댓글 숨김 마커(``) 형식을 바꾸면 파싱 정규식과 테스트도 같은 PR에서 바뀌어야 한다. +- `aiReview`에 fetch가 늘어나면 `tests/subrequest-budget.test.js`의 고정값도 함께 갱신되어야 한다. - 새 시크릿을 `wrangler secret put` 전제로 추가하면 지적한다. 시크릿은 GitHub secret으로 두고 배포 워크플로가 넣는다. diff --git a/handlers/tag-patterns.js b/handlers/ai-review.js similarity index 61% rename from handlers/tag-patterns.js rename to handlers/ai-review.js index 1db8677..f54b612 100644 --- a/handlers/tag-patterns.js +++ b/handlers/ai-review.js @@ -1,10 +1,10 @@ /** - * AI 풀이 분석 핸들러 + * AI 리뷰 핸들러 * - * PR의 솔루션 파일들을 분석하여 알고리즘 패턴 + 시간/공간 복잡도를 - * PR head 커밋의 check run 하나로 남긴다. 사람 리뷰와 섞이지 않도록 리뷰 댓글은 - * 달지 않고, 대화 탭에는 check run 링크 댓글 하나만 유지한다. - * 복잡도 분석은 패턴 분석 루프와 병렬로 OpenAI 1콜에서 모든 파일을 한 번에 처리한다. + * PR의 솔루션 파일들을 AI로 리뷰해 알고리즘 패턴, 의도한 접근법 일치 여부, + * 시간/공간 복잡도, 작성자의 학습 현황을 PR head 커밋의 check run 하나로 남긴다. + * 사람 리뷰와 섞이지 않도록 리뷰 댓글은 달지 않고, 대화 탭에는 check run 링크 댓글 하나만 유지한다. + * 복잡도 분석(1콜)과 기수 풀이 조회는 파일별 패턴 분석 루프와 병렬로 진행한다. * * 주의: Workers Free 플랜의 외부 subrequest 한도는 invocation당 50회이므로 준수해야 한다. * @see https://developers.cloudflare.com/workers/platform/limits/#subrequests @@ -12,32 +12,40 @@ import { getGitHubHeaders } from "../utils/github.js"; import { generatePatternAnalysis } from "../utils/openai.js"; +import { + fetchProblemCategories, + fetchCohortSolvedProblems, +} from "../utils/learningData.js"; +import { renderLearningStatus } from "../utils/learningStatus.js"; import { callComplexityAnalysis, renderComplexitySection, } from "./complexity-analysis.js"; -const CHECK_RUN_NAME = "AI 풀이 분석"; +const CHECK_RUN_NAME = "AI 리뷰"; const LINK_COMMENT_MARKER = ""; -// 레거시 단독 복잡도 issue comment 식별용 -const LEGACY_COMPLEXITY_MARKER = ""; +// 예전에 issue comment 로 달던 결과물. 보이면 지운다. +const LEGACY_COMMENT_MARKERS = [ + "", + "", +]; 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의 솔루션 파일들을 분석해 check run 으로 게시하기 + * PR의 솔루션 파일들을 AI로 리뷰해 check run 으로 게시하기 * * @param {string} repoOwner * @param {string} repoName * @param {number} prNumber * @param {string} headSha - PR head commit SHA - * @param {object} prData - PR 객체 (draft, labels 포함) + * @param {object} prData - PR 객체 (draft, user 포함) * @param {string} appToken - GitHub App installation token * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) */ -export async function tagPatterns( +export async function aiReview( repoOwner, repoName, prNumber, @@ -46,14 +54,12 @@ export async function tagPatterns( appToken, ai ) { - // 2-1. Skip 조건 if (prData.draft === true) { - console.log(`[tagPatterns] Skipping PR #${prNumber}: draft`); + console.log(`[aiReview] Skipping PR #${prNumber}: draft`); return { skipped: "draft" }; } - // 2-2. PR 변경 파일 목록 조회 + 필터링 - // 풀이 파일이 없는 PR은 아래 SOLUTION_PATH_REGEX 필터에서 걸러진다 + // PR 변경 파일 목록 조회 + 필터링 const filesResponse = await fetch( `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`, { headers: getGitHubHeaders(appToken) } @@ -73,53 +79,74 @@ export async function tagPatterns( ); console.log( - `[tagPatterns] PR #${prNumber}: ${allFiles.length} total files, ${solutionFiles.length} solution files to analyze` + `[aiReview] PR #${prNumber}: ${allFiles.length} total files, ${solutionFiles.length} solution files to analyze` ); if (solutionFiles.length === 0) { return { skipped: "no-solution-files" }; } - // 2-3. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유) - const fileEntries = await downloadFileEntries(solutionFiles); + // 파일 raw 내용과 문제 정보를 함께 받는다. 문제 정보가 없어도 리뷰는 계속한다. + const [fileEntries, categories] = await Promise.all([ + downloadFileEntries(solutionFiles), + fetchProblemCategories(repoOwner, repoName, appToken).catch((err) => { + console.error(`[aiReview] problem categories fetch failed: ${err.message}`); + return null; + }), + ]); - // 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 분석은 게시. - const complexityPromise = callComplexityAnalysis(fileEntries, ai) - .catch((err) => { - console.error(`[tagPatterns] complexity analysis failed: ${err.message}`); + // 복잡도 분석(1콜)과 기수 풀이 조회는 패턴 루프와 병렬 진행. 실패해도 나머지는 게시. + const complexityPromise = callComplexityAnalysis(fileEntries, ai).catch( + (err) => { + console.error(`[aiReview] complexity analysis failed: ${err.message}`); return []; - }); - - // 2-5. 파일별 OpenAI 패턴 분석 (실패한 파일은 analysis 가 null) + } + ); + const learningStatusPromise = categories + ? fetchCohortSolvedProblems(repoOwner, repoName, prData.user.login, appToken) + .then((solved) => + renderLearningStatus(prData.user.login, categories, solved) + ) + .catch((err) => { + console.error(`[aiReview] learning status failed: ${err.message}`); + return null; + }) + : Promise.resolve(null); + + // 파일별 패턴 + 의도한 접근 분석 (실패한 파일은 analysis 가 null) const analyses = []; for (const fe of fileEntries) { + const problemInfo = categories?.[fe.problemName] ?? null; try { const analysis = await generatePatternAnalysis( fe.content, fe.problemName, + problemInfo, ai ); - analyses.push({ fileEntry: fe, analysis }); + analyses.push({ fileEntry: fe, problemInfo, analysis }); } catch (error) { console.error( - `[tagPatterns] Failed to analyze ${fe.file.filename}: ${error.message}` + `[aiReview] Failed to analyze ${fe.file.filename}: ${error.message}` ); - analyses.push({ fileEntry: fe, analysis: null }); + analyses.push({ fileEntry: fe, problemInfo, analysis: null }); } } - const complexityResults = await complexityPromise; + const [complexityResults, learningStatus] = await Promise.all([ + complexityPromise, + learningStatusPromise, + ]); - // 2-6. check run 게시 const checkRun = await createCheckRun( repoOwner, repoName, headSha, - renderCheckRunOutput(analyses, complexityResults), + renderCheckRunOutput(analyses, complexityResults, learningStatus), appToken ); - // 2-7. 대화 탭의 링크 댓글 갱신 + 레거시 단독 복잡도 issue comment 삭제 + // 대화 탭의 링크 댓글 갱신 + 레거시 issue comment 삭제 await syncIssueComments( repoOwner, repoName, @@ -137,27 +164,32 @@ export async function tagPatterns( /** * check run output(title, summary, text)을 렌더링한다. - * summary 에는 파일별 패턴 요약표를, text 에는 파일별 상세 분석을 넣는다. + * summary 에는 파일별 요약표와 학습 현황을, text 에는 파일별 상세 리뷰를 넣는다. * - * @param {Array<{fileEntry: object, analysis: object|null}>} analyses + * @param {Array<{fileEntry: object, problemInfo: object|null, analysis: object|null}>} analyses * @param {Array<{problemName: string, solutions: Array}>} complexityResults + * @param {string|null} learningStatus - 학습 현황 마크다운 */ -function renderCheckRunOutput(analyses, complexityResults) { +function renderCheckRunOutput(analyses, complexityResults, learningStatus) { const summaryLines = [ - "> 🤖 AI가 생성한 분석이라 틀릴 수 있어요. 리뷰어의 피드백을 우선해 주세요.", + "> 🤖 AI가 생성한 리뷰라 틀릴 수 있어요. 리뷰어의 피드백을 우선해 주세요.", "", - "| 파일 | 패턴 |", - "|---|---|", + "| 파일 | 난이도 | 패턴 | 의도한 접근 |", + "|---|---|---|---|", ...analyses.map( - ({ fileEntry, analysis }) => - `| \`${fileEntry.file.filename}\` | ${renderPatterns(analysis)} |` + ({ fileEntry, problemInfo, analysis }) => + `| \`${fileEntry.file.filename}\` | ${problemInfo?.difficulty ?? "-"} | ${renderPatterns(analysis)} | ${renderApproach(analysis)} |` ), ]; + if (learningStatus) { + summaryLines.push("", learningStatus); + } const text = analyses - .map(({ fileEntry, analysis }) => + .map(({ fileEntry, problemInfo, analysis }) => renderFileSection( fileEntry, + problemInfo, analysis, complexityResults.find((r) => r.problemName === fileEntry.problemName) ) @@ -165,12 +197,20 @@ function renderCheckRunOutput(analyses, complexityResults) { .join("\n\n"); return { - title: `풀이 파일 ${analyses.length}개 분석`, + title: renderTitle(analyses), summary: truncateOutput(summaryLines.join("\n")), text: truncateOutput(text), }; } +function renderTitle(analyses) { + const title = `풀이 ${analyses.length}개 리뷰`; + const graded = analyses.filter((a) => a.analysis?.approach); + if (graded.length === 0) return title; + const matched = graded.filter((a) => a.analysis.approach.matches).length; + return `${title} · 의도한 접근 ${matched}/${graded.length}`; +} + function renderPatterns(analysis) { if (analysis === null) return "⚠️ 분석 실패"; return analysis.patterns.length > 0 @@ -178,7 +218,12 @@ function renderPatterns(analysis) { : "감지된 패턴 없음"; } -function renderFileSection(fileEntry, analysis, complexityForFile) { +function renderApproach(analysis) { + if (!analysis?.approach) return "-"; + return analysis.approach.matches ? "✅ 일치" : "⚠️ 다름"; +} + +function renderFileSection(fileEntry, problemInfo, analysis, complexityForFile) { const lines = [`## \`${fileEntry.file.filename}\``, ""]; if (fileEntry.isContentTruncated) { @@ -196,6 +241,11 @@ function renderFileSection(fileEntry, analysis, complexityForFile) { `- **패턴**: ${renderPatterns(analysis)}`, `- **설명**: ${analysis.description || "(설명 없음)"}` ); + if (analysis.approach) { + lines.push( + `- **의도한 접근** (${problemInfo.intended_approach}): ${renderApproach(analysis)}${analysis.approach.explanation ? ` — ${analysis.approach.explanation}` : ""}` + ); + } } // 복잡도 섹션은 해당 파일 결과가 있을 때만 붙인다 @@ -249,7 +299,7 @@ async function createCheckRun(repoOwner, repoName, headSha, output, appToken) { /** * 솔루션 파일들의 raw 내용을 한 번에 다운로드한다. - * 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다. + * 패턴 분석과 복잡도 분석이 같은 fileEntries 를 공유한다. */ async function downloadFileEntries(solutionFiles) { return Promise.all( @@ -265,7 +315,7 @@ async function downloadFileEntries(solutionFiles) { if (isContentTruncated) { content = content.slice(0, MAX_FILE_CONTENT_LENGTH); console.log( - `[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_CONTENT_LENGTH} chars` + `[aiReview] Truncated ${file.filename} to ${MAX_FILE_CONTENT_LENGTH} chars` ); } return { @@ -282,7 +332,7 @@ async function downloadFileEntries(solutionFiles) { * issue comment 목록을 한 번 읽어 두 가지를 처리한다. * - 링크 댓글: 봇이 단 것이 있으면 최신 check run 링크로 고치고, 없으면 새로 단다. * 분석 중에 새 커밋이 푸시됐다면 더 늦게 끝난 이전 분석이 최신 링크를 덮지 않도록 건너뛴다. - * - 레거시 단독 복잡도 issue comment: 있으면 삭제한다 (마이그레이션). + * - 레거시 issue comment(단독 복잡도 분석, 학습 현황): 있으면 삭제한다 (마이그레이션). * 실패해도 check run 은 이미 게시됐으므로 로그만 남긴다. */ async function syncIssueComments( @@ -321,13 +371,15 @@ async function syncIssueComments( ); } else { console.log( - `[tagPatterns] Skipping link comment on PR #${prNumber}: head moved from ${headSha} to ${latestHeadSha}` + `[aiReview] Skipping link comment on PR #${prNumber}: head moved from ${headSha} to ${latestHeadSha}` ); } - const legacy = findBotComment(LEGACY_COMPLEXITY_MARKER); - if (legacy) { - await deleteIssueComment(repoOwner, repoName, legacy.id, appToken); + for (const marker of LEGACY_COMMENT_MARKERS) { + const legacy = findBotComment(marker); + if (legacy) { + await deleteIssueComment(repoOwner, repoName, legacy.id, appToken); + } } } @@ -344,7 +396,7 @@ async function listIssueComments(repoOwner, repoName, prNumber, appToken) { if (!response.ok) { console.error( - `[tagPatterns] Failed to list issue comments: ${response.status}` + `[aiReview] Failed to list issue comments: ${response.status}` ); return null; } @@ -365,7 +417,7 @@ async function getHeadSha(repoOwner, repoName, prNumber, appToken) { ); if (!response.ok) { - console.error(`[tagPatterns] Failed to get PR #${prNumber}: ${response.status}`); + console.error(`[aiReview] Failed to get PR #${prNumber}: ${response.status}`); return null; } @@ -382,7 +434,7 @@ async function upsertLinkComment( appToken ) { const body = `${LINK_COMMENT_MARKER} -🤖 AI 풀이 분석(알고리즘 패턴, 시간/공간 복잡도) 결과는 [Checks 탭](${checkRunUrl})에서 볼 수 있어요.`; +🤖 AI 리뷰(알고리즘 패턴, 시간/공간 복잡도, 학습 현황) 결과는 [Checks 탭](${checkRunUrl})에서 볼 수 있어요.`; const url = existing ? `https://api.github.com/repos/${repoOwner}/${repoName}/issues/comments/${existing.id}` @@ -400,12 +452,12 @@ async function upsertLinkComment( if (!response.ok) { console.error( - `[tagPatterns] Failed to upsert link comment on PR #${prNumber}: ${response.status}` + `[aiReview] Failed to upsert link comment on PR #${prNumber}: ${response.status}` ); } } catch (error) { console.error( - `[tagPatterns] Error upserting link comment on PR #${prNumber}: ${error.message}` + `[aiReview] Error upserting link comment on PR #${prNumber}: ${error.message}` ); } } @@ -422,17 +474,17 @@ async function deleteIssueComment(repoOwner, repoName, commentId, appToken) { if (!response.ok) { console.error( - `[tagPatterns] Failed to delete legacy complexity comment ${commentId}: ${response.status}` + `[aiReview] Failed to delete legacy comment ${commentId}: ${response.status}` ); return; } console.log( - `[tagPatterns] Deleted legacy complexity issue comment ${commentId}` + `[aiReview] Deleted legacy issue comment ${commentId}` ); } catch (error) { console.error( - `[tagPatterns] Error deleting legacy complexity comment ${commentId}: ${error.message}` + `[aiReview] Error deleting legacy comment ${commentId}: ${error.message}` ); } } diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index 32172ee..7e39083 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -1,7 +1,7 @@ /** * 시간/공간 복잡도 분석 (분석 함수 + 순수 헬퍼). * - * 오케스트레이션과 check run 게시는 tag-patterns 핸들러가 담당하며, 이 모듈은 + * 오케스트레이션과 check run 게시는 ai-review 핸들러가 담당하며, 이 모듈은 * OpenAI 호출(`callComplexityAnalysis`)과 사용자 주석 처리/매칭 헬퍼, * 한 파일분 섹션 렌더러(`renderComplexitySection`)만 제공한다. * diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index 024afbe..6d9cde1 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -2,14 +2,13 @@ * 내부 디스패치 핸들러 * * self-fetch를 통해 호출되는 내부 엔드포인트. - * 각 핸들러가 별도 Worker 호출(invocation)에서 실행되므로 + * 웹훅 invocation 과 분리된 별도 Worker 호출(invocation)에서 실행되므로 * 독립적인 subrequest 예산(50)을 갖는다. */ import { generateGitHubAppToken } from "../utils/github.js"; import { errorResponse, corsResponse } from "../utils/cors.js"; -import { tagPatterns } from "./tag-patterns.js"; -import { postLearningStatus } from "./learning-status.js"; +import { aiReview } from "./ai-review.js"; const INTERNAL_HEADER = "X-Internal-Secret"; @@ -42,11 +41,8 @@ export async function handleInternalDispatch(request, env, pathname) { const appToken = await generateGitHubAppToken(env); switch (pathname) { - case "/internal/tag-patterns": - return await handleTagPatterns(payload, appToken, env); - - case "/internal/learning-status": - return await handleLearningStatus(payload, appToken, env); + case "/internal/ai-review": + return await handleAiReview(payload, appToken, env); default: return errorResponse("Not found", 404); @@ -57,35 +53,16 @@ export async function handleInternalDispatch(request, env, pathname) { } } -async function handleTagPatterns(payload, appToken, env) { - const { +async function handleAiReview(payload, appToken, env) { + const { repoOwner, repoName, prNumber, headSha, prData } = payload; + const result = await aiReview( repoOwner, repoName, prNumber, headSha, prData, - } = payload; - const result = await tagPatterns( - repoOwner, - repoName, - prNumber, - headSha, - prData, - appToken, - env.AI - ); - return corsResponse({ handler: "tag-patterns", result }); -} - -async function handleLearningStatus(payload, appToken, env) { - const { repoOwner, repoName, prNumber, username } = payload; - const result = await postLearningStatus( - repoOwner, - repoName, - prNumber, - username, appToken, env.AI ); - return corsResponse({ handler: "learning-status", result }); + return corsResponse({ handler: "ai-review", result }); } diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index a93a247..9eb8a92 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -4,17 +4,12 @@ vi.mock("../utils/github.js", () => ({ generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"), })); -vi.mock("./tag-patterns.js", () => ({ - tagPatterns: vi.fn().mockResolvedValue({ analyzed: 1 }), -})); - -vi.mock("./learning-status.js", () => ({ - postLearningStatus: vi.fn().mockResolvedValue({ posted: true }), +vi.mock("./ai-review.js", () => ({ + aiReview: vi.fn().mockResolvedValue({ analyzed: 1 }), })); import { handleInternalDispatch } from "./internal-dispatch.js"; -import { tagPatterns } from "./tag-patterns.js"; -import { postLearningStatus } from "./learning-status.js"; +import { aiReview } from "./ai-review.js"; import { generateGitHubAppToken } from "../utils/github.js"; const FAKE_AI = { gateway: () => {} }; @@ -39,7 +34,7 @@ describe("handleInternalDispatch — 인증", () => { }); it("env 에 INTERNAL_SECRET 이 없으면 401 을 반환한다", async () => { - const request = makeRequest("/internal/tag-patterns", { + const request = makeRequest("/internal/ai-review", { secret: "anything", body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, }); @@ -47,33 +42,33 @@ describe("handleInternalDispatch — 인증", () => { const response = await handleInternalDispatch( request, {}, - "/internal/tag-patterns" + "/internal/ai-review" ); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); expect(generateGitHubAppToken).not.toHaveBeenCalled(); - expect(tagPatterns).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); it("X-Internal-Secret 헤더가 없으면 401 을 반환한다", async () => { - const request = makeRequest("/internal/tag-patterns", { + const request = makeRequest("/internal/ai-review", { body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, }); const response = await handleInternalDispatch( request, { INTERNAL_SECRET: VALID_SECRET }, - "/internal/tag-patterns" + "/internal/ai-review" ); expect(response.status).toBe(401); - expect(tagPatterns).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); it("X-Internal-Secret 헤더가 env.INTERNAL_SECRET 과 일치하지 않으면 401 을 반환한다", async () => { - const request = makeRequest("/internal/tag-patterns", { + const request = makeRequest("/internal/ai-review", { secret: "wrong-secret", body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, }); @@ -81,11 +76,11 @@ describe("handleInternalDispatch — 인증", () => { const response = await handleInternalDispatch( request, { INTERNAL_SECRET: VALID_SECRET }, - "/internal/tag-patterns" + "/internal/ai-review" ); expect(response.status).toBe(401); - expect(tagPatterns).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); }); @@ -96,9 +91,9 @@ describe("handleInternalDispatch — 라우팅", () => { vi.clearAllMocks(); }); - it("/internal/tag-patterns 요청을 tagPatterns 로 payload 필드와 함께 라우팅한다", async () => { + it("/internal/ai-review 요청을 aiReview 로 payload 필드와 함께 라우팅한다", async () => { const prData = { number: 42, head: { sha: "abc123" } }; - const request = makeRequest("/internal/tag-patterns", { + const request = makeRequest("/internal/ai-review", { secret: VALID_SECRET, body: { repoOwner: "DaleStudy", @@ -112,13 +107,13 @@ describe("handleInternalDispatch — 라우팅", () => { const response = await handleInternalDispatch( request, env, - "/internal/tag-patterns" + "/internal/ai-review" ); expect(response.status).toBe(200); const body = await response.json(); - expect(body.handler).toBe("tag-patterns"); - expect(tagPatterns).toHaveBeenCalledWith( + expect(body.handler).toBe("ai-review"); + expect(aiReview).toHaveBeenCalledWith( "DaleStudy", "leetcode-study", 42, @@ -127,42 +122,14 @@ describe("handleInternalDispatch — 라우팅", () => { "fake-token", FAKE_AI ); - expect(postLearningStatus).not.toHaveBeenCalled(); }); - it("/internal/learning-status 요청을 postLearningStatus 로 payload 필드와 함께 라우팅한다", async () => { - const request = makeRequest("/internal/learning-status", { - secret: VALID_SECRET, - body: { - repoOwner: "DaleStudy", - repoName: "leetcode-study", - prNumber: 42, - username: "testuser", - }, - }); - - const response = await handleInternalDispatch( - request, - env, - "/internal/learning-status" - ); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.handler).toBe("learning-status"); - expect(postLearningStatus).toHaveBeenCalledWith( - "DaleStudy", - "leetcode-study", - 42, - "testuser", - "fake-token", - FAKE_AI - ); - expect(tagPatterns).not.toHaveBeenCalled(); - }); - - it("/internal/complexity-analysis 경로는 더 이상 라우팅되지 않고 404 를 반환한다", async () => { - const request = makeRequest("/internal/complexity-analysis", { + it.each([ + "/internal/tag-patterns", + "/internal/learning-status", + "/internal/complexity-analysis", + ])("%s 는 ai-review 로 합쳐져 더 이상 라우팅되지 않고 404 를 반환한다", async (pathname) => { + const request = makeRequest(pathname, { secret: VALID_SECRET, body: { repoOwner: "DaleStudy", @@ -172,15 +139,10 @@ describe("handleInternalDispatch — 라우팅", () => { }, }); - const response = await handleInternalDispatch( - request, - env, - "/internal/complexity-analysis" - ); + const response = await handleInternalDispatch(request, env, pathname); expect(response.status).toBe(404); - expect(tagPatterns).not.toHaveBeenCalled(); - expect(postLearningStatus).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); it("알 수 없는 /internal/* 경로는 404 를 반환한다", async () => { @@ -196,8 +158,7 @@ describe("handleInternalDispatch — 라우팅", () => { ); expect(response.status).toBe(404); - expect(tagPatterns).not.toHaveBeenCalled(); - expect(postLearningStatus).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); }); @@ -208,10 +169,10 @@ describe("handleInternalDispatch — 에러 처리", () => { vi.clearAllMocks(); }); - it("tagPatterns 핸들러가 throw 하면 500 을 반환한다", async () => { - tagPatterns.mockRejectedValueOnce(new Error("boom")); + it("aiReview 핸들러가 throw 하면 500 을 반환한다", async () => { + aiReview.mockRejectedValueOnce(new Error("boom")); - const request = makeRequest("/internal/tag-patterns", { + const request = makeRequest("/internal/ai-review", { secret: VALID_SECRET, body: { repoOwner: "DaleStudy", @@ -225,7 +186,7 @@ describe("handleInternalDispatch — 에러 처리", () => { const response = await handleInternalDispatch( request, env, - "/internal/tag-patterns" + "/internal/ai-review" ); expect(response.status).toBe(500); diff --git a/handlers/learning-status.js b/handlers/learning-status.js deleted file mode 100644 index f0f7f32..0000000 --- a/handlers/learning-status.js +++ /dev/null @@ -1,228 +0,0 @@ -/** - * 학습 현황 오케스트레이션 핸들러 - * - * PR 제출 파일들을 AI로 분석하여 사용자의 누적 학습 현황을 - * PR 이슈 댓글로 게시하거나 업데이트한다. - */ - -import { - fetchProblemCategories, - fetchCohortUserSolutions, - fetchPRSubmissions, -} from "../utils/learningData.js"; -import { generateApproachAnalysis } from "../utils/openai.js"; -import { - formatLearningStatusComment, - upsertLearningStatusComment, -} from "../utils/learningComment.js"; - -const MAX_FILE_SIZE = 15000; // 15K 문자 제한 (OpenAI 토큰 안전장치) - -// Blind Top 75 큐레이션 순서 — 동률 정렬의 타이브레이커로 사용. -// 출처: https://www.teamblind.com/post/new-year-gift-curated-list-of-top-75-leetcode-questions-to-save-your-time-oam1oreu -const BLIND_CATEGORY_ORDER = [ - "Array", - "Binary", - "Dynamic Programming", - "Graph", - "Interval", - "Linked List", - "Matrix", - "String", - "Tree", - "Heap", -]; - -/** - * Blind Top 75 카테고리별로 누적 풀이 진행도를 계산한다. - * - * `problem-categories.json` 의 각 문제에 포함된 `blindCategories` 필드를 - * 기준으로 Blind 10개 카테고리만 집계한다 (이슈 #34). - * - * @param {object} categories - problem-categories.json 전체 오브젝트 - * @param {string[]} solvedProblems - 사용자가 풀이한 문제 이름 배열 - * @returns {Array<{ category: string, solved: number, total: number, difficulties: string }>} - */ -function buildCategoryProgress(categories, solvedProblems) { - const solvedSet = new Set(solvedProblems); - const categoryMap = new Map( - BLIND_CATEGORY_ORDER.map((cat) => [ - cat, - { total: 0, solved: 0, solvedDifficulties: [] }, - ]) - ); - - for (const [problemName, info] of Object.entries(categories)) { - for (const cat of info.blindCategories) { - const entry = categoryMap.get(cat); - if (!entry) continue; - entry.total++; - if (solvedSet.has(problemName)) { - entry.solved++; - entry.solvedDifficulties.push(info.difficulty); - } - } - } - - return [...categoryMap.entries()] - .sort((a, b) => { - const ratioA = a[1].total > 0 ? a[1].solved / a[1].total : 0; - const ratioB = b[1].total > 0 ? b[1].solved / b[1].total : 0; - if (ratioB !== ratioA) return ratioB - ratioA; - return ( - BLIND_CATEGORY_ORDER.indexOf(a[0]) - BLIND_CATEGORY_ORDER.indexOf(b[0]) - ); - }) - .map(([cat, data]) => { - const diffCounts = {}; - for (const d of data.solvedDifficulties) { - diffCounts[d] = (diffCounts[d] || 0) + 1; - } - const difficulties = Object.entries(diffCounts) - .map(([d, c]) => `${d} ${c}`) - .join(", "); - return { category: cat, solved: data.solved, total: data.total, difficulties }; - }); -} - -/** - * 학습 현황 기능 전체 흐름 오케스트레이션 - * - * @param {string} repoOwner - * @param {string} repoName - * @param {number} prNumber - * @param {string} username - * @param {string} appToken - GitHub App installation token - * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) - * @returns {Promise<{ skipped: string }|{ analyzed: number, matched: number }>} - */ -export async function postLearningStatus( - repoOwner, - repoName, - prNumber, - username, - appToken, - ai -) { - // 1. 문제 카테고리 메타데이터 조회 - const categories = await fetchProblemCategories(repoOwner, repoName, appToken); - if (!categories) { - console.log(`[learningStatus] PR #${prNumber}: problem-categories.json not found, skipping`); - return { skipped: "no-categories-file" }; - } - - // 2. 이번 기수에서 사용자가 제출한 풀이 목록 조회 - const cohortSolutions = await fetchCohortUserSolutions(repoOwner, repoName, username, appToken); - const solvedProblems = cohortSolutions.filter((problemName) => - Object.hasOwn(categories, problemName) - ); - console.log( - `[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} solutions in current cohort` - ); - - // 3. 이번 PR 제출 파일 목록 조회 - const submissions = await fetchPRSubmissions(repoOwner, repoName, prNumber, username, appToken); - if (submissions.length === 0) { - console.log(`[learningStatus] PR #${prNumber}: no solution files found for ${username}, skipping`); - return { skipped: "no-solution-files" }; - } - - console.log( - `[learningStatus] PR #${prNumber}: analyzing ${submissions.length} submission(s) for ${username}` - ); - - // 4. 제출 파일별 AI 분석 - const submissionResults = []; - const totalUsage = { prompt_tokens: 0, completion_tokens: 0 }; - - for (const submission of submissions) { - const problemInfo = categories[submission.problemName]; - - if (!problemInfo) { - console.log( - `[learningStatus] No category info for "${submission.problemName}", skipping AI analysis` - ); - submissionResults.push({ - problemName: submission.problemName, - difficulty: null, - matches: null, - explanation: "", - }); - continue; - } - - try { - // 파일 원본 내용 가져오기 - const rawResponse = await fetch(submission.rawUrl); - if (!rawResponse.ok) { - throw new Error(`Failed to fetch raw file: ${rawResponse.status} ${rawResponse.statusText}`); - } - - let fileContent = await rawResponse.text(); - if (fileContent.length > MAX_FILE_SIZE) { - fileContent = fileContent.slice(0, MAX_FILE_SIZE); - console.log( - `[learningStatus] Truncated "${submission.problemName}" to ${MAX_FILE_SIZE} chars` - ); - } - - const analysis = await generateApproachAnalysis( - fileContent, - submission.problemName, - problemInfo, - ai - ); - - if (analysis.usage) { - totalUsage.prompt_tokens += analysis.usage.prompt_tokens ?? 0; - totalUsage.completion_tokens += analysis.usage.completion_tokens ?? 0; - } - - submissionResults.push({ - problemName: submission.problemName, - difficulty: problemInfo.difficulty, - matches: analysis.matches, - explanation: analysis.explanation, - }); - } catch (error) { - console.error( - `[learningStatus] Failed to analyze "${submission.problemName}": ${error.message}` - ); - submissionResults.push({ - problemName: submission.problemName, - difficulty: problemInfo.difficulty, - matches: null, - explanation: "", - }); - } - } - - const hasUsage = totalUsage.prompt_tokens > 0 || totalUsage.completion_tokens > 0; - - // 5. 카테고리별 진행도 계산 - const totalProblems = Object.keys(categories).length; - const categoryProgress = buildCategoryProgress(categories, solvedProblems); - - // 6. 댓글 본문 포맷 - const commentBody = formatLearningStatusComment( - username, - submissionResults, - solvedProblems.length, - totalProblems, - categoryProgress - ); - - // 7. 댓글 생성 또는 업데이트 - await upsertLearningStatusComment( - repoOwner, - repoName, - prNumber, - commentBody, - appToken, - hasUsage ? totalUsage : null - ); - - // 8. 결과 반환 - const matchedCount = submissionResults.filter((r) => r.matches === true).length; - return { analyzed: submissionResults.length, matched: matchedCount }; -} diff --git a/handlers/learning-status.test.js b/handlers/learning-status.test.js deleted file mode 100644 index 55ff679..0000000 --- a/handlers/learning-status.test.js +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -vi.mock("../utils/learningData.js", () => ({ - fetchProblemCategories: vi.fn(), - fetchCohortUserSolutions: vi.fn(), - fetchPRSubmissions: vi.fn(), -})); - -vi.mock("../utils/openai.js", () => ({ - generateApproachAnalysis: vi.fn(), -})); - -import { postLearningStatus } from "./learning-status.js"; -import { - fetchProblemCategories, - fetchCohortUserSolutions, - fetchPRSubmissions, -} from "../utils/learningData.js"; -import { generateApproachAnalysis } from "../utils/openai.js"; - -describe("postLearningStatus — 누적 풀이 집계", () => { - let originalFetch; - - beforeEach(() => { - vi.clearAllMocks(); - originalFetch = globalThis.fetch; - fetchProblemCategories.mockResolvedValue({ - "two-sum": { - difficulty: "Easy", - blindCategories: ["Array"], - intended_approach: "Hash Table", - }, - }); - fetchPRSubmissions.mockResolvedValue([ - { problemName: "two-sum", rawUrl: "https://example.com/solution.py" }, - ]); - generateApproachAnalysis.mockResolvedValue({ - matches: true, - explanation: "Uses a hash table", - }); - globalThis.fetch = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve("class Solution: pass"), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve([]), - }) - .mockResolvedValueOnce({ ok: true }); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it.each([ - { - name: "등록된 문제의 풀이 수를 유지한다", - solved: ["two-sum"], - expected: 1, - }, - { - name: "미등록 문제가 섞여 있으면 등록된 문제만 집계한다", - solved: ["to-lower-case", "two-sum"], - expected: 1, - }, - { - name: "미등록 문제만 풀었으면 풀이 수는 0이다", - solved: ["to-lower-case"], - expected: 0, - }, - ])("$name", async ({ solved, expected }) => { - fetchCohortUserSolutions.mockResolvedValue(solved); - - await postLearningStatus( - "DaleStudy", - "leetcode-study", - 42, - "learner", - "token", - {} - ); - - const post = globalThis.fetch.mock.calls.find( - ([, options]) => options?.method === "POST" - ); - expect(post).toBeDefined(); - - const body = JSON.parse(post[1].body).body; - expect(body).toContain(`풀이한 문제: ${expected} / 1개`); - }); -}); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index cc16a49..f5c3b82 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -21,8 +21,7 @@ import { import { ALLOWED_REPO } from "../utils/constants.js"; import { performAIReview, addReactionToComment } from "../utils/prReview.js"; import { hasApprovedReview, safeJson } from "../utils/prActions.js"; -import { tagPatterns } from "./tag-patterns.js"; -import { postLearningStatus } from "./learning-status.js"; +import { aiReview } from "./ai-review.js"; /** * GitHub webhook 이벤트 처리 @@ -256,53 +255,34 @@ async function handlePullRequestEvent(payload, env, ctx) { console.log(`PR synchronized: #${prNumber}`); } - // AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산) + // AI 리뷰를 별도 Worker 호출로 디스패치 (독립적인 subrequest 예산) if (env.AI && env.INTERNAL_SECRET && env.WORKER_URL) { - const baseUrl = env.WORKER_URL; - - const dispatchHeaders = { - "Content-Type": "application/json", - "X-Internal-Secret": env.INTERNAL_SECRET, - }; - - const commonPayload = { repoOwner, repoName, prNumber }; - - // 패턴 태깅 디스패치 ctx.waitUntil( - fetch(`${baseUrl}/internal/tag-patterns`, { + fetch(`${env.WORKER_URL}/internal/ai-review`, { method: "POST", - headers: dispatchHeaders, + headers: { + "Content-Type": "application/json", + "X-Internal-Secret": env.INTERNAL_SECRET, + }, body: JSON.stringify({ - ...commonPayload, + repoOwner, + repoName, + prNumber, headSha: pr.head.sha, prData: pr, }), }).catch((err) => - console.error(`[dispatch] tagPatterns failed: ${err.message}`) + console.error(`[dispatch] aiReview failed: ${err.message}`) ) ); - // 학습 현황 디스패치 - ctx.waitUntil( - fetch(`${baseUrl}/internal/learning-status`, { - method: "POST", - headers: dispatchHeaders, - body: JSON.stringify({ - ...commonPayload, - username: pr.user.login, - }), - }).catch((err) => - console.error(`[dispatch] learningStatus failed: ${err.message}`) - ) - ); - - console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`); + console.log(`[handlePullRequestEvent] Dispatched AI review for PR #${prNumber}`); } else if (env.AI) { - // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) - console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); + // INTERNAL_SECRET/WORKER_URL 미설정 시 같은 invocation 에서 실행 + console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running AI review in-process"); try { - await tagPatterns( + await aiReview( repoOwner, repoName, prNumber, @@ -312,13 +292,7 @@ async function handlePullRequestEvent(payload, env, ctx) { env.AI ); } catch (error) { - console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`); - } - - try { - await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.AI); - } catch (error) { - console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`); + console.error(`[handlePullRequestEvent] aiReview failed: ${error.message}`); } } diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 3f9e524..f9bb013 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -24,12 +24,8 @@ vi.mock("../utils/prActions.js", () => ({ safeJson: vi.fn(), })); -vi.mock("./tag-patterns.js", () => ({ - tagPatterns: vi.fn(), -})); - -vi.mock("./learning-status.js", () => ({ - postLearningStatus: vi.fn(), +vi.mock("./ai-review.js", () => ({ + aiReview: vi.fn(), })); import { handleWebhook } from "./webhooks.js"; @@ -39,8 +35,7 @@ import { removeWarningComment, handleWeekComment, } from "../utils/prWeeks.js"; -import { tagPatterns } from "./tag-patterns.js"; -import { postLearningStatus } from "./learning-status.js"; +import { aiReview } from "./ai-review.js"; const FAKE_AI = { gateway: () => {} }; @@ -288,7 +283,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { }); }); - it("AI 바인딩, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { + it("AI 바인딩, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 1 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { AI: FAKE_AI, @@ -303,21 +298,17 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { ); expect(response.status).toBe(200); - expect(ctx.waitUntil).toHaveBeenCalledTimes(2); + expect(ctx.waitUntil).toHaveBeenCalledTimes(1); const fetchedUrls = globalThis.fetch.mock.calls.map(([url]) => url); - expect(fetchedUrls).toContain("https://worker.test/internal/tag-patterns"); - expect(fetchedUrls).toContain("https://worker.test/internal/learning-status"); - // complexity-analysis 는 tag-patterns 에 합본되어 별도 디스패치 없음 - expect(fetchedUrls).not.toContain("https://worker.test/internal/complexity-analysis"); + expect(fetchedUrls).toEqual(["https://worker.test/internal/ai-review"]); const dispatchCall = globalThis.fetch.mock.calls.find(([url]) => - url.endsWith("/internal/tag-patterns") + url.endsWith("/internal/ai-review") ); expect(dispatchCall[1].headers["X-Internal-Secret"]).toBe("fake-secret"); - expect(tagPatterns).not.toHaveBeenCalled(); - expect(postLearningStatus).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); it("INTERNAL_SECRET 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { @@ -335,10 +326,9 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(response.status).toBe(200); expect(ctx.waitUntil).not.toHaveBeenCalled(); - expect(tagPatterns).toHaveBeenCalledTimes(1); - expect(postLearningStatus).toHaveBeenCalledTimes(1); + expect(aiReview).toHaveBeenCalledTimes(1); - const [repoOwner, repoName, prNumber] = tagPatterns.mock.calls[0]; + const [repoOwner, repoName, prNumber] = aiReview.mock.calls[0]; expect(repoOwner).toBe("DaleStudy"); expect(repoName).toBe("leetcode-study"); expect(prNumber).toBe(42); @@ -359,8 +349,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(response.status).toBe(200); expect(ctx.waitUntil).not.toHaveBeenCalled(); - expect(tagPatterns).toHaveBeenCalledTimes(1); - expect(postLearningStatus).toHaveBeenCalledTimes(1); + expect(aiReview).toHaveBeenCalledTimes(1); }); it("AI 바인딩이 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { @@ -378,8 +367,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(response.status).toBe(200); expect(ctx.waitUntil).not.toHaveBeenCalled(); - expect(tagPatterns).not.toHaveBeenCalled(); - expect(postLearningStatus).not.toHaveBeenCalled(); + expect(aiReview).not.toHaveBeenCalled(); }); }); @@ -412,7 +400,7 @@ describe("handlePullRequestEvent — WEEK 가 없는 제목", () => { it.each([ ["[testuser] Onboarding", true], ["docs: update README", false], - ])("%s: Week 검사 여부는 %s이고 AI 핸들러는 디스패치한다", async (title, checksWeek) => { + ])("%s: Week 검사 여부는 %s이고 AI 리뷰는 디스패치한다", async (title, checksWeek) => { const ctx = { waitUntil: vi.fn() }; const env = { AI: FAKE_AI, @@ -431,10 +419,9 @@ describe("handlePullRequestEvent — WEEK 가 없는 제목", () => { expect(response.status).toBe(200); expect(handleWeekComment).toHaveBeenCalledTimes(checksWeek ? 1 : 0); - expect(ctx.waitUntil).toHaveBeenCalledTimes(2); + expect(ctx.waitUntil).toHaveBeenCalledTimes(1); const fetchedUrls = globalThis.fetch.mock.calls.map(([url]) => url); - expect(fetchedUrls).toContain("https://worker.test/internal/tag-patterns"); - expect(fetchedUrls).toContain("https://worker.test/internal/learning-status"); + expect(fetchedUrls).toEqual(["https://worker.test/internal/ai-review"]); }); }); diff --git a/tests/tag-patterns.test.js b/tests/ai-review.test.js similarity index 72% rename from tests/tag-patterns.test.js rename to tests/ai-review.test.js index bb48eaa..ac3d424 100644 --- a/tests/tag-patterns.test.js +++ b/tests/ai-review.test.js @@ -8,7 +8,7 @@ vi.mock("../utils/github.js", () => ({ })), })); -import { tagPatterns } from "../handlers/tag-patterns.js"; +import { aiReview } from "../handlers/ai-review.js"; import { createFakeAi } from "./fakeAi.js"; const REPO_OWNER = "DaleStudy"; @@ -20,6 +20,7 @@ const AI = createFakeAi(); const LINK_COMMENT_MARKER = ""; const LEGACY_COMPLEXITY_MARKER = ""; +const LEGACY_LEARNING_STATUS_MARKER = ""; const MAX_FILE_CONTENT_LENGTH = 20000; const CHECK_RUN_URL = "https://github.com/DaleStudy/leetcode-study/runs/777"; @@ -63,11 +64,11 @@ function makeSolutionFile(problemName, username = "testuser", status = "added") } function makePrData(overrides = {}) { - return { draft: false, labels: [], ...overrides }; + return { draft: false, labels: [], user: { login: "testuser" }, ...overrides }; } -function runTagPatterns(prData = makePrData()) { - return tagPatterns( +function runAiReview(prData = makePrData()) { + return aiReview( REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, prData, APP_TOKEN, AI @@ -76,7 +77,8 @@ function runTagPatterns(prData = makePrData()) { /** * 두 OpenAI 엔드포인트(패턴 분석 N콜 + 복잡도 분석 1콜)를 분기하고, - * 쓰기 요청(POST/PATCH/DELETE)은 requests 에 모은다. + * GitHub 쓰기 요청(POST/PATCH/DELETE)은 requests 에 모은다. + * categories 가 null 이면 problem-categories.json 은 404 다. * 복잡도 호출은 system_prompt 에 "시간/공간 복잡도를 분석" 이 포함되는지로 식별. */ function makeFetchMock({ @@ -89,13 +91,21 @@ function makeFetchMock({ checkRunFails = false, existingIssueComments = [], latestHeadSha = HEAD_SHA, + categories = null, + cohortPRs = [], + cohortFails = false, } = {}) { const requests = []; + const patternPrompts = []; 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/")) { + if ( + method !== "GET" && + !urlStr.includes("/openai/") && + !urlStr.endsWith("/graphql") + ) { requests.push({ method, url: urlStr, @@ -111,6 +121,15 @@ function makeFetchMock({ return okText(rawContent); } + if (urlStr.includes("/contents/problem-categories.json")) { + return categories ? okJson(categories) : failResponse(404); + } + + if (urlStr === "https://api.github.com/graphql") { + if (cohortFails) return failResponse(502); + return okJson({ data: { search: { nodes: cohortPRs } } }); + } + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( @@ -126,6 +145,7 @@ function makeFetchMock({ }); } const userPrompt = body.messages[1].content; + patternPrompts.push(userPrompt); if (failingPatternProblems.some((p) => userPrompt.includes(p))) { return failResponse(500); } @@ -165,7 +185,7 @@ function makeFetchMock({ throw new Error(`Unexpected fetch: ${method} ${urlStr}`); }); - return { fetchMock, requests }; + return { fetchMock, requests, patternPrompts }; } function setupFetch(options) { @@ -194,7 +214,7 @@ function complexityFile(problemName, feedback) { }; } -describe("tagPatterns — skip 조건", () => { +describe("aiReview — skip 조건", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -202,7 +222,7 @@ describe("tagPatterns — skip 조건", () => { it("draft PR 은 skip 한다", async () => { globalThis.fetch = vi.fn(); - const result = await runTagPatterns(makePrData({ draft: true })); + const result = await runAiReview(makePrData({ draft: true })); expect(result).toEqual({ skipped: "draft" }); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -215,7 +235,7 @@ describe("tagPatterns — skip 조건", () => { ], }); - const result = await runTagPatterns(); + const result = await runAiReview(); expect(result).toEqual({ skipped: "no-solution-files" }); expect(requests).toHaveLength(0); @@ -226,14 +246,14 @@ describe("tagPatterns — skip 조건", () => { solutionFiles: [makeSolutionFile("two-sum", "testuser", "removed")], }); - const result = await runTagPatterns(); + const result = await runAiReview(); expect(result).toEqual({ skipped: "no-solution-files" }); expect(requests).toHaveLength(0); }); }); -describe("tagPatterns — check run", () => { +describe("aiReview — check run", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -244,20 +264,20 @@ describe("tagPatterns — check run", () => { complexityFiles: [complexityFile("two-sum", "정확합니다!")], }); - const result = await runTagPatterns(); + const result = await runAiReview(); expect(result).toEqual({ analyzed: 1, checkRunUrl: CHECK_RUN_URL }); const checkRun = findCheckRun(requests); expect(checkRun).toMatchObject({ - name: "AI 풀이 분석", + name: "AI 리뷰", head_sha: HEAD_SHA, status: "completed", conclusion: "neutral", }); - expect(checkRun.output.title).toBe("풀이 파일 1개 분석"); + expect(checkRun.output.title).toBe("풀이 1개 리뷰"); expect(checkRun.output.summary).toContain( - "| `two-sum/testuser.js` | Two Pointers |" + "| `two-sum/testuser.js` | - | Two Pointers | - |" ); expect(checkRun.output.text).toContain("## `two-sum/testuser.js`"); expect(checkRun.output.text).toContain("### 🏷️ 알고리즘 패턴 분석"); @@ -272,7 +292,7 @@ describe("tagPatterns — check run", () => { solutionFiles: [makeSolutionFile("two-sum")], }); - await runTagPatterns(); + await runAiReview(); const { output } = findCheckRun(requests); expect(output.text).not.toContain(PLAIN_SOURCE); @@ -287,7 +307,7 @@ describe("tagPatterns — check run", () => { rawContent: "a".repeat(contentLength), }); - await runTagPatterns(); + await runAiReview(); expect(findCheckRun(requests).output.text).not.toContain( "앞부분만 분석" @@ -301,7 +321,7 @@ describe("tagPatterns — check run", () => { rawContent: "a".repeat(MAX_FILE_CONTENT_LENGTH + 1), }); - await runTagPatterns(); + await runAiReview(); expect(findCheckRun(requests).output.text).toContain( `앞부분 ${MAX_FILE_CONTENT_LENGTH}자만 분석했습니다.` @@ -314,7 +334,7 @@ describe("tagPatterns — check run", () => { complexityFails: true, }); - const result = await runTagPatterns(); + const result = await runAiReview(); const { output } = findCheckRun(requests); expect(result.analyzed).toBe(1); @@ -328,7 +348,7 @@ describe("tagPatterns — check run", () => { complexityFiles: [], }); - await runTagPatterns(); + await runAiReview(); const { output } = findCheckRun(requests); expect(output.text).toContain("### 🏷️ 알고리즘 패턴 분석"); @@ -344,16 +364,16 @@ describe("tagPatterns — check run", () => { failingPatternProblems: ["valid-parentheses"], }); - const result = await runTagPatterns(); + const result = await runAiReview(); const { output } = findCheckRun(requests); expect(result.analyzed).toBe(1); - expect(output.title).toBe("풀이 파일 2개 분석"); + expect(output.title).toBe("풀이 2개 리뷰"); expect(output.summary).toContain( - "| `two-sum/testuser.js` | Two Pointers |" + "| `two-sum/testuser.js` | - | Two Pointers | - |" ); expect(output.summary).toContain( - "| `valid-parentheses/testuser.js` | ⚠️ 분석 실패 |" + "| `valid-parentheses/testuser.js` | - | ⚠️ 분석 실패 | - |" ); expect(output.text).toContain("> ⚠️ 패턴 분석에 실패했습니다."); }); @@ -370,7 +390,7 @@ describe("tagPatterns — check run", () => { ], }); - await runTagPatterns(); + await runAiReview(); const [twoSumSection, validSection] = findCheckRun(requests) .output.text.split("## `") @@ -388,14 +408,14 @@ describe("tagPatterns — check run", () => { checkRunFails: true, }); - await expect(runTagPatterns()).rejects.toThrow( + await expect(runAiReview()).rejects.toThrow( "Failed to create check run: 403" ); expect(requests.some((r) => r.url.includes("/issues/"))).toBe(false); }); }); -describe("tagPatterns — 대화 탭 링크 댓글", () => { +describe("aiReview — 대화 탭 링크 댓글", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -405,7 +425,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { solutionFiles: [makeSolutionFile("two-sum")], }); - await runTagPatterns(); + await runAiReview(); const post = requests.find( (r) => r.method === "POST" && r.url.includes(`/issues/${PR_NUMBER}/comments`) @@ -426,7 +446,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { ], }); - await runTagPatterns(); + await runAiReview(); const commentWrites = requests.filter((r) => r.url.includes("/issues/")); expect(commentWrites).toHaveLength(1); @@ -452,7 +472,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { ], }); - await runTagPatterns(); + await runAiReview(); const commentWrites = requests.filter((r) => r.url.includes("/issues/")); expect(commentWrites).toHaveLength(1); @@ -473,7 +493,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { ], }); - await runTagPatterns(); + await runAiReview(); expect(findCheckRun(requests).head_sha).toBe(HEAD_SHA); expect(requests.filter((r) => r.url.includes("/issues/"))).toHaveLength(0); @@ -491,7 +511,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { ], }); - await runTagPatterns(); + await runAiReview(); const commentWrites = requests.filter((r) => r.url.includes("/issues/")); expect(commentWrites).toHaveLength(1); @@ -499,7 +519,7 @@ describe("tagPatterns — 대화 탭 링크 댓글", () => { }); }); -describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레이션", () => { +describe("aiReview — 레거시 issue comment 마이그레이션", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -508,19 +528,22 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 return requests.filter((r) => r.method === "DELETE"); } - it("LEGACY_COMPLEXITY_MARKER 가 있는 Bot issue comment 를 삭제한다", async () => { + it.each([ + LEGACY_COMPLEXITY_MARKER, + LEGACY_LEARNING_STATUS_MARKER, + ])("%s 가 있는 Bot issue comment 를 삭제한다", async (marker) => { const requests = setupFetch({ solutionFiles: [makeSolutionFile("two-sum")], existingIssueComments: [ { id: 1234, user: { type: "Bot" }, - body: `${LEGACY_COMPLEXITY_MARKER}\n레거시`, + body: `${marker}\n레거시`, }, ], }); - await runTagPatterns(); + await runAiReview(); expect(deletes(requests)).toHaveLength(1); expect(deletes(requests)[0].url).toContain("/issues/comments/1234"); @@ -531,7 +554,7 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 solutionFiles: [makeSolutionFile("two-sum")], }); - await runTagPatterns(); + await runAiReview(); expect(deletes(requests)).toHaveLength(0); }); @@ -548,8 +571,104 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 ], }); - await runTagPatterns(); + await runAiReview(); expect(deletes(requests)).toHaveLength(0); }); }); + +describe("aiReview — 의도한 접근과 학습 현황", () => { + const CATEGORIES = { + "two-sum": { + difficulty: "Easy", + blindCategories: ["Array"], + intended_approach: "Hash Table", + }, + "valid-parentheses": { + difficulty: "Easy", + blindCategories: ["String"], + intended_approach: "Stack", + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + function cohortPR(paths, projectTitle = "리트코드 스터디 9기", closed = false) { + return { + projectsV2: { nodes: [{ title: projectTitle, closed }] }, + files: { nodes: paths.map((path) => ({ path })) }, + }; + } + + it("문제 정보가 있으면 의도한 접근 판단과 학습 현황을 같은 check run 에 담는다", async () => { + const { fetchMock, requests, patternPrompts } = makeFetchMock({ + solutionFiles: [makeSolutionFile("two-sum")], + categories: CATEGORIES, + cohortPRs: [cohortPR(["two-sum/testuser.js"])], + patternResponse: { + patterns: ["Hash Map / Hash Set"], + description: "해시맵으로 보수를 찾는다", + matchesIntendedApproach: true, + approachExplanation: "해시 테이블로 한 번에 찾는다", + }, + }); + globalThis.fetch = fetchMock; + + await runAiReview(); + + expect(patternPrompts[0]).toContain("# 의도된 접근법\nHash Table"); + + const { output } = findCheckRun(requests); + expect(output.title).toBe("풀이 1개 리뷰 · 의도한 접근 1/1"); + expect(output.summary).toContain( + "| `two-sum/testuser.js` | Easy | Hash Map / Hash Set | ✅ 일치 |" + ); + expect(output.summary).toContain("## 📊 testuser 님의 학습 현황"); + expect(output.summary).toContain("이번 기수에 푼 문제: 1 / 2개"); + expect(output.text).toContain( + "- **의도한 접근** (Hash Table): ✅ 일치 — 해시 테이블로 한 번에 찾는다" + ); + }); + + it("문제 정보에 없는 문제는 의도한 접근을 판단하지 않는다", async () => { + const { fetchMock, requests, patternPrompts } = makeFetchMock({ + solutionFiles: [makeSolutionFile("to-lower-case")], + categories: CATEGORIES, + }); + globalThis.fetch = fetchMock; + + await runAiReview(); + + expect(patternPrompts[0]).not.toContain("# 의도된 접근법"); + const { output } = findCheckRun(requests); + expect(output.title).toBe("풀이 1개 리뷰"); + expect(output.summary).toContain( + "| `to-lower-case/testuser.js` | - | Two Pointers | - |" + ); + }); + + it("problem-categories.json 이 없으면 학습 현황 없이 게시한다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + }); + + await runAiReview(); + + expect(findCheckRun(requests).output.summary).not.toContain("학습 현황"); + }); + + it("기수 풀이 조회가 실패해도 리뷰는 게시한다", async () => { + const requests = setupFetch({ + solutionFiles: [makeSolutionFile("two-sum")], + categories: CATEGORIES, + cohortFails: true, + }); + + const result = await runAiReview(); + + expect(result.analyzed).toBe(1); + expect(findCheckRun(requests).output.summary).not.toContain("학습 현황"); + }); +}); diff --git a/tests/learningComment.test.js b/tests/learningComment.test.js deleted file mode 100644 index 581eef3..0000000 --- a/tests/learningComment.test.js +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; - -import { upsertLearningStatusComment } from "../utils/learningComment.js"; - -const REPO_OWNER = "DaleStudy"; -const REPO_NAME = "leetcode-study"; -const PR_NUMBER = 42; -const APP_TOKEN = "fake-app-token"; -const COMMENT_MARKER = ""; - -function ok(body) { - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - json: () => Promise.resolve(body), - }); -} - -function parseBody(call) { - return JSON.parse(call.init.body).body; -} - -describe("upsertLearningStatusComment — usage history accumulation", () => { - let originalFetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - it("posts a new comment with a single usage row when no prior comment exists", async () => { - const calls = []; - globalThis.fetch = (url, init = {}) => { - calls.push({ url: String(url), init }); - if (init.method === "POST") return ok({}); - return ok([]); // no existing comments - }; - - await upsertLearningStatusComment( - REPO_OWNER, - REPO_NAME, - PR_NUMBER, - `${COMMENT_MARKER}\n## body`, - APP_TOKEN, - { prompt_tokens: 100, completion_tokens: 50 } - ); - - const post = calls.find((c) => c.init.method === "POST"); - expect(post).toBeDefined(); - - const body = parseBody(post); - expect(body).toContain("🔢 API 사용량 (gpt-5-nano)"); - expect(body).toContain("| 1 | 100 | 50 | 150 |"); - // single-row history => no totals row - expect(body).not.toContain("**합계**"); - // hidden marker stores an array - expect(body).toMatch(//); - - globalThis.fetch = originalFetch; - }); - - it("accumulates usage rows across PR updates when prior usage marker is present", async () => { - const previousBody = [ - COMMENT_MARKER, - "## existing body", - "", - ``, - ].join("\n"); - - const calls = []; - globalThis.fetch = (url, init = {}) => { - const u = String(url); - calls.push({ url: u, init }); - if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { - return ok([ - { - id: 999, - user: { type: "Bot" }, - body: previousBody, - }, - ]); - } - if (init.method === "PATCH") return ok({}); - return ok([]); - }; - - await upsertLearningStatusComment( - REPO_OWNER, - REPO_NAME, - PR_NUMBER, - `${COMMENT_MARKER}\n## new body`, - APP_TOKEN, - { prompt_tokens: 300, completion_tokens: 120 } - ); - - const patch = calls.find((c) => c.init.method === "PATCH"); - expect(patch).toBeDefined(); - expect(patch.url).toContain("/issues/comments/999"); - - const body = parseBody(patch); - // all three calls present, in order - expect(body).toContain("| 1 | 100 | 50 | 150 |"); - expect(body).toContain("| 2 | 200 | 80 | 280 |"); - expect(body).toContain("| 3 | 300 | 120 | 420 |"); - // totals row appears once history.length > 1 - expect(body).toContain("| **합계** | **600** | **250** | **850** |"); - // marker is rewritten with the full array - expect(body).toMatch( - // - ); - - globalThis.fetch = originalFetch; - }); - - it("falls back to a single-row history when the prior marker is malformed", async () => { - // legacy / corrupt marker (object instead of array) — should not crash, just reset - const previousBody = [ - COMMENT_MARKER, - "## existing body", - "", - ``, - ].join("\n"); - - const calls = []; - globalThis.fetch = (url, init = {}) => { - const u = String(url); - calls.push({ url: u, init }); - if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { - return ok([ - { id: 1, user: { type: "Bot" }, body: previousBody }, - ]); - } - if (init.method === "PATCH") return ok({}); - return ok([]); - }; - - await upsertLearningStatusComment( - REPO_OWNER, - REPO_NAME, - PR_NUMBER, - `${COMMENT_MARKER}\n## new body`, - APP_TOKEN, - { prompt_tokens: 999, completion_tokens: 111 } - ); - - const patch = calls.find((c) => c.init.method === "PATCH"); - const body = parseBody(patch); - - expect(body).toContain("| 1 | 999 | 111 | 1,110 |"); - expect(body).not.toContain("**합계**"); - - globalThis.fetch = originalFetch; - }); - - it("omits the usage section entirely when no usage is provided", async () => { - const calls = []; - globalThis.fetch = (url, init = {}) => { - calls.push({ url: String(url), init }); - if (init.method === "POST") return ok({}); - return ok([]); - }; - - await upsertLearningStatusComment( - REPO_OWNER, - REPO_NAME, - PR_NUMBER, - `${COMMENT_MARKER}\n## body`, - APP_TOKEN - // no usage - ); - - const post = calls.find((c) => c.init.method === "POST"); - const body = parseBody(post); - expect(body).not.toContain("🔢 API 사용량"); - expect(body).not.toContain("usage-data:"); - - globalThis.fetch = originalFetch; - }); -}); diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index 459c75a..104a054 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { tagPatterns } from "../handlers/tag-patterns.js"; -import { postLearningStatus } from "../handlers/learning-status.js"; +import { aiReview } from "../handlers/ai-review.js"; import { createFakeAi } from "./fakeAi.js"; const REPO_OWNER = "DaleStudy"; @@ -40,13 +39,23 @@ function okText(text) { }); } -describe("subrequest 예산 — 핸들러별 invocation", () => { +describe("subrequest 예산 — AI 리뷰 invocation", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("tagPatterns 는 변경 파일 15개에서 50회 이하 subrequest를 호출한다 (예상 37: files 1 + raw 15 + 패턴 OpenAI 15 + 복잡도 OpenAI 1 + check run 1 + issue 코멘트 목록 1 + PR head 조회 1 + 링크 댓글 POST 1 + 레거시 DELETE 1)", async () => { + it("aiReview 는 변경 파일 15개에서 50회 이하 subrequest를 호출한다 (예상 40: files 1 + raw 15 + categories 1 + 패턴 OpenAI 15 + 복잡도 OpenAI 1 + 기수 풀이 GraphQL 1 + check run 1 + issue 코멘트 목록 1 + PR head 조회 1 + 링크 댓글 POST 1 + 레거시 DELETE 2)", async () => { const solutionFiles = makeSolutionFiles(15); + const categories = Object.fromEntries( + solutionFiles.map((_, i) => [ + `problem-${i + 1}`, + { + difficulty: "Easy", + blindCategories: ["Array"], + intended_approach: "Two Pointers", + }, + ]) + ); globalThis.fetch = vi.fn().mockImplementation((url, opts) => { const urlStr = typeof url === "string" ? url : url.url; @@ -60,6 +69,25 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { return okText("function solution() { return 0; }"); } + if (urlStr.includes("/contents/problem-categories.json")) { + return okJson(categories); + } + + if (urlStr === "https://api.github.com/graphql") { + return okJson({ + data: { + search: { + nodes: [ + { + projectsV2: { nodes: [{ title: "리트코드 스터디 9기", closed: false }] }, + files: { nodes: [{ path: `problem-1/${USERNAME}.ts` }] }, + }, + ], + }, + }, + }); + } + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( @@ -97,6 +125,8 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { content: JSON.stringify({ patterns: ["Two Pointers"], description: "test", + matchesIntendedApproach: true, + approachExplanation: "일치", }), }, }, @@ -117,25 +147,30 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { return okJson({ head: { sha: HEAD_SHA } }); } - // 레거시 단독 복잡도 issue comment 마이그레이션 + // 레거시 issue comment 마이그레이션 (단독 복잡도, 학습 현황) if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { return okJson([ { - id: 9999, + id: 9998, user: { type: "Bot" }, body: "\n레거시", }, + { + id: 9999, + user: { type: "Bot" }, + body: "\n레거시", + }, ]); } if (urlStr.includes("/issues/comments/") && method === "DELETE") { return okJson({}); } - throw new Error(`Unexpected fetch in tagPatterns mock: ${method} ${urlStr}`); + throw new Error(`Unexpected fetch in aiReview mock: ${method} ${urlStr}`); }); - const prData = { draft: false, labels: [] }; - const result = await tagPatterns( + const prData = { draft: false, labels: [], user: { login: USERNAME } }; + const result = await aiReview( REPO_OWNER, REPO_NAME, PR_NUMBER, @@ -148,129 +183,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { const fetchCount = globalThis.fetch.mock.calls.length; expect(result.analyzed).toBe(15); - expect(fetchCount).toBe(37); + expect(fetchCount).toBe(40); 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( - solutionFiles.map((_, i) => [ - `problem-${i + 1}`, - { - difficulty: "Easy", - categories: ["Array"], - blindCategories: ["Array"], - intended_approach: "Two Pointers", - }, - ]) - ); - - // 한 프로젝트(기수)당 최대 15개 PR 가정 — 유저가 cohort 에서 머지한 PR 15개 - const COHORT_PR_COUNT = 15; - const COHORT_PR_NUMBERS = Array.from( - { length: COHORT_PR_COUNT }, - (_, i) => PR_NUMBER - 1 - i - ); - const COHORT_PROJECT_ID = "PVT_kwDO_cohort"; - - globalThis.fetch = vi.fn().mockImplementation((url, opts) => { - const urlStr = typeof url === "string" ? url : url.url; - const method = opts?.method ?? "GET"; - - if (urlStr.includes("/contents/problem-categories.json")) { - return okJson(categories); - } - - if (urlStr === "https://api.github.com/graphql" && method === "POST") { - const body = JSON.parse(opts.body); - if (body.query.includes("projectsV2")) { - return okJson({ - data: { - repository: { - projectsV2: { - nodes: [ - { id: COHORT_PROJECT_ID, title: "리트코드 스터디 9기", closed: false }, - ], - }, - }, - }, - }); - } - if (body.query.includes("ProjectV2")) { - return okJson({ - data: { - node: { - items: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: COHORT_PR_NUMBERS.map((n) => ({ - content: { - number: n, - state: "MERGED", - author: { login: USERNAME }, - }, - })), - }, - }, - }, - }); - } - throw new Error(`Unexpected GraphQL query: ${body.query}`); - } - - if (COHORT_PR_NUMBERS.some((n) => urlStr.includes(`/pulls/${n}/files`))) { - return okJson(solutionFiles); - } - - if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { - return okJson(solutionFiles); - } - - if (urlStr.startsWith("https://raw.example.com/")) { - return okText("function solution() { return 0; }"); - } - - if (urlStr.includes("/openai/chat/completions")) { - return okJson({ - choices: [ - { - message: { - content: JSON.stringify({ - matches: true, - explanation: "의도된 접근법과 일치", - }), - }, - }, - ], - usage: { prompt_tokens: 100, completion_tokens: 50 }, - }); - } - - if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "GET") { - return okJson([]); - } - - if (urlStr.includes(`/issues/${PR_NUMBER}/comments`) && method === "POST") { - return okJson({ id: 500 }); - } - - throw new Error(`Unexpected fetch in postLearningStatus mock: ${method} ${urlStr}`); - }); - - const result = await postLearningStatus( - REPO_OWNER, - REPO_NAME, - PR_NUMBER, - USERNAME, - APP_TOKEN, - AI - ); - - const fetchCount = globalThis.fetch.mock.calls.length; - - expect(result.analyzed).toBe(5); - expect(fetchCount).toBe(31); - expect(fetchCount).toBeLessThan(50); - }); - }); diff --git a/utils/learningComment.js b/utils/learningComment.js deleted file mode 100644 index 8cf409b..0000000 --- a/utils/learningComment.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Learning status comment formatting and upsert utilities - */ - -import { getGitHubHeaders } from "./github.js"; - -/** - * Marker that identifies a learning status comment posted by this app. - */ -const COMMENT_MARKER = ""; - -/** - * Hidden marker for embedding per-request usage history in the comment. - * Format: - * - * The capture group must match the array — earlier versions of this regex - * matched only `{...}` and silently captured the first object inside the - * array, which made `parseUsageFromComment` always return `[]` and broke - * cumulative aggregation across PR updates. - */ -const USAGE_DATA_RE = //; - -/** gpt-5-nano pricing (USD per token) */ -const INPUT_COST_PER_TOKEN = 0.05 / 1_000_000; -const OUTPUT_COST_PER_TOKEN = 0.40 / 1_000_000; - -/** - * Calculates cost in USD from token counts. - * - * @param {number} promptTokens - * @param {number} completionTokens - * @returns {number} - */ -function calcCost(promptTokens, completionTokens) { - return promptTokens * INPUT_COST_PER_TOKEN + completionTokens * OUTPUT_COST_PER_TOKEN; -} - -/** - * Formats a number with thousand-separating commas. - * - * @param {number} n - * @returns {string} - */ -function fmt(n) { - return n.toLocaleString("en-US"); -} - -/** - * Parses per-request usage history embedded in an existing comment body. - * - * @param {string|undefined} body - * @returns {Array<{ prompt: number, completion: number }>} - */ -function parseUsageFromComment(body) { - if (!body) return []; - const match = body.match(USAGE_DATA_RE); - if (!match) return []; - try { - const parsed = JSON.parse(match[1]); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -/** - * Builds the usage footer section (per-request history table + hidden data marker). - * - * @param {Array<{ prompt: number, completion: number }>} history - all requests including current (latest last) - * @returns {string} - */ -function formatUsageSection(history) { - const lines = []; - lines.push("
"); - lines.push("🔢 API 사용량 (gpt-5-nano)"); - lines.push(""); - lines.push("| 요청 | 입력 토큰 | 출력 토큰 | 합계 | 비용 |"); - lines.push("|---:|---:|---:|---:|---:|"); - - let totalPrompt = 0; - let totalCompletion = 0; - - for (let i = 0; i < history.length; i++) { - const { prompt, completion } = history[i]; - const total = prompt + completion; - const cost = calcCost(prompt, completion); - lines.push(`| ${i + 1} | ${fmt(prompt)} | ${fmt(completion)} | ${fmt(total)} | $${cost.toFixed(6)} |`); - totalPrompt += prompt; - totalCompletion += completion; - } - - if (history.length > 1) { - const totalCost = calcCost(totalPrompt, totalCompletion); - lines.push(`| **합계** | **${fmt(totalPrompt)}** | **${fmt(totalCompletion)}** | **${fmt(totalPrompt + totalCompletion)}** | **$${totalCost.toFixed(6)}** |`); - } - - lines.push(""); - lines.push("
"); - - // Embed history for future parsing - lines.push(``); - - return lines.join("\n"); -} - -/** - * Returns true when `comment` is a Bot comment containing our marker. - * - * @param {{ user?: { type?: string }, body?: string }} comment - * @returns {boolean} - */ -function isLearningStatusComment(comment) { - return comment.user?.type === "Bot" && comment.body?.includes(COMMENT_MARKER); -} - -/** - * Renders a simple block-character progress bar. - * - * @param {number} completed - * @param {number} total - * @param {number} [barLength=7] - * @returns {string} e.g. "■■■□□□□" - */ -function progressBar(completed, total, barLength = 7) { - if (total === 0) { - return "□".repeat(barLength); - } - const filled = Math.min(Math.round((completed / total) * barLength), barLength); - return "■".repeat(filled) + "□".repeat(barLength - filled); -} - -/** - * Formats a learning status Markdown comment. - * - * @param {string} username - * @param {Array<{ problemName: string, difficulty: string|null, matches: boolean|null, explanation: string }>} submissions - * @param {number} totalSolved - * @param {number} totalProblems - * @param {Array<{ category: string, solved: number, total: number, difficulties: string }>} categoryProgress - * @returns {string} - */ -export function formatLearningStatusComment( - username, - submissions, - totalSolved, - totalProblems, - categoryProgress -) { - const lines = []; - - lines.push(COMMENT_MARKER); - lines.push(`## 📊 ${username} 님의 학습 현황`); - lines.push(""); - - // --- Submission table --- - lines.push("### 이번 주 제출 문제"); - lines.push("| 문제 | 난이도 | 유형 분석 |"); - lines.push("|---|---|---|"); - - for (const { problemName, difficulty, matches } of submissions) { - const difficultyCell = difficulty ?? "-"; - - let analysisCell; - if (matches === null) { - analysisCell = "ℹ️ 카테고리 정보 없음"; - } else if (matches === true) { - analysisCell = "✅ 의도한 유형"; - } else { - analysisCell = "⚠️ 유형 불일치"; - } - - lines.push(`| ${problemName} | ${difficultyCell} | ${analysisCell} |`); - } - - lines.push(""); - - // --- Cumulative summary --- - lines.push("### 누적 학습 요약"); - lines.push(`- 풀이한 문제: ${totalSolved} / ${totalProblems}개`); - - // Match rate line — only include when at least one submission has matches !== null - const gradedSubmissions = submissions.filter((s) => s.matches !== null); - if (gradedSubmissions.length > 0) { - const matched = gradedSubmissions.filter((s) => s.matches === true).length; - const total = gradedSubmissions.length; - const rate = Math.round((matched / total) * 100); - lines.push( - `- 이번 주 유형 일치율: ${rate}% (${total}문제 중 ${matched}문제 일치)` - ); - } - - lines.push(""); - - // --- Category progress --- - if (categoryProgress.length > 0) { - lines.push("### 문제 풀이 현황"); - lines.push("| 카테고리 | 진행도 | 완료 |"); - lines.push("|---|---|---|"); - - for (const { category, solved, total, difficulties } of categoryProgress) { - const bar = progressBar(solved, total); - let completionCell = difficulties - ? `${solved} / ${total} (${difficulties})` - : `${solved} / ${total}`; - if (solved === 0) { - completionCell += " ← 아직 시작 안 함"; - } - lines.push(`| ${category} | ${bar} | ${completionCell} |`); - } - - lines.push(""); - } - - lines.push("---"); - lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다."); - - return lines.join("\n") + "\n"; -} - -/** - * Creates or updates the learning status comment on a PR. - * - * Searches through the first 100 issue comments for an existing Bot comment - * that contains COMMENT_MARKER. If found, PATCHes it; otherwise POSTs a new one. - * - * When `currentUsage` is provided, appends a collapsible usage/cost section to - * the comment and accumulates it with any previously stored cumulative totals. - * - * @param {string} repoOwner - * @param {string} repoName - * @param {number} prNumber - * @param {string} commentBody - * @param {string} appToken - * @param {{ prompt_tokens: number, completion_tokens: number }|null} [currentUsage] - * @returns {Promise} - */ -export async function upsertLearningStatusComment( - repoOwner, - repoName, - prNumber, - commentBody, - appToken, - currentUsage = null -) { - const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`; - - // Fetch existing comments - const listResponse = await fetch( - `${baseUrl}/issues/${prNumber}/comments?per_page=100`, - { headers: getGitHubHeaders(appToken) } - ); - - if (!listResponse.ok) { - throw new Error( - `[learningStatus] Failed to list comments on PR #${prNumber}: ${listResponse.status} ${listResponse.statusText}` - ); - } - - const comments = await listResponse.json(); - const existing = comments.find(isLearningStatusComment); - - // Append usage section when token data is available - if (currentUsage) { - const prevHistory = parseUsageFromComment(existing?.body); - const history = [ - ...prevHistory, - { prompt: currentUsage.prompt_tokens, completion: currentUsage.completion_tokens }, - ]; - commentBody = commentBody.trimEnd() + "\n\n" + formatUsageSection(history) + "\n"; - } - - if (existing) { - // Update existing comment - console.log( - `[learningStatus] Updating existing comment ${existing.id} on PR #${prNumber}` - ); - - const patchResponse = await fetch( - `${baseUrl}/issues/comments/${existing.id}`, - { - method: "PATCH", - headers: { - ...getGitHubHeaders(appToken), - "Content-Type": "application/json", - }, - body: JSON.stringify({ body: commentBody }), - } - ); - - if (!patchResponse.ok) { - const errorData = await patchResponse.json(); - throw new Error( - `[learningStatus] Failed to update comment ${existing.id} on PR #${prNumber}: ${JSON.stringify(errorData)}` - ); - } - - console.log( - `[learningStatus] Updated comment ${existing.id} on PR #${prNumber}` - ); - } else { - // Post new comment - console.log( - `[learningStatus] Posting new learning status comment on PR #${prNumber}` - ); - - const postResponse = await fetch( - `${baseUrl}/issues/${prNumber}/comments`, - { - method: "POST", - headers: { - ...getGitHubHeaders(appToken), - "Content-Type": "application/json", - }, - body: JSON.stringify({ body: commentBody }), - } - ); - - if (!postResponse.ok) { - const errorData = await postResponse.json(); - throw new Error( - `[learningStatus] Failed to post comment on PR #${prNumber}: ${JSON.stringify(errorData)}` - ); - } - - console.log( - `[learningStatus] Created new comment on PR #${prNumber}` - ); - } -} diff --git a/utils/learningData.js b/utils/learningData.js index 2293e96..0c39629 100644 --- a/utils/learningData.js +++ b/utils/learningData.js @@ -1,201 +1,13 @@ /** - * Learning data fetching utilities for DaleStudy GitHub App + * 학습 현황에 쓰는 데이터 조회 */ import { getGitHubHeaders } from "./github.js"; -const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; const COHORT_PROJECT_PATTERN = /리트코드 스터디\s*\d+기/; /** - * GitHub GraphQL API 호출 헬퍼 - * - * @param {string} query - * @param {string} appToken - * @returns {Promise} - */ -async function graphql(query, appToken) { - const response = await fetch(GITHUB_GRAPHQL_URL, { - method: "POST", - headers: { - ...getGitHubHeaders(appToken), - "Content-Type": "application/json", - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`); - } - - const result = await response.json(); - if (result.errors) { - throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`); - } - - return result.data; -} - -/** - * 현재 진행 중인 기수 프로젝트 ID를 조회한다. - * "리트코드 스터디X기" 패턴의 열린 프로젝트를 찾는다. - * - * @param {string} repoOwner - * @param {string} repoName - * @param {string} appToken - * @returns {Promise} 프로젝트 node ID, 없으면 null - */ -async function fetchActiveCohortProjectId(repoOwner, repoName, appToken) { - const data = await graphql( - `{ - repository(owner: "${repoOwner}", name: "${repoName}") { - projectsV2(first: 20) { - nodes { - id - title - closed - } - } - } - }`, - appToken - ); - - const projects = data.repository.projectsV2.nodes; - const active = projects.find( - (p) => !p.closed && COHORT_PROJECT_PATTERN.test(p.title) - ); - - if (!active) { - console.warn( - `[fetchActiveCohortProjectId] No open cohort project found for ${repoOwner}/${repoName}` - ); - return null; - } - - console.log( - `[fetchActiveCohortProjectId] Active cohort project: "${active.title}" (${active.id})` - ); - return active.id; -} - -/** - * 기수 프로젝트에서 해당 유저가 머지한 PR 번호 목록을 반환한다. - * 프로젝트 아이템을 페이지네이션하며 author.login으로 필터링한다. - * - * @param {string} projectId - * @param {string} username - * @param {string} appToken - * @returns {Promise} - */ -async function fetchUserMergedPRsInProject(projectId, username, appToken) { - const prNumbers = []; - let cursor = null; - - while (true) { - const afterClause = cursor ? `, after: "${cursor}"` : ""; - const data = await graphql( - `{ - node(id: "${projectId}") { - ... on ProjectV2 { - items(first: 100${afterClause}) { - pageInfo { hasNextPage endCursor } - nodes { - content { - ... on PullRequest { - number - state - author { login } - } - } - } - } - } - } - }`, - appToken - ); - - const { nodes, pageInfo } = data.node.items; - - for (const item of nodes) { - const pr = item.content; - if ( - pr?.state === "MERGED" && - pr?.author?.login?.toLowerCase() === username.toLowerCase() - ) { - prNumbers.push(pr.number); - } - } - - if (!pageInfo.hasNextPage) break; - cursor = pageInfo.endCursor; - } - - return prNumbers; -} - -/** - * 현재 기수 프로젝트에서 해당 유저가 제출한 문제 목록을 반환한다. - * - * 기수 프로젝트를 찾지 못하면 전체 레포 트리 스캔(fetchUserSolutions)으로 폴백한다. - * - * @param {string} repoOwner - * @param {string} repoName - * @param {string} username - * @param {string} appToken - * @returns {Promise} - */ -export async function fetchCohortUserSolutions( - repoOwner, - repoName, - username, - appToken -) { - const projectId = await fetchActiveCohortProjectId( - repoOwner, - repoName, - appToken - ); - - if (!projectId) { - console.warn( - `[fetchCohortUserSolutions] Falling back to full tree scan for ${username}` - ); - return fetchUserSolutions(repoOwner, repoName, username, appToken); - } - - const prNumbers = await fetchUserMergedPRsInProject( - projectId, - username, - appToken - ); - - console.log( - `[fetchCohortUserSolutions] ${username} has ${prNumbers.length} merged PRs in current cohort` - ); - - const problemNames = new Set(); - for (const prNumber of prNumbers) { - const submissions = await fetchPRSubmissions( - repoOwner, - repoName, - prNumber, - username, - appToken - ); - for (const { problemName } of submissions) { - problemNames.add(problemName); - } - } - - return Array.from(problemNames); -} - -/** - * Fetches problem-categories.json from the repo root via GitHub API. - * Returns parsed JSON object, or null if the file is not found (404). - * Throws on other errors. + * 저장소 루트의 problem-categories.json 을 읽는다. 파일이 없으면(404) null. * * @param {string} repoOwner * @param {string} repoName @@ -226,10 +38,10 @@ export async function fetchProblemCategories(repoOwner, repoName, appToken) { } /** - * Fetches the full repo file tree and returns a deduplicated array of problem - * names that have a solution file submitted by the given username. + * 진행 중인 기수 프로젝트에 속한, 사용자가 머지한 PR 에서 푼 문제 이름 목록. * - * Matches files of the form: {problem-name}/{username}.{ext} + * 머지된 PR 을 최근 순으로 100 개까지 한 번의 GraphQL 검색으로 가져와 + * 열린 "리트코드 스터디 N기" 프로젝트에 속한 PR 만 센다. 한 기수는 100 PR 보다 훨씬 적다. * * @param {string} repoOwner * @param {string} repoName @@ -237,124 +49,66 @@ export async function fetchProblemCategories(repoOwner, repoName, appToken) { * @param {string} appToken * @returns {Promise} */ -export async function fetchUserSolutions( +export async function fetchCohortSolvedProblems( repoOwner, repoName, username, appToken ) { - const url = `https://api.github.com/repos/${repoOwner}/${repoName}/git/trees/main?recursive=1`; - - const response = await fetch(url, { - headers: getGitHubHeaders(appToken), + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + ...getGitHubHeaders(appToken), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: `query($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + projectsV2(first: 10) { nodes { title closed } } + files(first: 100) { nodes { path } } + } + } + } + }`, + variables: { + q: `repo:${repoOwner}/${repoName} is:pr is:merged author:${username} sort:created-desc`, + }, + }), }); if (!response.ok) { throw new Error( - `Failed to fetch repo tree: ${response.status} ${response.statusText}` + `GraphQL request failed: ${response.status} ${response.statusText}` ); } - const data = await response.json(); - - if (data.truncated) { - console.warn( - `[fetchUserSolutions] Tree response truncated for ${repoOwner}/${repoName}. Results may be incomplete.` - ); + const result = await response.json(); + if (result.errors) { + throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`); } - // Pattern: {problem-name}/{username}.{ext} - // The path must have exactly two segments and the filename must be username.ext - const usernamePattern = new RegExp( + const solutionPattern = new RegExp( `^([^/]+)/${escapeRegExp(username)}\\.[^/]+$` ); - const problemNames = new Set(); - for (const item of data.tree) { - if (item.type !== "blob") continue; - - const match = item.path.match(usernamePattern); - if (match) { - problemNames.add(match[1]); - } - } - - return Array.from(problemNames); -} - -/** - * Fetches the files changed in a PR and returns those that match - * {problem-name}/{username}.{ext} and are added, modified, or renamed. - * - * @param {string} repoOwner - * @param {string} repoName - * @param {number} prNumber - * @param {string} username - * @param {string} appToken - * @returns {Promise>} - */ -export async function fetchPRSubmissions( - repoOwner, - repoName, - prNumber, - username, - appToken -) { - const url = `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`; - - const response = await fetch(url, { - headers: getGitHubHeaders(appToken), - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch PR files: ${response.status} ${response.statusText}` - ); - } - - const files = await response.json(); - - if (files.length === 100) { - console.warn( - `[fetchPRSubmissions] PR #${prNumber} has 100+ files. Some submissions may be missed.` + for (const pr of result.data.search.nodes) { + const inActiveCohort = pr.projectsV2?.nodes.some( + (p) => !p.closed && COHORT_PROJECT_PATTERN.test(p.title) ); - } - - // Pattern: {problem-name}/{username}.{ext} - const usernamePattern = new RegExp( - `^([^/]+)/${escapeRegExp(username)}\\.[^/]+$` - ); - - const results = []; + if (!inActiveCohort) continue; - for (const file of files) { - if ( - file.status !== "added" && - file.status !== "modified" && - file.status !== "renamed" - ) - continue; - - const match = file.filename.match(usernamePattern); - if (match) { - results.push({ - problemName: match[1], - filename: file.filename, - rawUrl: file.raw_url, - }); + for (const { path } of pr.files?.nodes ?? []) { + const match = path.match(solutionPattern); + if (match) problemNames.add(match[1]); } } - return results; + return [...problemNames]; } -/** - * Escapes special regex characters in a string. - * - * @param {string} str - * @returns {string} - */ function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/utils/learningData.test.js b/utils/learningData.test.js new file mode 100644 index 0000000..55680c4 --- /dev/null +++ b/utils/learningData.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { fetchCohortSolvedProblems } from "./learningData.js"; + +function pr(paths, projects) { + return { + projectsV2: { nodes: projects }, + files: { nodes: paths.map((path) => ({ path })) }, + }; +} + +describe("fetchCohortSolvedProblems", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("열린 기수 프로젝트에 속한 PR 에서 작성자의 풀이 파일만 모은다", async () => { + const open = { title: "리트코드 스터디 9기", closed: false }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: { + search: { + nodes: [ + pr(["two-sum/learner.py", "README.md", "two-sum/other.py"], [open]), + pr(["valid-parentheses/learner.py"], [open]), + pr(["climbing-stairs/learner.py"], [{ title: "리트코드 스터디 8기", closed: true }]), + pr(["house-robber/learner.py"], []), + ], + }, + }, + }), + }); + + const solved = await fetchCohortSolvedProblems("DaleStudy", "leetcode-study", "learner", "token"); + + expect(solved).toEqual(["two-sum", "valid-parentheses"]); + const { variables } = JSON.parse(globalThis.fetch.mock.calls[0][1].body); + expect(variables.q).toBe( + "repo:DaleStudy/leetcode-study is:pr is:merged author:learner sort:created-desc" + ); + }); + + it("GraphQL 에러가 오면 throw 한다", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errors: [{ message: "bad" }] }), + }); + + await expect( + fetchCohortSolvedProblems("DaleStudy", "leetcode-study", "learner", "token") + ).rejects.toThrow("GraphQL error"); + }); +}); diff --git a/utils/learningStatus.js b/utils/learningStatus.js new file mode 100644 index 0000000..1daf9bd --- /dev/null +++ b/utils/learningStatus.js @@ -0,0 +1,118 @@ +/** + * AI 리뷰 check run 의 학습 현황 섹션 + */ + +// Blind Top 75 큐레이션 순서 — 동률 정렬의 타이브레이커로 사용. +// 출처: https://www.teamblind.com/post/new-year-gift-curated-list-of-top-75-leetcode-questions-to-save-your-time-oam1oreu +const BLIND_CATEGORY_ORDER = [ + "Array", + "Binary", + "Dynamic Programming", + "Graph", + "Interval", + "Linked List", + "Matrix", + "String", + "Tree", + "Heap", +]; + +/** + * Blind Top 75 카테고리별 누적 풀이 진행도. + * problem-categories.json 의 blindCategories 를 기준으로 Blind 10개 카테고리만 집계한다. + * + * @param {object} categories - problem-categories.json 전체 오브젝트 + * @param {string[]} solvedProblems - 사용자가 푼 문제 이름 배열 + * @returns {Array<{ category: string, solved: number, total: number, difficulties: string }>} + */ +export function buildCategoryProgress(categories, solvedProblems) { + const solvedSet = new Set(solvedProblems); + const categoryMap = new Map( + BLIND_CATEGORY_ORDER.map((cat) => [ + cat, + { total: 0, solved: 0, solvedDifficulties: [] }, + ]) + ); + + for (const [problemName, info] of Object.entries(categories)) { + for (const cat of info.blindCategories) { + const entry = categoryMap.get(cat); + if (!entry) continue; + entry.total++; + if (solvedSet.has(problemName)) { + entry.solved++; + entry.solvedDifficulties.push(info.difficulty); + } + } + } + + return [...categoryMap.entries()] + .sort((a, b) => { + const ratioA = a[1].total > 0 ? a[1].solved / a[1].total : 0; + const ratioB = b[1].total > 0 ? b[1].solved / b[1].total : 0; + if (ratioB !== ratioA) return ratioB - ratioA; + return ( + BLIND_CATEGORY_ORDER.indexOf(a[0]) - BLIND_CATEGORY_ORDER.indexOf(b[0]) + ); + }) + .map(([cat, data]) => { + const diffCounts = {}; + for (const d of data.solvedDifficulties) { + diffCounts[d] = (diffCounts[d] || 0) + 1; + } + const difficulties = Object.entries(diffCounts) + .map(([d, c]) => `${d} ${c}`) + .join(", "); + return { category: cat, solved: data.solved, total: data.total, difficulties }; + }); +} + +/** + * @param {number} completed + * @param {number} total + * @param {number} [barLength=7] + * @returns {string} e.g. "■■■□□□□" + */ +function progressBar(completed, total, barLength = 7) { + if (total === 0) { + return "□".repeat(barLength); + } + const filled = Math.min(Math.round((completed / total) * barLength), barLength); + return "■".repeat(filled) + "□".repeat(barLength - filled); +} + +/** + * 학습 현황 마크다운 섹션. + * + * @param {string} username + * @param {object} categories - problem-categories.json 전체 오브젝트 + * @param {string[]} cohortSolutions - 이번 기수에 머지된 PR 에서 푼 문제 이름 배열 + * @returns {string} + */ +export function renderLearningStatus(username, categories, cohortSolutions) { + const solvedProblems = cohortSolutions.filter((name) => + Object.hasOwn(categories, name) + ); + const totalProblems = Object.keys(categories).length; + + const lines = [ + `## 📊 ${username} 님의 학습 현황`, + "", + `이번 기수에 푼 문제: ${solvedProblems.length} / ${totalProblems}개 (머지된 PR 기준)`, + "", + "| 카테고리 | 진행도 | 완료 |", + "|---|---|---|", + ]; + + for (const { category, solved, total, difficulties } of buildCategoryProgress( + categories, + solvedProblems + )) { + const completion = difficulties + ? `${solved} / ${total} (${difficulties})` + : `${solved} / ${total}`; + lines.push(`| ${category} | ${progressBar(solved, total)} | ${completion} |`); + } + + return lines.join("\n"); +} diff --git a/utils/learningStatus.test.js b/utils/learningStatus.test.js new file mode 100644 index 0000000..fcc07a0 --- /dev/null +++ b/utils/learningStatus.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; + +import { renderLearningStatus } from "./learningStatus.js"; + +const CATEGORIES = { + "two-sum": { difficulty: "Easy", blindCategories: ["Array"] }, + "valid-parentheses": { difficulty: "Easy", blindCategories: ["String"] }, +}; + +describe("renderLearningStatus", () => { + it.each([ + { name: "등록된 문제의 풀이 수를 센다", solved: ["two-sum"], expected: 1 }, + { + name: "미등록 문제가 섞여 있으면 등록된 문제만 센다", + solved: ["to-lower-case", "two-sum"], + expected: 1, + }, + { name: "미등록 문제만 풀었으면 0이다", solved: ["to-lower-case"], expected: 0 }, + ])("$name", ({ solved, expected }) => { + expect(renderLearningStatus("learner", CATEGORIES, solved)).toContain( + `이번 기수에 푼 문제: ${expected} / 2개` + ); + }); + + it("진행도가 높은 카테고리를 먼저 보여준다", () => { + const markdown = renderLearningStatus("learner", CATEGORIES, ["valid-parentheses"]); + + expect(markdown.indexOf("| String |")).toBeLessThan(markdown.indexOf("| Array |")); + expect(markdown).toContain("| String | ■■■■■■■ | 1 / 1 (Easy 1) |"); + expect(markdown).toContain("| Array | □□□□□□□ | 0 / 1 |"); + }); +}); diff --git a/utils/openai.js b/utils/openai.js index f1c32bc..cc5ce64 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -112,12 +112,15 @@ ${prDiff} /** * 솔루션 파일의 알고리즘 패턴 분석 * + * problemInfo 가 있으면 같은 호출에서 의도된 접근법과 일치하는지도 판단한다. + * * @param {string} fileContent - 분석할 소스 코드 내용 * @param {string} problemName - 문제 이름 (폴더명) + * @param {{difficulty: string, intended_approach: string}|null} problemInfo - problem-categories.json 의 문제 정보 * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) - * @returns {Promise<{patterns: string[], description: string}>} + * @returns {Promise<{patterns: string[], description: string, approach: {matches: boolean, explanation: string}|null}>} */ -export async function generatePatternAnalysis(fileContent, problemName, ai) { +export async function generatePatternAnalysis(fileContent, problemName, problemInfo, ai) { const systemPrompt = `당신은 리트코드 문제 풀이의 알고리즘 패턴을 분석하는 전문가입니다. 주어진 소스 코드를 분석해서, 다음 패턴 목록 중 해당되는 것만 골라주세요. @@ -142,18 +145,28 @@ export async function generatePatternAnalysis(fileContent, problemName, ai) { 반드시 JSON 객체로만 응답하세요. 형식: { "patterns": ["패턴1", "패턴2"], - "description": "이 코드가 왜 해당 패턴에 속하는지 간단한 한국어 설명 (2-3문장)" + "description": "이 코드가 왜 해당 패턴에 속하는지 간단한 한국어 설명 (2-3문장)", + "matchesIntendedApproach": true 또는 false, + "approachExplanation": "의도된 접근법과 비교한 한국어 1문장" } 규칙: - patterns는 위 목록의 정확한 이름만 사용 - 해당 패턴이 없으면 빈 배열 - 일반적으로 1-3개 패턴이면 충분 -- description은 150자 이내`; +- description은 150자 이내 +- 의도된 접근법이 주어지지 않으면 matchesIntendedApproach, approachExplanation 은 생략 +- 핵심 자료구조나 알고리즘이 의도된 접근법과 같으면 matchesIntendedApproach=true (완전히 같을 필요는 없음) +- 최적화된 접근법이 의도됐는데 브루트 포스로 풀었으면 matchesIntendedApproach=false +- approachExplanation은 80자 이내`; + + const intendedApproach = problemInfo + ? `\n# 의도된 접근법\n${problemInfo.intended_approach}\n` + : ""; const userPrompt = `# 문제 이름 ${problemName} - +${intendedApproach} # 소스 코드 \`\`\` ${fileContent} @@ -182,75 +195,15 @@ ${fileContent} return { patterns: Array.isArray(parsed.patterns) ? parsed.patterns : [], description: typeof parsed.description === "string" ? parsed.description : "", + approach: problemInfo + ? { + matches: parsed.matchesIntendedApproach === true, + explanation: + typeof parsed.approachExplanation === "string" + ? parsed.approachExplanation + : "", + } + : null, }; } -/** - * 솔루션 코드가 의도된 알고리즘 접근법과 일치하는지 분석 - * - * @param {string} fileContent - 분석할 소스 코드 내용 - * @param {string} problemName - 문제 이름 (폴더명) - * @param {{difficulty: string, categories: string[], intended_approach: string}} problemInfo - 문제 메타 정보 - * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) - * @returns {Promise<{matches: boolean, explanation: string}>} - */ -export async function generateApproachAnalysis(fileContent, problemName, problemInfo, ai) { - const systemPrompt = `You are an algorithm analysis expert. Determine if code matches the intended approach. - -Respond with a JSON object in this exact format: -{ - "matches": true or false, - "explanation": "한국어로 1문장, 80자 이내" -} - -Rules: -- matches=true if the core data structure or algorithm matches the intended approach (does not need to be identical) -- matches=false if brute force was used when an optimized approach was intended -- Keep explanation to 1 sentence in Korean, 80 characters or fewer`; - - const truncatedContent = fileContent.slice(0, 15000); - - const userPrompt = `# 문제 이름 -${problemName} - -# 문제 정보 -- 난이도: ${problemInfo.difficulty} -- 카테고리: ${(problemInfo.categories || []).join(", ")} -- 의도된 접근법: ${problemInfo.intended_approach} - -# 소스 코드 -\`\`\` -${truncatedContent} -\`\`\` - -위 코드가 의도된 접근법과 일치하는지 분석해주세요.`; - - const data = await createChatCompletion(ai, { - model: "gpt-5-nano", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - max_completion_tokens: 1500, - reasoning_effort: "minimal", - }); - const content = data.choices[0]?.message?.content; - - if (!content) { - throw new Error("Empty response from OpenAI"); - } - - let parsed; - try { - parsed = JSON.parse(content); - } catch { - throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`); - } - - return { - matches: parsed.matches === true, - explanation: typeof parsed.explanation === "string" ? parsed.explanation : "", - usage: data.usage ?? null, - }; -}