From 5c7063e3ec6f506a55f9a785a6e37eec7ce976cb Mon Sep 17 00:00:00 2001 From: soobing Date: Mon, 20 Apr 2026 21:20:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B8=B0=EC=88=98=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=ED=98=84=ED=99=A9=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재참여자의 이전 기수 풀이가 현재 기수 누적 학습 현황에 포함되던 문제를 수정한다. 레포 전체 트리 스캔 대신, 열린 "리트코드 스터디X기" GitHub 프로젝트에 연결된 머지된 PR만을 기준으로 집계하도록 변경한다. subrequest 회귀 테스트는 새 호출 패턴(GraphQL project lookup + cohort PR files)에 맞춰 mock 과 예상 카운트를 31 로 갱신했다 (cohort 당 최대 15개 PR 가정). Closes #10 Co-Authored-By: sounmind <37020415+sounmind@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 --- handlers/learning-status.js | 8 +- tests/subrequest-budget.test.js | 55 ++++++++-- utils/learningData.js | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 11 deletions(-) diff --git a/handlers/learning-status.js b/handlers/learning-status.js index b3676ae..4cb9bf6 100644 --- a/handlers/learning-status.js +++ b/handlers/learning-status.js @@ -7,7 +7,7 @@ import { fetchProblemCategories, - fetchUserSolutions, + fetchCohortUserSolutions, fetchPRSubmissions, } from "../utils/learningData.js"; import { generateApproachAnalysis } from "../utils/openai.js"; @@ -88,10 +88,10 @@ export async function postLearningStatus( return { skipped: "no-categories-file" }; } - // 2. 사용자의 누적 풀이 목록 조회 - const solvedProblems = await fetchUserSolutions(repoOwner, repoName, username, appToken); + // 2. 이번 기수에서 사용자가 제출한 풀이 목록 조회 + const solvedProblems = await fetchCohortUserSolutions(repoOwner, repoName, username, appToken); console.log( - `[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} cumulative solutions` + `[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} solutions in current cohort` ); // 3. 이번 PR 제출 파일 목록 조회 diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index 8013dca..e944adc 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -111,7 +111,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( expect(fetchCount).toBeLessThan(50); }); - it("postLearningStatus 는 50 회 이하 subrequest 를 호출한다 (예상 15: categories 1 + tree 1 + PR files 1 + 5×(raw+openai) + 이슈 코멘트 목록 1 + POST 1)", async () => { + 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 categories = Object.fromEntries( SOLUTION_FILES.map((_, i) => [ `problem-${i + 1}`, @@ -123,6 +123,14 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( ]) ); + // 한 프로젝트(기수)당 최대 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"; @@ -131,11 +139,44 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( return okJson(categories); } - if (urlStr.includes("/git/trees/main")) { - return okJson({ - truncated: false, - tree: SOLUTION_FILES.map((f) => ({ type: "blob", path: f.filename })), - }); + 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(SOLUTION_FILES); } if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { @@ -185,7 +226,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", ( const fetchCount = globalThis.fetch.mock.calls.length; expect(result.analyzed).toBe(5); - expect(fetchCount).toBe(15); + expect(fetchCount).toBe(31); expect(fetchCount).toBeLessThan(50); }); diff --git a/utils/learningData.js b/utils/learningData.js index f747ceb..6693046 100644 --- a/utils/learningData.js +++ b/utils/learningData.js @@ -4,6 +4,194 @@ 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).