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
227 changes: 227 additions & 0 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* 알고리즘 패턴 태깅 핸들러
*
* PR의 솔루션 파일들을 분석하여 사용된 알고리즘 패턴을
* 파일별 review comment로 남긴다.
*/

import { getGitHubHeaders } from "../utils/github.js";
import { hasMaintenanceLabel } from "../utils/validation.js";
import { generatePatternAnalysis } from "../utils/openai.js";

const COMMENT_MARKER = "<!-- dalestudy-pattern-tag -->";
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치)

/**
* PR의 솔루션 파일들에 알고리즘 패턴 태그 달기
*
* @param {string} repoOwner
* @param {string} repoName
* @param {number} prNumber
* @param {string} headSha - PR head commit SHA
* @param {object} prData - PR 객체 (draft, labels 포함)
* @param {string} appToken - GitHub App installation token
* @param {string} openaiApiKey
*/
export async function tagPatterns(
repoOwner,
repoName,
prNumber,
headSha,
prData,
appToken,
openaiApiKey
) {
// 2-1. Skip 조건
if (prData.draft === true) {
console.log(`[tagPatterns] Skipping PR #${prNumber}: draft`);
return { skipped: "draft" };
}

const labels = (prData.labels || []).map((l) => l.name);
if (hasMaintenanceLabel(labels)) {
console.log(`[tagPatterns] Skipping PR #${prNumber}: maintenance label`);
return { skipped: "maintenance" };
}

// 2-2. PR 변경 파일 목록 조회 + 필터링
const filesResponse = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
{ headers: getGitHubHeaders(appToken) }
);

if (!filesResponse.ok) {
throw new Error(
`Failed to list PR files: ${filesResponse.status} ${filesResponse.statusText}`
);
}

const allFiles = await filesResponse.json();
const solutionFiles = allFiles.filter(
(f) =>
(f.status === "added" || f.status === "modified") &&
SOLUTION_PATH_REGEX.test(f.filename)
);

console.log(
`[tagPatterns] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solution files`
);

if (solutionFiles.length === 0) {
return { skipped: "no-solution-files" };
}

// 2-3. 기존 Bot 패턴 태그 코멘트 삭제
await deletePreviousPatternComments(repoOwner, repoName, prNumber, appToken);

// 2-4. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑)
const results = [];
for (const file of solutionFiles) {
try {
const result = await tagSingleFile(
file,
repoOwner,
repoName,
prNumber,
headSha,
appToken,
openaiApiKey
);
results.push({ path: file.filename, ...result });
} catch (error) {
console.error(
`[tagPatterns] Failed to tag ${file.filename}: ${error.message}`
);
results.push({ path: file.filename, error: error.message });
}
}

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

/**
* 기존 Bot 패턴 태그 코멘트 삭제 (다른 사용자 코멘트는 절대 건드리지 않음)
*/
async function deletePreviousPatternComments(
repoOwner,
repoName,
prNumber,
appToken
) {
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 botPatternComments = comments.filter(
(c) => c.user?.type === "Bot" && c.body?.includes(COMMENT_MARKER)
);

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`
);
}

/**
* 단일 파일 분석 + 코멘트 작성
*/
async function tagSingleFile(
file,
repoOwner,
repoName,
prNumber,
headSha,
appToken,
openaiApiKey
) {
// 파일 내용 가져오기
const contentResponse = await fetch(file.raw_url);
if (!contentResponse.ok) {
throw new Error(`Failed to fetch raw content: ${contentResponse.status}`);
}

let fileContent = await contentResponse.text();
if (fileContent.length > MAX_FILE_SIZE) {
fileContent = fileContent.slice(0, MAX_FILE_SIZE);
console.log(
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars`
);
}

// 폴더명(=문제 이름) 추출
const problemName = file.filename.split("/")[0];

// OpenAI 패턴 분석
const analysis = await generatePatternAnalysis(
fileContent,
problemName,
openaiApiKey
);

