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
96 changes: 23 additions & 73 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
* 파일별 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";
Expand Down Expand Up @@ -95,23 +95,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,74 +128,14 @@ 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`
);
}

/**
* 단일 파일 분석 + 코멘트 작성
*
Expand Down Expand Up @@ -233,6 +167,8 @@ async function tagSingleFile(
let body = `${COMMENT_MARKER}
### 🏷️ 알고리즘 패턴 분석

${renderAnalyzedSource(file.filename, fileContent)}

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.

원본 소스코드를 보여주는 아이디어 좋네요!
코드 변경 이후의 히스토리 저장 측면에서도 유용하다고 생각합니다.
다만 여기서 더 나아가 diff를 보여준다던지 하는 건 없을까요? 어디서 어떻게 변경됐는지도 알 수 있으면 좋겠다 생각했기 떄문이에요.
그러면 깃허브에서 자동으로 제공하는 diff 링크 같은 게 있을까도 궁금합니다.
아래 코멘트에서 언급된 rebase로 인한 force push 업데이트에서도 깃허브에서 변경사항을 보여주는 링크가 제공되기에 해결할 수 있는 방법 중 하나가 되지 않을까 생각해봤습니다.


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

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

function renderAnalyzedSource(filename, content) {
const language = filename.includes(".") ? filename.split(".").pop() : "";
const truncated = content.length >= MAX_FILE_SIZE ? "\n... (이하 생략)" : "";

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.

downloadFileEntries> MAX_FILE_SIZE일 때만 자르는데 여기서는 >= MAX_FILE_SIZE로 판정하네요. 의도하신 건가요?


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

\`\`\`${language}
${content}${truncated}
\`\`\`

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

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 블록이 깨지지는 않겠죠?

}

/**
* 솔루션 파일들의 raw 내용을 한 번에 다운로드한다.
* 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다.
Expand Down
52 changes: 37 additions & 15 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,28 @@ 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;
}

let changedFilenames = null;

try {
changedFilenames = await getChangedFilenames(
repoOwner,
repoName,
payload.before,
payload.after,
appToken
);
} catch (error) {
console.error(`[resolveChangedFilenames] failed: ${error.message}`);
}

return changedFilenames;
Comment on lines +243 to +257

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.

이렇게 살짝 다르게 작성하는 패턴에 대해서는 어떻게 생각하세요? 실패하면 null이 반환되는 부분이 더 쉽게 읽힐 수 있거든요.

Suggested change
let changedFilenames = null;
try {
changedFilenames = await getChangedFilenames(
repoOwner,
repoName,
payload.before,
payload.after,
appToken
);
} catch (error) {
console.error(`[resolveChangedFilenames] failed: ${error.message}`);
}
return changedFilenames;
try {
return await getChangedFilenames(
repoOwner,
repoName,
payload.before,
payload.after,
appToken
);
} catch (error) {
console.error(`[resolveChangedFilenames] failed: ${error.message}`);
return null;
}

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.

저도 이 방식이 더 직관적으로 보이네요!

}
Comment on lines +238 to +258

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 +310,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 +333,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 +359,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
44 changes: 43 additions & 1 deletion handlers/webhooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ describe("webhook 저장소 필터링", () => {
describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
const basePRPayload = {
action: "synchronize",
before: "base-sha",
after: "head-sha",
organization: { login: "DaleStudy" },
repository: {
name: "leetcode-study",
Expand All @@ -260,7 +262,13 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
vi.clearAllMocks();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ files: [] }),
json: () =>
Promise.resolve({
files: [
{ filename: "two-sum/testuser.js" },
{ filename: "valid-parentheses/testuser.js" },
],
}),
});
});

Expand Down Expand Up @@ -291,6 +299,10 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
url.endsWith("/internal/tag-patterns")
);
expect(dispatchCall[1].headers["X-Internal-Secret"]).toBe("fake-secret");
expect(JSON.parse(dispatchCall[1].body).changedFilenames).toEqual([
"two-sum/testuser.js",
"valid-parentheses/testuser.js",
]);

expect(tagPatterns).not.toHaveBeenCalled();
expect(postLearningStatus).not.toHaveBeenCalled();
Expand Down Expand Up @@ -357,4 +369,34 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
expect(tagPatterns).not.toHaveBeenCalled();
expect(postLearningStatus).not.toHaveBeenCalled();
});

it("변경 파일 목록 조회에 실패하면 null로 폴백한다", async () => {
const ctx = makeCtx();
const env = {
OPENAI_API_KEY: "fake-openai",
INTERNAL_SECRET: "fake-secret",
WORKER_URL: "https://worker.test",
};

globalThis.fetch = vi.fn().mockImplementation((url) => {
if (url.includes("/compare/")) {
return Promise.reject(new Error("network failure"));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
});

await handleWebhook(
makeRequest("pull_request", basePRPayload),
env,
ctx
);

const tagPatternsDispatch = globalThis.fetch.mock.calls.find(([url]) =>
url.endsWith("/internal/tag-patterns")
);
expect(tagPatternsDispatch).toBeDefined();

const payload = JSON.parse(tagPatternsDispatch[1].body);
expect(payload.changedFilenames).toBeNull();
});
});
Loading