From ba017d97fd7fcddb0d54baa4e7a4f8dc4badaa69 Mon Sep 17 00:00:00 2001 From: soobing Date: Wed, 8 Apr 2026 22:55:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?perf:=20AI=20=ED=95=B8=EB=93=A4=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=84=EB=8F=84=20Worker=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20subrequest?= =?UTF-8?q?=20=EC=98=88=EC=82=B0=20=EB=8F=85=EB=A6=BD=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self-fetch + ctx.waitUntil() 패턴으로 tagPatterns, learningStatus, complexityAnalysis 각각을 별도 Worker invocation에서 실행하여 50 subrequest 제한을 공유하지 않도록 개선. Co-Authored-By: sounmind Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 5 +++ handlers/internal-dispatch.js | 85 +++++++++++++++++++++++++++++++++++ handlers/webhooks.js | 67 +++++++++++++++++++-------- index.js | 10 ++++- 4 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 handlers/internal-dispatch.js diff --git a/AGENTS.md b/AGENTS.md index 47db9bf..fe2dbb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,6 +328,11 @@ wrangler secret put OPENAI_API_KEY # Webhook Secret (선택사항) wrangler secret put WEBHOOK_SECRET + +# Internal Dispatch Secret (AI 핸들러 Worker 분리용, 권장) +# 설정하면 tagPatterns, learningStatus, complexityAnalysis가 +# 별도 Worker 호출로 디스패치되어 각각 독립적인 subrequest 예산을 가짐 +wrangler secret put INTERNAL_SECRET ``` ### 5. GitHub App 설치 diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js new file mode 100644 index 0000000..f78f1c0 --- /dev/null +++ b/handlers/internal-dispatch.js @@ -0,0 +1,85 @@ +/** + * 내부 디스패치 핸들러 + * + * self-fetch를 통해 호출되는 내부 엔드포인트. + * 각 핸들러가 별도 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"; + +const INTERNAL_HEADER = "X-Internal-Secret"; + +/** + * 내부 요청 인증 검증 + */ +function verifyInternalRequest(request, env) { + if (!env.INTERNAL_SECRET) { + console.error("[internal-dispatch] INTERNAL_SECRET not configured"); + return false; + } + return request.headers.get(INTERNAL_HEADER) === env.INTERNAL_SECRET; +} + +/** + * 내부 디스패치 엔드포인트 라우터 + * + * @param {Request} request + * @param {object} env + * @param {string} pathname + */ +export async function handleInternalDispatch(request, env, pathname) { + if (!verifyInternalRequest(request, env)) { + return errorResponse("Unauthorized", 401); + } + + const payload = await request.json(); + + try { + 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); + + default: + return errorResponse("Not found", 404); + } + } catch (error) { + console.error(`[internal-dispatch] ${pathname} failed:`, error); + return errorResponse(`Internal handler error: ${error.message}`, 500); + } +} + +async function handleTagPatterns(payload, appToken, env) { + const { repoOwner, repoName, prNumber, headSha, prData } = payload; + const result = await tagPatterns( + repoOwner, + repoName, + prNumber, + headSha, + prData, + appToken, + env.OPENAI_API_KEY + ); + 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.OPENAI_API_KEY + ); + return corsResponse({ handler: "learning-status", result }); +} diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 6745115..7fed34c 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -27,7 +27,7 @@ import { postLearningStatus } from "./learning-status.js"; /** * GitHub webhook 이벤트 처리 */ -export async function handleWebhook(request, env) { +export async function handleWebhook(request, env, ctx) { try { const payload = await request.json(); const eventType = request.headers.get("X-GitHub-Event"); @@ -54,7 +54,7 @@ export async function handleWebhook(request, env) { return handleProjectsV2ItemEvent(payload, env); case "pull_request": - return handlePullRequestEvent(payload, env); + return handlePullRequestEvent(payload, env, ctx); case "issue_comment": return handleIssueCommentEvent(payload, env); @@ -240,7 +240,7 @@ async function getChangedFilenames(repoOwner, repoName, baseSha, headSha, appTok * - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일) * - synchronize: 알고리즘 패턴 태깅만 (변경된 파일만, Week 체크 스킵) */ -async function handlePullRequestEvent(payload, env) { +async function handlePullRequestEvent(payload, env, ctx) { const action = payload.action; // opened, reopened, synchronize 액션만 처리 @@ -284,8 +284,51 @@ async function handlePullRequestEvent(payload, env) { console.log(`PR synchronized: #${prNumber}`); } - // 알고리즘 패턴 태깅 (OPENAI_API_KEY 있을 때만) - if (env.OPENAI_API_KEY) { + // AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산) + if (env.OPENAI_API_KEY && env.INTERNAL_SECRET) { + const baseUrl = env.WORKER_URL || "https://github.daleseo.workers.dev"; + + const dispatchHeaders = { + "Content-Type": "application/json", + "X-Internal-Secret": env.INTERNAL_SECRET, + }; + + const commonPayload = { repoOwner, repoName, prNumber }; + + // 패턴 태깅 디스패치 + ctx.waitUntil( + fetch(`${baseUrl}/internal/tag-patterns`, { + method: "POST", + headers: dispatchHeaders, + body: JSON.stringify({ + ...commonPayload, + headSha: pr.head.sha, + prData: pr, + }), + }).catch((err) => + console.error(`[dispatch] tagPatterns 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}`); + } else if (env.OPENAI_API_KEY) { + // INTERNAL_SECRET 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) + console.warn("[handlePullRequestEvent] INTERNAL_SECRET not set, running handlers in-process"); + try { // synchronize일 때만 변경 파일 목록 추출 (최적화: #7) let changedFilenames = null; @@ -314,24 +357,12 @@ async function handlePullRequestEvent(payload, env) { ); } catch (error) { console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`); - // 패턴 태깅 실패는 전체 흐름을 중단시키지 않음 } - } - // 학습 현황 댓글 (OPENAI_API_KEY 있을 때만) - if (env.OPENAI_API_KEY) { try { - await postLearningStatus( - repoOwner, - repoName, - prNumber, - pr.user.login, - appToken, - env.OPENAI_API_KEY - ); + await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.OPENAI_API_KEY); } catch (error) { console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`); - // 학습 현황 실패는 전체 흐름을 중단시키지 않음 } } diff --git a/index.js b/index.js index a7a5ddd..2fa0b52 100644 --- a/index.js +++ b/index.js @@ -6,13 +6,14 @@ import { checkWeeks } from "./handlers/check-weeks.js"; import { handleWebhook } from "./handlers/webhooks.js"; +import { handleInternalDispatch } from "./handlers/internal-dispatch.js"; import { approvePrs } from "./handlers/approve_prs.js"; import { mergePrs } from "./handlers/merge_prs.js"; import { preflightResponse, corsResponse, errorResponse } from "./utils/cors.js"; import { verifyWebhookSignature } from "./utils/webhook.js"; export default { - async fetch(request, env) { + async fetch(request, env, ctx) { // Handle CORS preflight if (request.method === "OPTIONS") { return preflightResponse(); @@ -24,6 +25,11 @@ export default { const url = new URL(request.url); + // 내부 디스패치 엔드포인트 (self-fetch로 호출, 별도 Worker 호출로 subrequest 분리) + if (url.pathname.startsWith("/internal/")) { + return handleInternalDispatch(request, env, url.pathname); + } + // GitHub Webhook 수신 if (url.pathname === "/webhooks") { // Webhook signature 검증 @@ -51,7 +57,7 @@ export default { body: rawBody, }); - return handleWebhook(newRequest, env); + return handleWebhook(newRequest, env, ctx); } // PR Week 설정 검사 (수동 호출용) From aa3313df2187d14b5e964544016c13ec92e27cf3 Mon Sep 17 00:00:00 2001 From: soobing Date: Thu, 16 Apr 2026 22:20:23 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20subrequest=20=EC=98=88=EC=82=B0=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?AI=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/subrequest-budget.test.js: 5파일 PR 시나리오에서 tagPatterns(22회), postLearningStatus(15회) fetch 호출 수 검증 (Cloudflare 50 한도 하회) - handlers/internal-dispatch.test.js: 내부 디스패치 엔드포인트 인증·라우팅·에러 처리 테스트 - handlers/webhooks.test.js: AI 핸들러 self-fetch 디스패치 통합 테스트 추가 - AGENTS.md, README.md: AI 핸들러 Worker 분리 아키텍처 다이어그램 및 테스트 실행 가이드 - integration.yaml: vi.mock 전역 누출 회피를 위해 bun test handlers/, tests/ 를 별도 스텝으로 실행 - handlers/webhooks.js, wrangler.jsonc: WORKER_URL 을 명시적 env 로 요구하고 기본값은 wrangler vars 로 이동 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/integration.yaml | 3 +- AGENTS.md | 96 ++++++++++++- README.md | 31 ++++- handlers/internal-dispatch.test.js | 211 +++++++++++++++++++++++++++++ handlers/webhooks.js | 8 +- handlers/webhooks.test.js | 123 +++++++++++++++++ tests/subrequest-budget.test.js | 190 ++++++++++++++++++++++++++ wrangler.jsonc | 3 + 8 files changed, 654 insertions(+), 11 deletions(-) create mode 100644 handlers/internal-dispatch.test.js create mode 100644 tests/subrequest-budget.test.js diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 051bb11..f86f256 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -14,4 +14,5 @@ jobs: steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - - run: bun test + - run: bun test handlers/ + - run: bun test tests/ diff --git a/AGENTS.md b/AGENTS.md index fe2dbb6..1e4eee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,27 @@ GitHub Organization webhook 수신용 엔드포인트 4. Week 없음 → 경고 댓글 작성 (중복 방지: Bot이 작성한 경고 댓글이 이미 있으면 스킵) 5. Week 있음 → 기존 경고 댓글 삭제 (Bot이 작성한 Week 경고 댓글만) +### 4. AI 핸들러 Worker 분리 아키텍처 + +PR 이벤트를 받으면 webhook 핸들러가 두 AI 핸들러(`tagPatterns`, `postLearningStatus`)를 **별도 Worker invocation**으로 분리 디스패치한다. 각 invocation은 독립적인 Cloudflare subrequest 예산(50)을 가지므로 파일이 많은 PR에서도 예산 초과를 방지한다. + +``` +GitHub webhook + │ + ▼ +[Invocation #1] webhook 핸들러 ← 50 subrequest 예산 + │ ctx.waitUntil(fetch("/internal/tag-patterns")) ─┐ self-fetch는 + │ ctx.waitUntil(fetch("/internal/learning-status")) ─┤ 외부 요청이라 + │ │ 새 invocation 트리거 + ├──────────────▶ [Invocation #2] tagPatterns ◀─┘ ← 독립 50 예산 + │ + └──────────────▶ [Invocation #3] postLearningStatus ← 독립 50 예산 +``` + +- `INTERNAL_SECRET`과 `WORKER_URL`이 모두 설정되어야 활성화된다. 둘 중 하나라도 없으면 기존처럼 같은 invocation에서 순차 실행(subrequest 예산 공유)되어 파일이 많은 PR에서 예산을 초과할 수 있다. +- 내부 엔드포인트는 `/internal/tag-patterns`, `/internal/learning-status`이며 `X-Internal-Secret` 헤더로 인증한다. +- 참고: `tests/subrequest-budget.test.js`가 5개 파일 변경 시나리오에서 각 핸들러의 fetch 호출 수(각각 22, 15회)를 회귀 테스트로 박아둔다. + ## 보안 및 권한 ### DaleStudy Organization 전용 @@ -330,11 +351,14 @@ wrangler secret put OPENAI_API_KEY wrangler secret put WEBHOOK_SECRET # Internal Dispatch Secret (AI 핸들러 Worker 분리용, 권장) -# 설정하면 tagPatterns, learningStatus, complexityAnalysis가 -# 별도 Worker 호출로 디스패치되어 각각 독립적인 subrequest 예산을 가짐 +# 설정하면 tagPatterns, learningStatus가 별도 Worker 호출로 +# 디스패치되어 각각 독립적인 subrequest 예산을 가짐. +# WORKER_URL과 함께 설정되어야 활성화된다. wrangler secret put INTERNAL_SECRET ``` +`WORKER_URL`은 `wrangler.jsonc`의 `vars`에 정의되어 있어 기본 배포에는 추가 설정이 필요 없다. 스테이징/다른 계정 등으로 배포할 때만 덮어쓰면 된다. + ### 5. GitHub App 설치 저장소에 App이 설치되어 있는지 확인: @@ -364,6 +388,69 @@ curl -X POST https://github.dalestudy.com/check-weeks \ - ❌ npm 패키지 대부분 호환 안 됨 (@octokit/app 등) - ✅ 순수 JavaScript + Web APIs로 구현 +## 테스트 + +이 프로젝트는 [Bun](https://bun.sh)의 내장 테스트 러너를 사용합니다. 별도의 `package.json`이나 의존성 설치 없이 테스트를 작성하고 실행할 수 있습니다. + +### 테스트 실행 + +테스트는 `handlers/`(핸들러별 단위 테스트)와 `tests/`(프로세스 격리가 필요한 테스트)로 나뉘어 있다. Bun의 `vi.mock()`은 프로세스 전역 레지스트리에 등록되어 같은 실행 내에서 다른 파일로 누출되므로, 같은 모듈을 모킹하는 테스트와 실제 구현을 호출하는 테스트는 **별도 `bun test` 프로세스로 실행**해야 한다. + +```bash +# 전체 테스트 실행 (두 디렉토리를 별도 프로세스로) +bun test handlers/ && bun test tests/ + +# 특정 파일만 실행 +bun test handlers/webhooks.test.js + +# 감시 모드 (파일 변경 시 자동 재실행) +bun test handlers/ --watch +``` + +Bun 설치: https://bun.sh/docs/installation + +### 테스트 파일 작성 규칙 + +- 테스트 파일은 대상 파일과 같은 디렉토리에 `*.test.js` 이름으로 배치합니다. + - 예: `handlers/webhooks.js` → `handlers/webhooks.test.js` +- `bun:test`에서 제공하는 API(`describe`, `it`, `expect`, `vi`)를 사용합니다. +- 외부 의존성(`utils/github.js` 등)은 `vi.mock()`으로 대체하고, `fetch`는 `globalThis.fetch = vi.fn()...`로 스텁합니다. + +```javascript +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +vi.mock("../utils/github.js", () => ({ + generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"), +})); + +import { checkWeeks } from "./check-weeks.js"; + +describe("checkWeeks", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + }); + + it("returns 403 for non-DaleStudy organization", async () => { + const request = new Request("https://example.com/check-weeks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repo_owner: "OtherOrg", repo_name: "leetcode-study" }), + }); + + const response = await checkWeeks(request, {}); + expect(response.status).toBe(403); + }); +}); +``` + +### CI 자동 실행 + +`.github/workflows/integration.yaml`이 모든 Pull Request와 `main` 브랜치 푸시에서 `bun test handlers/`와 `bun test tests/`를 각각 별도 스텝으로 자동 실행합니다. 테스트가 실패하면 PR 체크가 실패하므로, 머지 전에 반드시 통과시켜야 합니다. + ## 새 기능 추가 가이드 새로운 자동화 기능을 추가할 때 다음 단계를 따르세요: @@ -371,8 +458,9 @@ curl -X POST https://github.dalestudy.com/check-weeks \ 1. **엔드포인트 추가**: `index.js`의 `fetch()` 함수에 새로운 pathname 라우팅 추가 2. **핸들러 함수 작성**: 비즈니스 로직을 별도 함수로 분리 (예: `handleCheckAllPrs`) 3. **GitHub App 권한 확인**: 필요한 권한이 있는지 확인하고 없으면 추가 -4. **문서 업데이트**: AGENTS.md, README.md에 새 기능 문서화 -5. **테스트**: 로컬(`wrangler dev`)에서 먼저 테스트 후 배포 +4. **테스트 작성**: 핸들러 옆에 `*.test.js`를 추가하고 `bun test`로 통과 확인 (위 "테스트" 섹션 참고) +5. **문서 업데이트**: AGENTS.md, README.md에 새 기능 문서화 +6. **로컬 실행 테스트**: `wrangler dev`로 실제 엔드포인트 동작 확인 후 배포 ## 코드 수정 시 주의사항 diff --git a/README.md b/README.md index 5cbcd3b..b0bca10 100644 --- a/README.md +++ b/README.md @@ -168,13 +168,40 @@ https://github.dalestudy.com # 개발 서버 시작 wrangler dev -# 로컬 테스트 (별도 터미널) +# 로컬 엔드포인트 호출 (별도 터미널) curl -X POST http://localhost:8787/check-weeks \ -H "Content-Type: application/json" \ -d '{"repo_owner": "DaleStudy", "repo_name": "leetcode-study"}' ``` -### 프로덕션 테스트 +### 테스트 코드 실행 + +이 프로젝트는 **[Bun](https://bun.sh)의 내장 테스트 러너**를 사용합니다. `package.json`이나 `node_modules`가 없는 이유는 Bun이 런타임·테스트 러너·모킹 API(`vi.mock`, `vi.fn`)를 모두 내장하고 있어서 별도 설치 없이 바로 실행되기 때문입니다. + +```bash +# Bun 설치 (최초 1회) — https://bun.sh/docs/installation +curl -fsSL https://bun.sh/install | bash + +# 전체 테스트 실행 (두 디렉토리를 별도 프로세스로) +bun test handlers/ && bun test tests/ + +# 특정 파일만 실행 +bun test handlers/webhooks.test.js + +# 감시 모드 (파일 변경 시 자동 재실행) +bun test handlers/ --watch +``` + +테스트는 두 디렉토리로 나뉘어 있습니다: + +- `handlers/*.test.js`: 대상 파일 옆에 두는 단위 테스트 +- `tests/*.test.js`: Bun `vi.mock()`의 전역 레지스트리 누출을 피하기 위해 별도 프로세스로 실행하는 테스트 (예: `subrequest-budget.test.js`) + +자세한 작성 규칙과 예제는 `AGENTS.md`의 "테스트" 섹션을 참고하세요. + +모든 Pull Request와 `main` 브랜치 푸시에서 `.github/workflows/integration.yaml`이 두 디렉토리의 테스트를 자동 실행합니다. + +### 프로덕션 엔드포인트 호출 ```bash curl -X POST https://github.dalestudy.com/check-weeks \ diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js new file mode 100644 index 0000000..d163361 --- /dev/null +++ b/handlers/internal-dispatch.test.js @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +vi.mock("../utils/github.js", () => ({ + generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"), +})); + +vi.mock("./tag-patterns.js", () => ({ + tagPatterns: vi.fn().mockResolvedValue({ tagged: true }), +})); + +vi.mock("./learning-status.js", () => ({ + postLearningStatus: vi.fn().mockResolvedValue({ posted: true }), +})); + +import { handleInternalDispatch } from "./internal-dispatch.js"; +import { tagPatterns } from "./tag-patterns.js"; +import { postLearningStatus } from "./learning-status.js"; +import { generateGitHubAppToken } from "../utils/github.js"; + +const VALID_SECRET = "test-secret-123"; + +function makeRequest(pathname, { secret, body } = {}) { + const headers = { "Content-Type": "application/json" }; + if (secret !== undefined) { + headers["X-Internal-Secret"] = secret; + } + return new Request(`https://example.com${pathname}`, { + method: "POST", + headers, + body: JSON.stringify(body ?? {}), + }); +} + +describe("handleInternalDispatch — authentication", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when INTERNAL_SECRET is not configured in env", async () => { + const request = makeRequest("/internal/tag-patterns", { + secret: "anything", + body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, + }); + + const response = await handleInternalDispatch( + request, + {}, + "/internal/tag-patterns" + ); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + expect(generateGitHubAppToken).not.toHaveBeenCalled(); + expect(tagPatterns).not.toHaveBeenCalled(); + }); + + it("returns 401 when X-Internal-Secret header is missing", async () => { + const request = makeRequest("/internal/tag-patterns", { + body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, + }); + + const response = await handleInternalDispatch( + request, + { INTERNAL_SECRET: VALID_SECRET }, + "/internal/tag-patterns" + ); + + expect(response.status).toBe(401); + expect(tagPatterns).not.toHaveBeenCalled(); + }); + + it("returns 401 when X-Internal-Secret header does not match env.INTERNAL_SECRET", async () => { + const request = makeRequest("/internal/tag-patterns", { + secret: "wrong-secret", + body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, + }); + + const response = await handleInternalDispatch( + request, + { INTERNAL_SECRET: VALID_SECRET }, + "/internal/tag-patterns" + ); + + expect(response.status).toBe(401); + expect(tagPatterns).not.toHaveBeenCalled(); + }); +}); + +describe("handleInternalDispatch — routing", () => { + const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes /internal/tag-patterns to tagPatterns with payload fields", async () => { + const prData = { number: 42, head: { sha: "abc123" } }; + const request = makeRequest("/internal/tag-patterns", { + secret: VALID_SECRET, + body: { + repoOwner: "DaleStudy", + repoName: "leetcode-study", + prNumber: 42, + headSha: "abc123", + prData, + }, + }); + + const response = await handleInternalDispatch( + request, + env, + "/internal/tag-patterns" + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.handler).toBe("tag-patterns"); + expect(tagPatterns).toHaveBeenCalledWith( + "DaleStudy", + "leetcode-study", + 42, + "abc123", + prData, + "fake-token", + "fake-openai" + ); + expect(postLearningStatus).not.toHaveBeenCalled(); + }); + + it("routes /internal/learning-status to postLearningStatus with payload fields", 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-openai" + ); + expect(tagPatterns).not.toHaveBeenCalled(); + }); + + it("returns 404 for an unknown /internal/* pathname", async () => { + const request = makeRequest("/internal/unknown", { + secret: VALID_SECRET, + body: {}, + }); + + const response = await handleInternalDispatch( + request, + env, + "/internal/unknown" + ); + + expect(response.status).toBe(404); + expect(tagPatterns).not.toHaveBeenCalled(); + expect(postLearningStatus).not.toHaveBeenCalled(); + }); +}); + +describe("handleInternalDispatch — error handling", () => { + const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 500 when the handler throws", async () => { + tagPatterns.mockRejectedValueOnce(new Error("boom")); + + const request = makeRequest("/internal/tag-patterns", { + secret: VALID_SECRET, + body: { + repoOwner: "DaleStudy", + repoName: "leetcode-study", + prNumber: 1, + headSha: "sha", + prData: {}, + }, + }); + + const response = await handleInternalDispatch( + request, + env, + "/internal/tag-patterns" + ); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toContain("boom"); + }); +}); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 7fed34c..01abbe6 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -285,8 +285,8 @@ async function handlePullRequestEvent(payload, env, ctx) { } // AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산) - if (env.OPENAI_API_KEY && env.INTERNAL_SECRET) { - const baseUrl = env.WORKER_URL || "https://github.daleseo.workers.dev"; + if (env.OPENAI_API_KEY && env.INTERNAL_SECRET && env.WORKER_URL) { + const baseUrl = env.WORKER_URL; const dispatchHeaders = { "Content-Type": "application/json", @@ -326,8 +326,8 @@ async function handlePullRequestEvent(payload, env, ctx) { console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`); } else if (env.OPENAI_API_KEY) { - // INTERNAL_SECRET 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) - console.warn("[handlePullRequestEvent] INTERNAL_SECRET not set, running handlers in-process"); + // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) + console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); try { // synchronize일 때만 변경 파일 목록 추출 (최적화: #7) diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index d7fe355..6d307d6 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -39,6 +39,8 @@ import { removeWarningComment, handleWeekComment, } from "../utils/prWeeks.js"; +import { tagPatterns } from "./tag-patterns.js"; +import { postLearningStatus } from "./learning-status.js"; function makeRequest(eventType, payload) { return new Request("https://example.com/webhooks", { @@ -233,3 +235,124 @@ describe("webhook repo filtering", () => { }); }); }); + +describe("handlePullRequestEvent — AI handler dispatch", () => { + const basePRPayload = { + action: "synchronize", + organization: { login: "DaleStudy" }, + repository: { + name: "leetcode-study", + owner: { login: "DaleStudy" }, + }, + pull_request: { + number: 42, + labels: [], + head: { sha: "head-sha" }, + user: { login: "testuser" }, + }, + }; + + function makeCtx() { + return { waitUntil: vi.fn() }; + } + + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ files: [] }), + }); + }); + + it("dispatches 2 self-fetches via ctx.waitUntil when OPENAI_API_KEY, INTERNAL_SECRET, and WORKER_URL are all set", async () => { + const ctx = makeCtx(); + const env = { + OPENAI_API_KEY: "fake-openai", + INTERNAL_SECRET: "fake-secret", + WORKER_URL: "https://worker.test", + }; + + const response = await handleWebhook( + makeRequest("pull_request", basePRPayload), + env, + ctx + ); + + expect(response.status).toBe(200); + expect(ctx.waitUntil).toHaveBeenCalledTimes(2); + + 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"); + + const dispatchCall = globalThis.fetch.mock.calls.find(([url]) => + url.endsWith("/internal/tag-patterns") + ); + expect(dispatchCall[1].headers["X-Internal-Secret"]).toBe("fake-secret"); + + expect(tagPatterns).not.toHaveBeenCalled(); + expect(postLearningStatus).not.toHaveBeenCalled(); + }); + + it("falls back to in-process handler calls when INTERNAL_SECRET is not set", async () => { + const ctx = makeCtx(); + const env = { + OPENAI_API_KEY: "fake-openai", + WORKER_URL: "https://worker.test", + }; + + const response = await handleWebhook( + makeRequest("pull_request", basePRPayload), + env, + ctx + ); + + expect(response.status).toBe(200); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + expect(tagPatterns).toHaveBeenCalledTimes(1); + expect(postLearningStatus).toHaveBeenCalledTimes(1); + + const [repoOwner, repoName, prNumber] = tagPatterns.mock.calls[0]; + expect(repoOwner).toBe("DaleStudy"); + expect(repoName).toBe("leetcode-study"); + expect(prNumber).toBe(42); + }); + + it("falls back to in-process handler calls when WORKER_URL is not set", async () => { + const ctx = makeCtx(); + const env = { + OPENAI_API_KEY: "fake-openai", + INTERNAL_SECRET: "fake-secret", + }; + + const response = await handleWebhook( + makeRequest("pull_request", basePRPayload), + env, + ctx + ); + + expect(response.status).toBe(200); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + expect(tagPatterns).toHaveBeenCalledTimes(1); + expect(postLearningStatus).toHaveBeenCalledTimes(1); + }); + + it("does not dispatch or call handlers when OPENAI_API_KEY is missing", async () => { + const ctx = makeCtx(); + const env = { + INTERNAL_SECRET: "fake-secret", + WORKER_URL: "https://worker.test", + }; + + const response = await handleWebhook( + makeRequest("pull_request", basePRPayload), + env, + ctx + ); + + expect(response.status).toBe(200); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + expect(tagPatterns).not.toHaveBeenCalled(); + expect(postLearningStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js new file mode 100644 index 0000000..88bf84f --- /dev/null +++ b/tests/subrequest-budget.test.js @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +import { tagPatterns } from "../handlers/tag-patterns.js"; +import { postLearningStatus } from "../handlers/learning-status.js"; + +const REPO_OWNER = "DaleStudy"; +const REPO_NAME = "leetcode-study"; +const PR_NUMBER = 42; +const HEAD_SHA = "head-sha"; +const USERNAME = "testuser"; +const APP_TOKEN = "fake-app-token"; +const OPENAI_KEY = "fake-openai-key"; + +const SOLUTION_FILES = Array.from({ length: 5 }, (_, i) => ({ + filename: `problem-${i + 1}/${USERNAME}.ts`, + status: "added", + raw_url: `https://raw.example.com/problem-${i + 1}/${USERNAME}.ts`, +})); + +function okJson(data) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }); +} + +function okText(text) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(text), + }); +} + +describe("subrequest budget — per handler invocation (5 changed files)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("tagPatterns makes ≤ 50 subrequests (expected 22: 1 files + 1 list comments + 5 deletes + 5×(raw+openai+post))", async () => { + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === "string" ? url : url.url; + const method = opts?.method ?? "GET"; + + if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { + return okJson(SOLUTION_FILES); + } + + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "GET") { + return okJson( + SOLUTION_FILES.map((f, i) => ({ + id: 1000 + i, + user: { type: "Bot" }, + body: "", + path: f.filename, + })) + ); + } + + if (urlStr.includes("/pulls/comments/") && method === "DELETE") { + return okJson({}); + } + + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() { return 0; }"); + } + + if (urlStr.includes("openai.com/v1/chat/completions")) { + return okJson({ + choices: [ + { + message: { + content: JSON.stringify({ + patterns: ["Two Pointers"], + description: "test", + }), + }, + }, + ], + usage: { prompt_tokens: 100, completion_tokens: 50 }, + }); + } + + if (urlStr.includes(`/pulls/${PR_NUMBER}/comments`) && method === "POST") { + return okJson({ id: 999 }); + } + + throw new Error(`Unexpected fetch in tagPatterns mock: ${method} ${urlStr}`); + }); + + const prData = { draft: false, labels: [] }; + const result = await tagPatterns( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + HEAD_SHA, + prData, + APP_TOKEN, + OPENAI_KEY + ); + + const fetchCount = globalThis.fetch.mock.calls.length; + + expect(result.tagged).toBe(5); + expect(fetchCount).toBe(22); + expect(fetchCount).toBeLessThan(50); + }); + + it("postLearningStatus makes ≤ 50 subrequests (expected 15: 1 categories + 1 tree + 1 PR files + 5×(raw+openai) + 1 list issue comments + 1 post)", async () => { + const categories = Object.fromEntries( + SOLUTION_FILES.map((_, i) => [ + `problem-${i + 1}`, + { + difficulty: "Easy", + categories: ["Array"], + intended_approach: "Two Pointers", + }, + ]) + ); + + 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.includes("/git/trees/main")) { + return okJson({ + truncated: false, + tree: SOLUTION_FILES.map((f) => ({ type: "blob", path: f.filename })), + }); + } + + if (urlStr.includes(`/pulls/${PR_NUMBER}/files`)) { + return okJson(SOLUTION_FILES); + } + + if (urlStr.startsWith("https://raw.example.com/")) { + return okText("function solution() { return 0; }"); + } + + if (urlStr.includes("openai.com/v1/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, + OPENAI_KEY + ); + + const fetchCount = globalThis.fetch.mock.calls.length; + + expect(result.analyzed).toBe(5); + expect(fetchCount).toBe(15); + expect(fetchCount).toBeLessThan(50); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 8f4804b..2635ad2 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,6 +3,9 @@ "main": "index.js", "compatibility_date": "2024-01-01", "account_id": "94480614a6df7731f1e4491bdac5c440", + "vars": { + "WORKER_URL": "https://github.daleseo.workers.dev", + }, "observability": { "logs": { "enabled": true, From b49e8b191bffe9e8dbb66b4be022b6d0fe40af8d Mon Sep 17 00:00:00 2001 From: soobing Date: Thu, 16 Apr 2026 22:23:41 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=EC=9D=84=20=ED=95=9C=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe/it 블록 제목을 모두 한국어로 통일. Co-Authored-By: Claude Opus 4.6 --- handlers/check-weeks.test.js | 12 +++++----- handlers/internal-dispatch.test.js | 20 ++++++++-------- handlers/webhooks.test.js | 38 +++++++++++++++--------------- tests/subrequest-budget.test.js | 6 ++--- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/handlers/check-weeks.test.js b/handlers/check-weeks.test.js index cde4738..ffdb053 100644 --- a/handlers/check-weeks.test.js +++ b/handlers/check-weeks.test.js @@ -24,7 +24,7 @@ function makeRequest(body) { const env = {}; -describe("check-weeks repo filtering", () => { +describe("check-weeks 저장소 필터링", () => { beforeEach(() => { vi.clearAllMocks(); globalThis.fetch = vi.fn().mockResolvedValue({ @@ -33,7 +33,7 @@ describe("check-weeks repo filtering", () => { }); }); - it("returns 403 for non-DaleStudy organization", async () => { + it("DaleStudy 가 아닌 organization 은 403 을 반환한다", async () => { const request = makeRequest({ repo_owner: "OtherOrg", repo_name: "leetcode-study", @@ -46,7 +46,7 @@ describe("check-weeks repo filtering", () => { expect(body.error).toContain("Unauthorized organization"); }); - it("returns 403 for non-leetcode-study repo_name", async () => { + it("leetcode-study 가 아닌 repo_name 은 403 을 반환한다", async () => { const request = makeRequest({ repo_owner: "DaleStudy", repo_name: "daleui", @@ -61,7 +61,7 @@ describe("check-weeks repo filtering", () => { expect(generateGitHubAppToken).not.toHaveBeenCalled(); }); - it("processes leetcode-study repo_name successfully", async () => { + it("leetcode-study repo_name 은 정상 처리한다", async () => { const request = makeRequest({ repo_owner: "DaleStudy", repo_name: "leetcode-study", @@ -74,7 +74,7 @@ describe("check-weeks repo filtering", () => { expect(body.success).toBe(true); }); - it("returns 400 when repo_name is missing", async () => { + it("repo_name 이 없으면 400 을 반환한다", async () => { const request = makeRequest({ repo_owner: "DaleStudy", }); @@ -86,7 +86,7 @@ describe("check-weeks repo filtering", () => { expect(body.error).toContain("repo_name"); }); - it("defaults repo_owner to DaleStudy when omitted", async () => { + it("repo_owner 생략 시 DaleStudy 로 기본 설정된다", async () => { const request = makeRequest({ repo_name: "leetcode-study", }); diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index d163361..56e74fc 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -31,12 +31,12 @@ function makeRequest(pathname, { secret, body } = {}) { }); } -describe("handleInternalDispatch — authentication", () => { +describe("handleInternalDispatch — 인증", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("returns 401 when INTERNAL_SECRET is not configured in env", async () => { + it("env 에 INTERNAL_SECRET 이 없으면 401 을 반환한다", async () => { const request = makeRequest("/internal/tag-patterns", { secret: "anything", body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, @@ -55,7 +55,7 @@ describe("handleInternalDispatch — authentication", () => { expect(tagPatterns).not.toHaveBeenCalled(); }); - it("returns 401 when X-Internal-Secret header is missing", async () => { + it("X-Internal-Secret 헤더가 없으면 401 을 반환한다", async () => { const request = makeRequest("/internal/tag-patterns", { body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, }); @@ -70,7 +70,7 @@ describe("handleInternalDispatch — authentication", () => { expect(tagPatterns).not.toHaveBeenCalled(); }); - it("returns 401 when X-Internal-Secret header does not match env.INTERNAL_SECRET", async () => { + it("X-Internal-Secret 헤더가 env.INTERNAL_SECRET 과 일치하지 않으면 401 을 반환한다", async () => { const request = makeRequest("/internal/tag-patterns", { secret: "wrong-secret", body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 }, @@ -87,14 +87,14 @@ describe("handleInternalDispatch — authentication", () => { }); }); -describe("handleInternalDispatch — routing", () => { +describe("handleInternalDispatch — 라우팅", () => { const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; beforeEach(() => { vi.clearAllMocks(); }); - it("routes /internal/tag-patterns to tagPatterns with payload fields", async () => { + it("/internal/tag-patterns 요청을 tagPatterns 로 payload 필드와 함께 라우팅한다", async () => { const prData = { number: 42, head: { sha: "abc123" } }; const request = makeRequest("/internal/tag-patterns", { secret: VALID_SECRET, @@ -128,7 +128,7 @@ describe("handleInternalDispatch — routing", () => { expect(postLearningStatus).not.toHaveBeenCalled(); }); - it("routes /internal/learning-status to postLearningStatus with payload fields", async () => { + it("/internal/learning-status 요청을 postLearningStatus 로 payload 필드와 함께 라우팅한다", async () => { const request = makeRequest("/internal/learning-status", { secret: VALID_SECRET, body: { @@ -159,7 +159,7 @@ describe("handleInternalDispatch — routing", () => { expect(tagPatterns).not.toHaveBeenCalled(); }); - it("returns 404 for an unknown /internal/* pathname", async () => { + it("알 수 없는 /internal/* 경로는 404 를 반환한다", async () => { const request = makeRequest("/internal/unknown", { secret: VALID_SECRET, body: {}, @@ -177,14 +177,14 @@ describe("handleInternalDispatch — routing", () => { }); }); -describe("handleInternalDispatch — error handling", () => { +describe("handleInternalDispatch — 에러 처리", () => { const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; beforeEach(() => { vi.clearAllMocks(); }); - it("returns 500 when the handler throws", async () => { + it("핸들러가 throw 하면 500 을 반환한다", async () => { tagPatterns.mockRejectedValueOnce(new Error("boom")); const request = makeRequest("/internal/tag-patterns", { diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 6d307d6..3a609a8 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -52,7 +52,7 @@ function makeRequest(eventType, payload) { const env = {}; -describe("webhook repo filtering", () => { +describe("webhook 저장소 필터링", () => { beforeEach(() => { vi.clearAllMocks(); globalThis.fetch = vi.fn().mockResolvedValue({ @@ -62,8 +62,8 @@ describe("webhook repo filtering", () => { }); }); - describe("top-level filter (payload.repository)", () => { - it("ignores immediately when payload.repository.name is not leetcode-study", async () => { + describe("최상위 필터 (payload.repository)", () => { + it("payload.repository.name 이 leetcode-study 가 아니면 즉시 무시한다", async () => { const request = makeRequest("pull_request", { action: "opened", organization: { login: "DaleStudy" }, @@ -77,7 +77,7 @@ describe("webhook repo filtering", () => { expect(body.message).toBe("Ignored: daleui"); }); - it("passes when payload.repository.name is leetcode-study", async () => { + it("payload.repository.name 이 leetcode-study 면 통과시킨다", async () => { const request = makeRequest("pull_request", { action: "synchronize", organization: { login: "DaleStudy" }, @@ -100,7 +100,7 @@ describe("webhook repo filtering", () => { }); }); - describe("projects_v2_item event repo filtering", () => { + describe("projects_v2_item 이벤트 저장소 필터링", () => { const basePayload = { action: "edited", organization: { login: "DaleStudy" }, @@ -116,7 +116,7 @@ describe("webhook repo filtering", () => { }, }; - it("ignores when GraphQL lookup returns a non-leetcode-study repo", async () => { + it("GraphQL 조회 결과가 leetcode-study 가 아니면 무시한다", async () => { getPRInfoFromNodeId.mockResolvedValue({ number: 962, owner: "DaleStudy", @@ -132,7 +132,7 @@ describe("webhook repo filtering", () => { expect(removeWarningComment).not.toHaveBeenCalled(); }); - it("processes normally when GraphQL lookup returns leetcode-study", async () => { + it("GraphQL 조회 결과가 leetcode-study 면 정상 처리한다", async () => { getPRInfoFromNodeId.mockResolvedValue({ number: 100, owner: "DaleStudy", @@ -146,7 +146,7 @@ describe("webhook repo filtering", () => { expect(body.message).toBe("Processed"); }); - it("ignores non-leetcode-study repo on deleted action", async () => { + it("deleted 액션에서 leetcode-study 가 아닌 저장소는 무시한다", async () => { getPRInfoFromNodeId.mockResolvedValue({ number: 962, owner: "DaleStudy", @@ -164,7 +164,7 @@ describe("webhook repo filtering", () => { expect(ensureWarningComment).not.toHaveBeenCalled(); }); - it("ignores non-leetcode-study repo on created action", async () => { + it("created 액션에서 leetcode-study 가 아닌 저장소는 무시한다", async () => { getPRInfoFromNodeId.mockResolvedValue({ number: 962, owner: "DaleStudy", @@ -183,8 +183,8 @@ describe("webhook repo filtering", () => { }); }); - describe("organization filter", () => { - it("ignores non-DaleStudy organization", async () => { + describe("organization 필터", () => { + it("DaleStudy 가 아닌 organization 은 무시한다", async () => { const request = makeRequest("pull_request", { action: "opened", organization: { login: "OtherOrg" }, @@ -201,7 +201,7 @@ describe("webhook repo filtering", () => { expect(body.message).toBe("Ignored: not DaleStudy organization"); }); - it("ignores when organization field is missing", async () => { + it("organization 필드가 없으면 무시한다", async () => { const request = makeRequest("pull_request", { action: "opened", repository: { @@ -218,8 +218,8 @@ describe("webhook repo filtering", () => { }); }); - describe("event type filter", () => { - it("ignores unsupported event types", async () => { + describe("이벤트 타입 필터", () => { + it("지원하지 않는 이벤트 타입은 무시한다", async () => { const request = makeRequest("push", { organization: { login: "DaleStudy" }, repository: { @@ -236,7 +236,7 @@ describe("webhook repo filtering", () => { }); }); -describe("handlePullRequestEvent — AI handler dispatch", () => { +describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { const basePRPayload = { action: "synchronize", organization: { login: "DaleStudy" }, @@ -264,7 +264,7 @@ describe("handlePullRequestEvent — AI handler dispatch", () => { }); }); - it("dispatches 2 self-fetches via ctx.waitUntil when OPENAI_API_KEY, INTERNAL_SECRET, and WORKER_URL are all set", async () => { + it("OPENAI_API_KEY, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { OPENAI_API_KEY: "fake-openai", @@ -294,7 +294,7 @@ describe("handlePullRequestEvent — AI handler dispatch", () => { expect(postLearningStatus).not.toHaveBeenCalled(); }); - it("falls back to in-process handler calls when INTERNAL_SECRET is not set", async () => { + it("INTERNAL_SECRET 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { OPENAI_API_KEY: "fake-openai", @@ -318,7 +318,7 @@ describe("handlePullRequestEvent — AI handler dispatch", () => { expect(prNumber).toBe(42); }); - it("falls back to in-process handler calls when WORKER_URL is not set", async () => { + it("WORKER_URL 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { OPENAI_API_KEY: "fake-openai", @@ -337,7 +337,7 @@ describe("handlePullRequestEvent — AI handler dispatch", () => { expect(postLearningStatus).toHaveBeenCalledTimes(1); }); - it("does not dispatch or call handlers when OPENAI_API_KEY is missing", async () => { + it("OPENAI_API_KEY 가 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { const ctx = makeCtx(); const env = { INTERNAL_SECRET: "fake-secret", diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index 88bf84f..c7c15c2 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -36,12 +36,12 @@ function okText(text) { }); } -describe("subrequest budget — per handler invocation (5 changed files)", () => { +describe("subrequest 예산 — 핸들러별 invocation (변경 파일 5개)", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("tagPatterns makes ≤ 50 subrequests (expected 22: 1 files + 1 list comments + 5 deletes + 5×(raw+openai+post))", async () => { + it("tagPatterns 는 50 회 이하 subrequest 를 호출한다 (예상 22: files 1 + 코멘트 목록 1 + DELETE 5 + 5×(raw+openai+post))", async () => { globalThis.fetch = vi.fn().mockImplementation((url, opts) => { const urlStr = typeof url === "string" ? url : url.url; const method = opts?.method ?? "GET"; @@ -110,7 +110,7 @@ describe("subrequest budget — per handler invocation (5 changed files)", () => expect(fetchCount).toBeLessThan(50); }); - it("postLearningStatus makes ≤ 50 subrequests (expected 15: 1 categories + 1 tree + 1 PR files + 5×(raw+openai) + 1 list issue comments + 1 post)", async () => { + it("postLearningStatus 는 50 회 이하 subrequest 를 호출한다 (예상 15: categories 1 + tree 1 + PR files 1 + 5×(raw+openai) + 이슈 코멘트 목록 1 + POST 1)", async () => { const categories = Object.fromEntries( SOLUTION_FILES.map((_, i) => [ `problem-${i + 1}`,