Skip to content
Merged
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` 20회, `postLearningStatus` 31회)를 회귀 테스트로 박아둔다.

## 보안 및 권한

Expand Down
8 changes: 7 additions & 1 deletion handlers/internal-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ 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,
} = payload;
const result = await tagPatterns(
repoOwner,
repoName,
Expand Down
235 changes: 145 additions & 90 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
* 파일별 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 {
parseFileShaMarker,
renderFileShaMarker,
} from "../utils/commentMarker.js";
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 +28,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 All @@ -35,7 +40,6 @@ const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치)
* @param {object} prData - PR 객체 (draft, labels 포함)
* @param {string} appToken - GitHub App installation token
* @param {string} openaiApiKey
* @param {string[]|null} [changedFilenames=null] - synchronize 시 변경된 파일명 목록 (null이면 전체 분석)
*/
export async function tagPatterns(
repoOwner,
Expand All @@ -44,8 +48,7 @@ export async function tagPatterns(
headSha,
prData,
appToken,
openaiApiKey,
changedFilenames = null
openaiApiKey
) {
// 2-1. Skip 조건
if (prData.draft === true) {
Expand Down Expand Up @@ -78,13 +81,20 @@ export async function tagPatterns(
SOLUTION_PATH_REGEX.test(f.filename)
);

// changedFilenames가 제공되면 해당 파일만 대상으로 좁힘 (synchronize 최적화)
if (changedFilenames !== null) {
const changedSet = new Set(changedFilenames);
solutionFiles = solutionFiles.filter((f) => changedSet.has(f.filename));
console.log(
`[tagPatterns] PR #${prNumber}: narrowed to ${solutionFiles.length} changed solution files`
);
if (solutionFiles.length === 0) {
return { skipped: "no-solution-files" };
}

solutionFiles = await getUnanalyzedOrChangedFiles(
repoOwner,
repoName,
prNumber,
appToken,
solutionFiles
);

if (solutionFiles === null) {
return { skipped: "review-comments-unavailable" };
}

console.log(
Expand All @@ -95,23 +105,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 +138,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 +162,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 @@ -231,7 +180,9 @@ async function tagSingleFile(
const patternsText =
analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음";
let body = `${COMMENT_MARKER}
### 🏷️ 알고리즘 패턴 분석
${file.sha ? `${renderFileShaMarker(file.sha)}\n` : ""}### 🏷️ 알고리즘 패턴 분석

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

- **패턴**: ${patternsText}
- **설명**: ${analysis.description || "(설명 없음)"}`;
Expand Down Expand Up @@ -273,6 +224,108 @@ 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 thread
DaleSeo marked this conversation as resolved.
}

async function fetchReviewCommentsNewestFirst(
repoOwner,
repoName,
prNumber,
appToken
) {
try {
const response = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?sort=created&direction=desc&per_page=100`,
{ headers: getGitHubHeaders(appToken) }
);

if (!response.ok) {
throw new Error(`GitHub API responded with ${response.status}`);
}

return await response.json();
} catch (error) {
console.error(
`[tagPatterns] Failed to load review comments: ${error.message}`
);
return null;
}
}

function extractLatestAnalyzedFileShas(commentsNewestFirst) {
const latestAnalyzedFileShas = new Map();

for (const comment of commentsNewestFirst) {
const hasLatestAnalysisForFile = latestAnalyzedFileShas.has(comment.path);
const isTopLevelComment = comment.in_reply_to_id == null;
const isBotPatternAnalysisComment =
comment.user?.type === "Bot" &&
comment.body?.includes(COMMENT_MARKER);

if (
!hasLatestAnalysisForFile &&
isTopLevelComment &&
isBotPatternAnalysisComment
) {
latestAnalyzedFileShas.set(
comment.path,
parseFileShaMarker(comment.body)
);
}
}

return latestAnalyzedFileShas;
}

/**
* 이전에 분석하지 않았거나 파일 내용이 변경된 파일만 반환한다.
* 기존 분석 댓글을 조회하지 못하면 null을 반환한다.
*/
async function getUnanalyzedOrChangedFiles(
repoOwner,
repoName,
prNumber,
appToken,
solutionFiles
) {
const reviewCommentsNewestFirst = await fetchReviewCommentsNewestFirst(
repoOwner,
repoName,
prNumber,
appToken
);

if (reviewCommentsNewestFirst === null) {
return null;
}

const latestAnalyzedFileShas = extractLatestAnalyzedFileShas(
reviewCommentsNewestFirst
);

function needsAnalysis(file) {
const latestAnalyzedFileSha = latestAnalyzedFileShas.get(file.filename);
const isFileShaMissing = file.sha == null;
const hasFileChanged = file.sha !== latestAnalyzedFileSha;

return isFileShaMissing || hasFileChanged;
}

return solutionFiles.filter(needsAnalysis);
}

/**
* 솔루션 파일들의 raw 내용을 한 번에 다운로드한다.
* 패턴 분석 + 복잡도 분석이 같은 fileEntries 를 공유한다.
Expand All @@ -287,16 +340,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
Loading