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).