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