Skip to content

Commit 5c7063e

Browse files
soobingsounmindclaude
committed
fix: 기수 프로젝트 기반으로 학습 현황 집계 범위 수정
재참여자의 이전 기수 풀이가 현재 기수 누적 학습 현황에 포함되던 문제를 수정한다. 레포 전체 트리 스캔 대신, 열린 "리트코드 스터디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 <noreply@anthropic.com>
1 parent 43488ff commit 5c7063e

3 files changed

Lines changed: 240 additions & 11 deletions

File tree

handlers/learning-status.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import {
99
fetchProblemCategories,
10-
fetchUserSolutions,
10+
fetchCohortUserSolutions,
1111
fetchPRSubmissions,
1212
} from "../utils/learningData.js";
1313
import { generateApproachAnalysis } from "../utils/openai.js";
@@ -88,10 +88,10 @@ export async function postLearningStatus(
8888
return { skipped: "no-categories-file" };
8989
}
9090

91-
// 2. 사용자의 누적 풀이 목록 조회
92-
const solvedProblems = await fetchUserSolutions(repoOwner, repoName, username, appToken);
91+
// 2. 이번 기수에서 사용자가 제출한 풀이 목록 조회
92+
const solvedProblems = await fetchCohortUserSolutions(repoOwner, repoName, username, appToken);
9393
console.log(
94-
`[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} cumulative solutions`
94+
`[learningStatus] PR #${prNumber}: ${username} has ${solvedProblems.length} solutions in current cohort`
9595
);
9696

9797
// 3. 이번 PR 제출 파일 목록 조회

tests/subrequest-budget.test.js

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", (
111111
expect(fetchCount).toBeLessThan(50);
112112
});
113113

