Skip to content

Commit ba017d9

Browse files
soobingnoreplyclaude
committed
perf: AI 핸들러를 별도 Worker 호출로 분리하여 subrequest 예산 독립화
self-fetch + ctx.waitUntil() 패턴으로 tagPatterns, learningStatus, complexityAnalysis 각각을 별도 Worker invocation에서 실행하여 50 subrequest 제한을 공유하지 않도록 개선. Co-Authored-By: sounmind <noreply@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4913987 commit ba017d9

File tree

4 files changed

+147
-20
lines changed

4 files changed

+147
-20
lines changed

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ wrangler secret put OPENAI_API_KEY
328328

329329
# Webhook Secret (선택사항)
330330
wrangler secret put WEBHOOK_SECRET
331+
332+
# Internal Dispatch Secret (AI 핸들러 Worker 분리용, 권장)
333+
# 설정하면 tagPatterns, learningStatus, complexityAnalysis가
334+
# 별도 Worker 호출로 디스패치되어 각각 독립적인 subrequest 예산을 가짐
335+
wrangler secret put INTERNAL_SECRET
331336
```
332337

333338
### 5. GitHub App 설치

handlers/internal-dispatch.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* 내부 디스패치 핸들러
3+
*
4+
* self-fetch를 통해 호출되는 내부 엔드포인트.
5+
* 각 핸들러가 별도 Worker 호출(invocation)에서 실행되므로
6+
* 독립적인 subrequest 예산(50)을 갖는다.
7+
*/
8+
9+
import { generateGitHubAppToken } from "../utils/github.js";
10+
import { errorResponse, corsResponse } from "../utils/cors.js";
11+
import { tagPatterns } from "./tag-patterns.js";
12+
import { postLearningStatus } from "./learning-status.js";
13+
14+
const INTERNAL_HEADER = "X-Internal-Secret";
15+
16+
/**
17+
* 내부 요청 인증 검증
18+
*/
19+
function verifyInternalRequest(request, env) {
20+
if (!env.INTERNAL_SECRET) {
21+
console.error("[internal-dispatch] INTERNAL_SECRET not configured");
22+
return false;
23+
}
24+
return request.headers.get(INTERNAL_HEADER) === env.INTERNAL_SECRET;
25+
}
26+
27+
/**
28+
* 내부 디스패치 엔드포인트 라우터
29+
*
30+
* @param {Request} request
31+
* @param {object} env
32+
* @param {string} pathname
33+
*/
34+
export async function handleInternalDispatch(request, env, pathname) {
35+
if (!verifyInternalRequest(request, env)) {
36+
return errorResponse("Unauthorized", 401);
37+
}
38+
39+
const payload = await request.json();
40+
41+
try {
42+
const appToken = await generateGitHubAppToken(env);
43+
44+
switch (pathname) {
45+
case "/internal/tag-patterns":
46+
return await handleTagPatterns(payload, appToken, env);
47+
48+
case "/internal/learning-status":
49+
return await handleLearningStatus(payload, appToken, env);
50+
51+
default:
52+
return errorResponse("Not found", 404);
53+
}
54+
} catch (error) {
55+
console.error(`[internal-dispatch] ${pathname} failed:`, error);
56+
return errorResponse(`Internal handler error: ${error.message}`, 500);
57+
}
58+
}
59+
60+
async function handleTagPatterns(payload, appToken, env) {
61+
const { repoOwner, repoName, prNumber, headSha, prData } = payload;
62+
const result = await tagPatterns(
63+
repoOwner,
64+
repoName,
65+
prNumber,
66+
headSha,
67+
prData,
68+
appToken,
69+
env.OPENAI_API_KEY
70+
);
71+
return corsResponse({ handler: "tag-patterns", result });
72+
}
73+
74+
async function handleLearningStatus(payload, appToken, env) {
75+
const { repoOwner, repoName, prNumber, username } = payload;
76+
const result = await postLearningStatus(
77+
repoOwner,
78+
repoName,
79+
prNumber,
80+
username,
81+
appToken,
82+
env.OPENAI_API_KEY
83+
);
84+
return corsResponse({ handler: "learning-status", result });
85+
}

handlers/webhooks.js

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { postLearningStatus } from "./learning-status.js";
2727
/**
2828
* GitHub webhook 이벤트 처리
2929
*/
30-
export async function handleWebhook(request, env) {
30+
export async function handleWebhook(request, env, ctx) {
3131
try {
3232
const payload = await request.json();
3333
const eventType = request.headers.get("X-GitHub-Event");
@@ -54,7 +54,7 @@ export async function handleWebhook(request, env) {
5454
return handleProjectsV2ItemEvent(payload, env);
5555

5656
case "pull_request":
57-
return handlePullRequestEvent(payload, env);
57+
return handlePullRequestEvent(payload, env, ctx);
5858

5959
case "issue_comment":
6060
return handleIssueCommentEvent(payload, env);
@@ -240,7 +240,7 @@ async function getChangedFilenames(repoOwner, repoName, baseSha, headSha, appTok
240240
* - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일)
241241
* - synchronize: 알고리즘 패턴 태깅만 (변경된 파일만, Week 체크 스킵)
242242
*/
243-
async function handlePullRequestEvent(payload, env) {
243+
async function handlePullRequestEvent(payload, env, ctx) {
244244
const action = payload.action;
245245

246246
// opened, reopened, synchronize 액션만 처리
@@ -284,8 +284,51 @@ async function handlePullRequestEvent(payload, env) {
284284
console.log(`PR synchronized: #${prNumber}`);
285285
}
286286