// 코멘트 본문 작성
const patternsText =
analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음";
const body = `${COMMENT_MARKER}
### 🏷️ 알고리즘 패턴 분석

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

// 파일 단위 review comment 작성
const commentResponse = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments`,
{
method: "POST",
headers: {
...getGitHubHeaders(appToken),
"Content-Type": "application/json",
},
body: JSON.stringify({
body,
commit_id: headSha,
path: file.filename,
subject_type: "file",
}),
}
);

if (!commentResponse.ok) {
const errorText = await commentResponse.text();
throw new Error(
`Failed to post review comment: ${commentResponse.status} ${errorText}`
);
}

return { patterns: analysis.patterns };
}
60 changes: 44 additions & 16 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { ALLOWED_REPO } from "../utils/constants.js";
import { performAIReview, addReactionToComment } from "../utils/prReview.js";
import { hasApprovedReview, safeJson } from "../utils/prActions.js";
import { tagPatterns } from "./tag-patterns.js";

/**
* GitHub webhook 이벤트 처리
Expand Down Expand Up @@ -203,13 +204,15 @@ async function handleProjectsV2ItemEvent(payload, env) {
}

/**
* Pull Request 이벤트 처리 (PR 생성 시 즉시 체크)
* Pull Request 이벤트 처리
* - opened/reopened: Week 설정 체크 + 알고리즘 패턴 태깅
* - synchronize: 알고리즘 패턴 태깅만 (Week 체크 스킵 - 이미 설정됐을 가능성 높음)
*/
async function handlePullRequestEvent(payload, env) {
const action = payload.action;

// opened, reopened 액션만 처리
if (!["opened", "reopened"].includes(action)) {
// opened, reopened, synchronize 액션만 처리
if (!["opened", "reopened", "synchronize"].includes(action)) {
console.log(`Ignoring pull_request action: ${action}`);
return corsResponse({ message: `Ignored: ${action}` });
}
Expand All @@ -221,31 +224,56 @@ async function handlePullRequestEvent(payload, env) {
const repoName = payload.repository.name;
const prNumber = pr.number;

// maintenance 라벨 체크
// maintenance 라벨 체크 (early exit - GitHub API 호출 전에)
const labels = pr.labels.map((l) => l.name);
if (hasMaintenanceLabel(labels)) {
console.log(`Skipping PR #${prNumber}: has maintenance label`);
return corsResponse({ message: "Ignored: maintenance label" });
}

console.log(`New PR opened: #${prNumber}`);
const appToken = await generateGitHubAppToken(env);
let weekValue = null;

// Week 설정 확인 및 댓글 작성 (아직 Week 설정 안 되어 있을 가능성 높음)
// 잠시 대기 후 체크 (프로젝트 추가 시간 고려)
await new Promise((resolve) => setTimeout(resolve, 3000));
// Week 체크는 opened/reopened일 때만 (synchronize는 이미 설정됐을 가능성 높음)
if (action === "opened" || action === "reopened") {
console.log(`New PR ${action}: #${prNumber}`);

const appToken = await generateGitHubAppToken(env);
const weekValue = await handleWeekComment(
repoOwner,
repoName,
prNumber,
env,
appToken
);
// 잠시 대기 후 체크 (프로젝트 추가 시간 고려)
await new Promise((resolve) => setTimeout(resolve, 3000));

weekValue = await handleWeekComment(
repoOwner,
repoName,
prNumber,
env,
appToken
);
} else {
console.log(`PR synchronized: #${prNumber}`);
}

// 알고리즘 패턴 태깅 (OPENAI_API_KEY 있을 때만)
if (env.OPENAI_API_KEY) {
try {
await tagPatterns(
repoOwner,
repoName,
prNumber,
pr.head.sha,
pr,
appToken,
env.OPENAI_API_KEY
);
} catch (error) {
console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`);
// 패턴 태깅 실패는 전체 흐름을 중단시키지 않음
}
}

return corsResponse({
message: "Processed",
pr: prNumber,
action,
week: weekValue,
});
}
Expand Down
Loading