114-
it("postLearningStatus 는 50 회 이하 subrequest 를 호출한다 (예상 15: categories 1 + tree 1 + PR files 1 + 5×(raw+openai) + 이슈 코멘트 목록 1 + POST 1)", async () => {
114+
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 () => {
115115
const categories = Object.fromEntries(
116116
SOLUTION_FILES.map((_, i) => [
117117
`problem-${i + 1}`,
@@ -123,6 +123,14 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", (
123123
])
124124
);
125125

126+
// 한 프로젝트(기수)당 최대 15개 PR 가정 — 유저가 cohort 에서 머지한 PR 15개
127+
const COHORT_PR_COUNT = 15;
128+
const COHORT_PR_NUMBERS = Array.from(
129+
{ length: COHORT_PR_COUNT },
130+
(_, i) => PR_NUMBER - 1 - i
131+
);
132+
const COHORT_PROJECT_ID = "PVT_kwDO_cohort";
133+
126134
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
127135
const urlStr = typeof url === "string" ? url : url.url;
128136
const method = opts?.method ?? "GET";
@@ -131,11 +139,44 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", (
131139
return okJson(categories);
132140
}
133141

134-
if (urlStr.includes("/git/trees/main")) {
135-
return okJson({
136-
truncated: false,
137-
tree: SOLUTION_FILES.map((f) => ({ type: "blob", path: f.filename })),
138-
});
142+
if (urlStr === "https://api.github.com/graphql" && method === "POST") {
143+
const body = JSON.parse(opts.body);
144+
if (body.query.includes("projectsV2")) {
145+
return okJson({
146+
data: {
147+
repository: {
148+
projectsV2: {
149+
nodes: [
150+
{ id: COHORT_PROJECT_ID, title: "리트코드 스터디 9기", closed: false },
151+
],
152+
},
153+
},
154+
},
155+
});
156+
}
157+
if (body.query.includes("ProjectV2")) {
158+
return okJson({
159+
data: {
160+
node: {
161+
items: {
162+
pageInfo: { hasNextPage: false, endCursor: null },
163+
nodes: COHORT_PR_NUMBERS.map((n) => ({
164+
content: {
165+
number: n,
166+
state: "MERGED",
167+
author: { login: USERNAME },
168+
},
169+
})),
170+
},
171+
},
172+
},
173+
});
174+
}
175+
throw new Error(`Unexpected GraphQL query: ${body.query}`);
176+
}
177+
178+
if (COHORT_PR_NUMBERS.some((n) => urlStr.includes(`/pulls/${n}/files`))) {
179+
return okJson(SOLUTION_FILES);
139180
}
140181

141182
if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) {
@@ -185,7 +226,7 @@ describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", (
185226
const fetchCount = globalThis.fetch.mock.calls.length;
186227

187228
expect(result.analyzed).toBe(5);
188-
expect(fetchCount).toBe(15);
229+
expect(fetchCount).toBe(31);
189230
expect(fetchCount).toBeLessThan(50);
190231
});
191232

utils/learningData.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,194 @@
44

55
import { getGitHubHeaders } from "./github.js";
66

7+
const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
8+
const COHORT_PROJECT_PATTERN = / \s*\d+/;
9+
10+
/**
11+
* GitHub GraphQL API 호출 헬퍼
12+
*
13+
* @param {string} query
14+
* @param {string} appToken
15+
* @returns {Promise<object>}
16+
*/
17+
async function graphql(query, appToken) {
18+
const response = await fetch(GITHUB_GRAPHQL_URL, {
19+
method: "POST",
20+
headers: {
21+
...getGitHubHeaders(appToken),
22+
"Content-Type": "application/json",
23+
},
24+
body: JSON.stringify({ query }),
25+
});
26+
27+
if (!response.ok) {
28+
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
29+
}
30+
31+
const result = await response.json();
32+
if (result.errors) {
33+
throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`);
34+
}
35+
36+
return result.data;
37+
}
38+
39+
/**
40+
* 현재 진행 중인 기수 프로젝트 ID를 조회한다.
41+
* "리트코드 스터디X기" 패턴의 열린 프로젝트를 찾는다.
42+
*
43+
* @param {string} repoOwner
44+
* @param {string} repoName
45+
* @param {string} appToken
46+
* @returns {Promise<string|null>} 프로젝트 node ID, 없으면 null
47+
*/
48+
async function fetchActiveCohortProjectId(repoOwner, repoName, appToken) {
49+
const data = await graphql(
50+
`{
51+
repository(owner: "${repoOwner}", name: "${repoName}") {
52+
projectsV2(first: 20) {
53+
nodes {
54+
id
55+
title
56+
closed
57+
}
58+
}
59+
}
60+
}`,
61+
appToken
62+
);
63+
64+
const projects = data.repository.projectsV2.nodes;
65+
const active = projects.find(
66+
(p) => !p.closed && COHORT_PROJECT_PATTERN.test(p.title)
67+
);
68+
69+
if (!active) {
70+
console.warn(
71+
`[fetchActiveCohortProjectId] No open cohort project found for ${repoOwner}/${repoName}`
72+
);
73+
return null;
74+
}
75+
76+
console.log(
77+
`[fetchActiveCohortProjectId] Active cohort project: "${active.title}" (${active.id})`
78+
);
79+
return active.id;
80+
}
81+
82+
/**
83+
* 기수 프로젝트에서 해당 유저가 머지한 PR 번호 목록을 반환한다.
84+
* 프로젝트 아이템을 페이지네이션하며 author.login으로 필터링한다.
85+
*
86+
* @param {string} projectId
87+
* @param {string} username
88+
* @param {string} appToken
89+
* @returns {Promise<number[]>}
90+
*/
91+
async function fetchUserMergedPRsInProject(projectId, username, appToken) {
92+
const prNumbers = [];
93+
let cursor = null;
94+
95+
while (true) {
96+
const afterClause = cursor ? `, after: "${cursor}"` : "";
97+
const data = await graphql(
98+
`{
99+
node(id: "${projectId}") {
100+
... on ProjectV2 {
101+
items(first: 100${afterClause}) {
102+
pageInfo { hasNextPage endCursor }
103+
nodes {
104+
content {
105+
... on PullRequest {
106+
number
107+
state
108+
author { login }
109+
}
110+
}
111+
}
112+
}
113+
}
114+
}
115+
}`,
116+
appToken
117+
);
118+
119+
const { nodes, pageInfo } = data.node.items;
120+
121+
for (const item of nodes) {
122+
const pr = item.content;
123+
if (
124+
pr?.state === "MERGED" &&
125+
pr?.author?.login?.toLowerCase() === username.toLowerCase()
126+
) {
127+
prNumbers.push(pr.number);
128+
}
129+
}
130+
131+
if (!pageInfo.hasNextPage) break;
132+
cursor = pageInfo.endCursor;
133+
}
134+
135+
return prNumbers;
136+
}
137+
138+
/**
139+
* 현재 기수 프로젝트에서 해당 유저가 제출한 문제 목록을 반환한다.
140+
*
141+
* 기수 프로젝트를 찾지 못하면 전체 레포 트리 스캔(fetchUserSolutions)으로 폴백한다.
142+
*
143+
* @param {string} repoOwner
144+
* @param {string} repoName
145+
* @param {string} username
146+
* @param {string} appToken
147+
* @returns {Promise<string[]>}
148+
*/
149+
export async function fetchCohortUserSolutions(
150+
repoOwner,
151+
repoName,
152+
username,
153+
appToken
154+
) {
155+
const projectId = await fetchActiveCohortProjectId(
156+
repoOwner,
157+
repoName,
158+
appToken
159+
);
160+
161+
if (!projectId) {
162+
console.warn(
163+
`[fetchCohortUserSolutions] Falling back to full tree scan for ${username}`
164+
);
165+
return fetchUserSolutions(repoOwner, repoName, username, appToken);
166+
}
167+
168+
const prNumbers = await fetchUserMergedPRsInProject(
169+
projectId,
170+
username,
171+
appToken
172+
);
173+
174+
console.log(
175+
`[fetchCohortUserSolutions] ${username} has ${prNumbers.length} merged PRs in current cohort`
176+
);
177+
178+
const problemNames = new Set();
179+
for (const prNumber of prNumbers) {
180+
const submissions = await fetchPRSubmissions(
181+
repoOwner,
182+
repoName,
183+
prNumber,
184+
username,
185+
appToken
186+
);
187+
for (const { problemName } of submissions) {
188+
problemNames.add(problemName);
189+
}
190+
}
191+
192+
return Array.from(problemNames);
193+
}
194+
7195
/**
8196
* Fetches problem-categories.json from the repo root via GitHub API.
9197
* Returns parsed JSON object, or null if the file is not found (404).

0 commit comments

Comments
 (0)