diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js new file mode 100644 index 0000000..c983b1f --- /dev/null +++ b/handlers/tag-patterns.js @@ -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 = ""; +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 }; +} diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 23c5e5b..68135af 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -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 이벤트 처리 @@ -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}` }); } @@ -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, }); } diff --git a/utils/openai.js b/utils/openai.js index 6aff6b5..aadce73 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -83,3 +83,93 @@ ${prDiff} const data = await response.json(); return data.choices[0]?.message?.content || "Failed to generate review"; } + +/** + * 솔루션 파일의 알고리즘 패턴 분석 + * + * @param {string} fileContent - 분석할 소스 코드 내용 + * @param {string} problemName - 문제 이름 (폴더명) + * @param {string} apiKey - OpenAI API 키 + * @returns {Promise<{patterns: string[], description: string}>} + */ +export async function generatePatternAnalysis(fileContent, problemName, apiKey) { + const systemPrompt = `당신은 리트코드 문제 풀이의 알고리즘 패턴을 분석하는 전문가입니다. +주어진 소스 코드를 분석해서, 다음 패턴 목록 중 해당되는 것만 골라주세요. + +감지 대상 패턴: +- Two Pointers +- Sliding Window +- Fast & Slow Pointers +- BFS +- DFS +- Backtracking +- Dynamic Programming +- Binary Search +- Monotonic Stack +- Heap / Priority Queue +- Hash Map / Hash Set +- Greedy +- Divide and Conquer +- Union Find +- Trie +- Bit Manipulation + +반드시 JSON 객체로만 응답하세요. 형식: +{ + "patterns": ["패턴1", "패턴2"], + "description": "이 코드가 왜 해당 패턴에 속하는지 간단한 한국어 설명 (2-3문장)" +} + +규칙: +- patterns는 위 목록의 정확한 이름만 사용 +- 해당 패턴이 없으면 빈 배열 +- 일반적으로 1-3개 패턴이면 충분 +- description은 150자 이내`; + + const userPrompt = `# 문제 이름 +${problemName} + +# 소스 코드 +\`\`\` +${fileContent} +\`\`\` + +위 코드에 사용된 알고리즘 패턴을 분석해주세요.`; + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4.1-nano", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_tokens: 500, + temperature: 0.3, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${error}`); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + const parsed = JSON.parse(content); + + return { + patterns: Array.isArray(parsed.patterns) ? parsed.patterns : [], + description: typeof parsed.description === "string" ? parsed.description : "", + }; +}