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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ GitHub webhook

- `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회)를 회귀 테스트로 박아둔다.
- 참고: `tests/subrequest-budget.test.js`가 5개 파일 변경 시나리오에서 각 핸들러의 fetch 호출 수(`tagPatterns` 19회, `postLearningStatus` 31회)를 회귀 테스트로 박아둔다.

## 보안 및 권한

Expand Down
12 changes: 10 additions & 2 deletions handlers/internal-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,23 @@ export async function handleInternalDispatch(request, env, pathname) {
}

async function handleTagPatterns(payload, appToken, env) {
const { repoOwner, repoName, prNumber, headSha, prData } = payload;
const {
repoOwner,
repoName,
prNumber,
headSha,
prData,
changedFilenames,
} = payload;
const result = await tagPatterns(
repoOwner,
repoName,
prNumber,
headSha,
prData,
appToken,
env.OPENAI_API_KEY
env.OPENAI_API_KEY,
changedFilenames
);
return corsResponse({ handler: "tag-patterns", result });
}
Expand Down
8 changes: 7 additions & 1 deletion handlers/internal-dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ describe("handleInternalDispatch — 라우팅", () => {

it("/internal/tag-patterns 요청을 tagPatterns 로 payload 필드와 함께 라우팅한다", async () => {
const prData = { number: 42, head: { sha: "abc123" } };
const changedFilenames = [
"two-sum/testuser.js",
"valid-parentheses/testuser.js",
];
const request = makeRequest("/internal/tag-patterns", {
secret: VALID_SECRET,
body: {
Expand All @@ -104,6 +108,7 @@ describe("handleInternalDispatch — 라우팅", () => {
prNumber: 42,
headSha: "abc123",
prData,
changedFilenames,
},
});

Expand All @@ -123,7 +128,8 @@ describe("handleInternalDispatch — 라우팅", () => {
"abc123",
prData,
"fake-token",
"fake-openai"
"fake-openai",
changedFilenames
);
expect(postLearningStatus).not.toHaveBeenCalled();
});
Expand Down
117 changes: 38 additions & 79 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
* 파일별 review comment로 남긴다. 복잡도 분석은 패턴 분석 루프와 병렬로
* OpenAI 1콜에서 모든 파일을 한 번에 처리하고, 그 결과를 파일별 댓글
* 본문에 한 섹션 더 붙이는 형태로 묻어간다.
* 재실행 시 기존 패턴 댓글과 답글은 보존하고, 변경된 파일에 새 분석 댓글을 추가한다.
*
* 주의: 솔루션 파일이 12개를 넘으면 subrequest 한도(50)에 가까워진다.
* 기존 패턴 태깅 자체도 13파일 이상에서 한도를 넘는 cliff 가 있으니
* 복잡도 합본은 그 cliff 를 1파일분(13→12) 당기는 정도다.
* 주의: Workers Free 플랜의 외부 subrequest 한도는 invocation당 50회이므로 준수해야 한다.
* @see https://developers.cloudflare.com/workers/platform/limits/#subrequests
*/

import { getGitHubHeaders } from "../utils/github.js";
import { createCodeFence } from "../utils/markdown.js";
import { hasMaintenanceLabel } from "../utils/validation.js";
import { generatePatternAnalysis } from "../utils/openai.js";
import {
Expand All @@ -23,7 +24,7 @@ const COMMENT_MARKER = "<!-- dalestudy-pattern-tag -->";
// 레거시 단독 복잡도 issue comment 식별용. 새 합본 댓글에는 박지 않는다.
const LEGACY_COMPLEXITY_MARKER = "<!-- dalestudy-complexity-analysis -->";
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치)
const MAX_FILE_CONTENT_LENGTH = 20000; // OpenAI 입력 크기 안전장치

/**
* PR의 솔루션 파일들에 알고리즘 패턴 태그 달기
Expand Down Expand Up @@ -95,23 +96,17 @@ export async function tagPatterns(
return { skipped: "no-solution-files" };
}

// 2-3. 기존 Bot 패턴 태그 코멘트 삭제 (변경 파일만)
const targetFilenames = solutionFiles.map((f) => f.filename);
await deletePreviousPatternComments(
repoOwner, repoName, prNumber, appToken, targetFilenames
);

// 2-4. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유)
// 2-3. 모든 파일 raw 다운로드 (한 번만, 복잡도 분석과 공유)
const fileEntries = await downloadFileEntries(solutionFiles);

// 2-5. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성.
// 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 댓글은 작성.
const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey)
.catch((err) => {
console.error(`[tagPatterns] complexity analysis failed: ${err.message}`);
return [];
});

// 2-6. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑)
// 2-5. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑)
const results = [];
for (const fe of fileEntries) {
try {
Expand All @@ -134,78 +129,18 @@ export async function tagPatterns(
}
}

// 2-7. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제
// 2-6. 마이그레이션: 구버전이 남긴 단독 복잡도 issue comment 가 있으면 삭제
await deleteLegacyComplexityIssueComment(
repoOwner, repoName, prNumber, appToken
);

return { tagged: results.filter((r) => !r.error).length, results };
}

/**
* 기존 Bot 패턴 태그 코멘트 삭제 (대상 파일만, 다른 사용자 코멘트는 절대 건드리지 않음)
*
* @param {string[]} targetFilenames - 삭제 대상 파일명 목록
*/
async function deletePreviousPatternComments(
repoOwner,
repoName,
prNumber,
appToken,
targetFilenames
) {
const response = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?per_page=100`,
{ headers: getGitHubHeaders(appToken) }
);