287-
// 알고리즘 패턴 태깅 (OPENAI_API_KEY 있을 때만)
288-
if (env.OPENAI_API_KEY) {
287+
// AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산)
288+
if (env.OPENAI_API_KEY && env.INTERNAL_SECRET) {
289+
const baseUrl = env.WORKER_URL || "https://github.daleseo.workers.dev";
290+
291+
const dispatchHeaders = {
292+
"Content-Type": "application/json",
293+
"X-Internal-Secret": env.INTERNAL_SECRET,
294+
};
295+
296+
const commonPayload = { repoOwner, repoName, prNumber };
297+
298+
// 패턴 태깅 디스패치
299+
ctx.waitUntil(
300+
fetch(`${baseUrl}/internal/tag-patterns`, {
301+
method: "POST",
302+
headers: dispatchHeaders,
303+
body: JSON.stringify({
304+
...commonPayload,
305+
headSha: pr.head.sha,
306+
prData: pr,
307+
}),
308+
}).catch((err) =>
309+
console.error(`[dispatch] tagPatterns failed: ${err.message}`)
310+
)
311+
);
312+
313+
// 학습 현황 디스패치
314+
ctx.waitUntil(
315+
fetch(`${baseUrl}/internal/learning-status`, {
316+
method: "POST",
317+
headers: dispatchHeaders,
318+
body: JSON.stringify({
319+
...commonPayload,
320+
username: pr.user.login,
321+
}),
322+
}).catch((err) =>
323+
console.error(`[dispatch] learningStatus failed: ${err.message}`)
324+
)
325+
);
326+
327+
console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`);
328+
} else if (env.OPENAI_API_KEY) {
329+
// INTERNAL_SECRET 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행)
330+
console.warn("[handlePullRequestEvent] INTERNAL_SECRET not set, running handlers in-process");
331+
289332
try {
290333
// synchronize일 때만 변경 파일 목록 추출 (최적화: #7)
291334
let changedFilenames = null;
@@ -314,24 +357,12 @@ async function handlePullRequestEvent(payload, env) {
314357
);
315358
} catch (error) {
316359
console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`);
317-
// 패턴 태깅 실패는 전체 흐름을 중단시키지 않음
318360
}
319-
}
320361

321-
// 학습 현황 댓글 (OPENAI_API_KEY 있을 때만)
322-
if (env.OPENAI_API_KEY) {
323362
try {
324-
await postLearningStatus(
325-
repoOwner,
326-
repoName,
327-
prNumber,
328-
pr.user.login,
329-
appToken,
330-
env.OPENAI_API_KEY
331-
);
363+
await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.OPENAI_API_KEY);
332364
} catch (error) {
333365
console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`);
334-
// 학습 현황 실패는 전체 흐름을 중단시키지 않음
335366
}
336367
}
337368

index.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66

77
import { checkWeeks } from "./handlers/check-weeks.js";
88
import { handleWebhook } from "./handlers/webhooks.js";
9+
import { handleInternalDispatch } from "./handlers/internal-dispatch.js";
910
import { approvePrs } from "./handlers/approve_prs.js";
1011
import { mergePrs } from "./handlers/merge_prs.js";
1112
import { preflightResponse, corsResponse, errorResponse } from "./utils/cors.js";
1213
import { verifyWebhookSignature } from "./utils/webhook.js";
1314

1415
export default {
15-
async fetch(request, env) {
16+
async fetch(request, env, ctx) {
1617
// Handle CORS preflight
1718
if (request.method === "OPTIONS") {
1819
return preflightResponse();
@@ -24,6 +25,11 @@ export default {
2425

2526
const url = new URL(request.url);
2627

28+
// 내부 디스패치 엔드포인트 (self-fetch로 호출, 별도 Worker 호출로 subrequest 분리)
29+
if (url.pathname.startsWith("/internal/")) {
30+
return handleInternalDispatch(request, env, url.pathname);
31+
}
32+
2733
// GitHub Webhook 수신
2834
if (url.pathname === "/webhooks") {
2935
// Webhook signature 검증
@@ -51,7 +57,7 @@ export default {
5157
body: rawBody,
5258
});
5359

54-
return handleWebhook(newRequest, env);
60+
return handleWebhook(newRequest, env, ctx);
5561
}
5662

5763
// PR Week 설정 검사 (수동 호출용)

0 commit comments

Comments
 (0)