Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions handlers/learning-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import {
fetchProblemCategories,
fetchUserSolutions,
fetchCohortUserSolutions,
fetchPRSubmissions,
} from "../utils/learningData.js";
import { generateApproachAnalysis } from "../utils/openai.js";
Expand Down Expand Up @@ -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 제출 파일 목록 조회
Expand Down
188 changes: 188 additions & 0 deletions utils/learningData.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>}
*/
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<string|null>} 프로젝트 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<number[]>}
*/
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<string[]>}
*/
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).
Expand Down