if (!response.ok) {
console.error(
`[tagPatterns] Failed to fetch review comments: ${response.status}`
);
return;
}

const comments = await response.json();
const targetSet = new Set(targetFilenames);
const botPatternComments = comments.filter(
(c) =>
c.user?.type === "Bot" &&
c.body?.includes(COMMENT_MARKER) &&
targetSet.has(c.path)
);

for (const comment of botPatternComments) {
try {
const deleteResponse = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/comments/${comment.id}`,
{
method: "DELETE",
headers: getGitHubHeaders(appToken),
}
);

if (!deleteResponse.ok) {
console.error(
`[tagPatterns] Failed to delete comment ${comment.id}: ${deleteResponse.status}`
);
}
} catch (error) {
console.error(
`[tagPatterns] Error deleting comment ${comment.id}: ${error.message}`
);
}
}

console.log(
`[tagPatterns] Deleted ${botPatternComments.length} previous pattern comments for ${targetFilenames.length} files`
);
}

/**
* 단일 파일 분석 + 코멘트 작성
*
* @param {{file: object, problemName: string, content: string}} fileEntry
* @param {{file: object, problemName: string, content: string, isContentTruncated: boolean}} fileEntry
* @param {Promise<Array>} complexityPromise - 모든 파일의 복잡도 분석 결과 (병렬 진행)
*/
async function tagSingleFile(
Expand All @@ -218,7 +153,12 @@ async function tagSingleFile(
appToken,
openaiApiKey
) {
const { file, problemName, content: fileContent } = fileEntry;
const {
file,
problemName,
content: fileContent,
isContentTruncated,
} = fileEntry;

// OpenAI 패턴 분석
const analysis = await generatePatternAnalysis(
Expand All @@ -233,6 +173,8 @@ async function tagSingleFile(
let body = `${COMMENT_MARKER}
### 🏷️ 알고리즘 패턴 분석

${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)}

- **패턴**: ${patternsText}
- **설명**: ${analysis.description || "(설명 없음)"}`;

Expand Down Expand Up @@ -273,6 +215,21 @@ async function tagSingleFile(
return { patterns: analysis.patterns };
}

function renderAnalyzedSource(filename, content, isContentTruncated) {
const language = filename.includes(".") ? filename.split(".").pop() : "";
const truncationNotice = isContentTruncated ? "\n... (이하 생략)" : "";
const codeFence = createCodeFence(content);

return `<details>
<summary>${filename}</summary>

${codeFence}${language}
${content}${truncationNotice}
${codeFence}

</details>`;
Comment on lines +223 to +230

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

풀이 파일의 주석이나 docstring에 ```이 포함되면 혹시 details 블록이 깨지지는 않겠죠?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

``` 포함되면 깨져서 파일 내용의 백틱을 확인해서 긴 fense를 사용하도록 수정해주었습니다. 0a3e5d8

- 기존 변경
이미지 image image
링크 DaleStudy-test/leetcode-study#2 (comment) DaleStudy-test/leetcode-study#2 (comment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 조치 감사합니다. 그런데 공유해주신 커밋은 리베이스 과정에서 유실된 것 같습니다. (개인적으로 커밋 링크 공유는 의도한 것과 같은 효과가 나기는 힘들 것 같더라고요. 🙂)

2026-08-02 at 10 20 42

}

/**
* 솔루션 파일들의 raw 내용을 한 번에 다운로드한다.
* 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다.
Expand All @@ -287,16 +244,18 @@ async function downloadFileEntries(solutionFiles) {
);
}
let content = await res.text();
if (content.length > MAX_FILE_SIZE) {
content = content.slice(0, MAX_FILE_SIZE);
const isContentTruncated = content.length > MAX_FILE_CONTENT_LENGTH;
if (isContentTruncated) {
content = content.slice(0, MAX_FILE_CONTENT_LENGTH);
console.log(
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars`
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_CONTENT_LENGTH} chars`
);
}
return {
file,
problemName: file.filename.split("/")[0],
content,
isContentTruncated,
};
})
);
Expand Down
49 changes: 34 additions & 15 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,25 @@ async function getChangedFilenames(repoOwner, repoName, baseSha, headSha, appTok
return (data.files || []).map((f) => f.filename);
}

async function resolveChangedFilenames(payload, repoOwner, repoName, appToken) {
if (payload.action !== "synchronize" || !payload.before || !payload.after) {
return null;
}

try {
return await getChangedFilenames(
repoOwner,
repoName,
payload.before,
payload.after,
appToken
);
} catch (error) {
console.error(`[resolveChangedFilenames] failed: ${error.message}`);
return null;
}
}
Comment on lines +238 to +255

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일반 push는 beforeafter의 조상이라 정확하지만, 충돌 해결 등으로 rebase 후 강제 푸시하면 merge base가 브랜치 분기점까지 내려가서 PR의 모든 풀이 파일이 changedFilenames에 포함도지 않을까요? 삭제 로직이 있을 때는
지우고 다시 다니 상관없었는데 이제는 전 파일에 댓글이 한 벌씩 더 쌓이게 될텐데 새로운 엣지 케이스가 우려가 되네요.


/**
* Pull Request 이벤트 처리
* - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅 (전체 파일)
Expand Down Expand Up @@ -288,6 +307,13 @@ async function handlePullRequestEvent(payload, env, ctx) {
if (env.OPENAI_API_KEY && env.INTERNAL_SECRET && env.WORKER_URL) {
const baseUrl = env.WORKER_URL;

const changedFilenames = await resolveChangedFilenames(
payload,
repoOwner,
repoName,
appToken
);

const dispatchHeaders = {
"Content-Type": "application/json",
"X-Internal-Secret": env.INTERNAL_SECRET,
Expand All @@ -304,6 +330,7 @@ async function handlePullRequestEvent(payload, env, ctx) {
...commonPayload,
headSha: pr.head.sha,
prData: pr,
changedFilenames,
}),
}).catch((err) =>
console.error(`[dispatch] tagPatterns failed: ${err.message}`)
Expand All @@ -329,22 +356,14 @@ async function handlePullRequestEvent(payload, env, ctx) {
// INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행)
console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process");

try {
// synchronize일 때만 변경 파일 목록 추출 (최적화: #7)
let changedFilenames = null;
if (action === "synchronize" && payload.before && payload.after) {
changedFilenames = await getChangedFilenames(
repoOwner,
repoName,
payload.before,
payload.after,
appToken
);
console.log(
`[handlePullRequestEvent] synchronize: ${changedFilenames?.length ?? "fallback(all)"} files changed between ${payload.before.slice(0, 7)}...${payload.after.slice(0, 7)}`
);
}
const changedFilenames = await resolveChangedFilenames(
payload,
repoOwner,
repoName,
appToken
);

try {
await tagPatterns(
repoOwner,
repoName,
Expand Down
Loading