Skip to content

Commit c11c641

Browse files
authored
Merge pull request #5 from DaleStudy/4-알고리즘-패턴-태깅
PR 파일별 알고리즘 패턴 태깅 추가 (#4)
2 parents 1bdc1c6 + 42e8778 commit c11c641

3 files changed

Lines changed: 361 additions & 16 deletions

File tree

handlers/tag-patterns.js

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* 알고리즘 패턴 태깅 핸들러
3+
*
4+
* PR의 솔루션 파일들을 분석하여 사용된 알고리즘 패턴을
5+
* 파일별 review comment로 남긴다.
6+
*/
7+
8+
import { getGitHubHeaders } from "../utils/github.js";
9+
import { hasMaintenanceLabel } from "../utils/validation.js";
10+
import { generatePatternAnalysis } from "../utils/openai.js";
11+
12+
const COMMENT_MARKER = "<!-- dalestudy-pattern-tag -->";
13+
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
14+
const MAX_FILE_SIZE = 20000; // 20K 문자 제한 (OpenAI 토큰 안전장치)
15+
16+
/**
17+
* PR의 솔루션 파일들에 알고리즘 패턴 태그 달기
18+
*
19+
* @param {string} repoOwner
20+
* @param {string} repoName
21+
* @param {number} prNumber
22+
* @param {string} headSha - PR head commit SHA
23+
* @param {object} prData - PR 객체 (draft, labels 포함)
24+
* @param {string} appToken - GitHub App installation token
25+
* @param {string} openaiApiKey
26+
*/
27+
export async function tagPatterns(
28+
repoOwner,
29+
repoName,
30+
prNumber,
31+
headSha,
32+
prData,
33+
appToken,
34+
openaiApiKey
35+
) {
36+
// 2-1. Skip 조건
37+
if (prData.draft === true) {
38+
console.log(`[tagPatterns] Skipping PR #${prNumber}: draft`);
39+
return { skipped: "draft" };
40+
}
41+
42+
const labels = (prData.labels || []).map((l) => l.name);
43+
if (hasMaintenanceLabel(labels)) {
44+
console.log(`[tagPatterns] Skipping PR #${prNumber}: maintenance label`);
45+
return { skipped: "maintenance" };
46+
}
47+
48+
// 2-2. PR 변경 파일 목록 조회 + 필터링
49+
const filesResponse = await fetch(
50+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
51+
{ headers: getGitHubHeaders(appToken) }
52+
);
53+
54+
if (!filesResponse.ok) {
55+
throw new Error(
56+
`Failed to list PR files: ${filesResponse.status} ${filesResponse.statusText}`
57+
);
58+
}
59+
60+
const allFiles = await filesResponse.json();
61+
const solutionFiles = allFiles.filter(
62+
(f) =>
63+
(f.status === "added" || f.status === "modified") &&
64+
SOLUTION_PATH_REGEX.test(f.filename)
65+
);
66+
67+
console.log(
68+
`[tagPatterns] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solution files`
69+
);
70+
71+
if (solutionFiles.length === 0) {
72+
return { skipped: "no-solution-files" };
73+
}
74+
75+
// 2-3. 기존 Bot 패턴 태그 코멘트 삭제
76+
await deletePreviousPatternComments(repoOwner, repoName, prNumber, appToken);
77+
78+
// 2-4. 파일별 OpenAI 분석 + 코멘트 작성 (각 파일 try/catch 래핑)
79+
const results = [];
80+
for (const file of solutionFiles) {
81+
try {
82+
const result = await tagSingleFile(
83+
file,
84+
repoOwner,
85+
repoName,
86+
prNumber,
87+
headSha,
88+
appToken,
89+
openaiApiKey
90+
);
91+
results.push({ path: file.filename, ...result });
92+
} catch (error) {
93+
console.error(
94+
`[tagPatterns] Failed to tag ${file.filename}: ${error.message}`
95+
);
96+
results.push({ path: file.filename, error: error.message });
97+
}
98+
}
99+
100+
return { tagged: results.filter((r) => !r.error).length, results };
101+
}
102+
103+
/**
104+
* 기존 Bot 패턴 태그 코멘트 삭제 (다른 사용자 코멘트는 절대 건드리지 않음)
105+
*/
106+
async function deletePreviousPatternComments(
107+
repoOwner,
108+
repoName,
109+
prNumber,
110+
appToken
111+
) {
112+
const response = await fetch(
113+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?per_page=100`,
114+
{ headers: getGitHubHeaders(appToken) }
115+
);
116+
117+
if (!response.ok) {
118+
console.error(
119+
`[tagPatterns] Failed to fetch review comments: ${response.status}`
120+
);
121+
return;
122+
}
123+
124+
const comments = await response.json();
125+
const botPatternComments = comments.filter(
126+
(c) => c.user?.type === "Bot" && c.body?.includes(COMMENT_MARKER)
127+
);
128+
129+
for (const comment of botPatternComments) {
130+
try {
131+
const deleteResponse = await fetch(
132+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/comments/${comment.id}`,
133+
{
134+
method: "DELETE",
135+
headers: getGitHubHeaders(appToken),
136+
}
137+
);
138+
139+
if (!deleteResponse.ok) {
140+
console.error(
141+
`[tagPatterns] Failed to delete comment ${comment.id}: ${deleteResponse.status}`
142+
);
143+
}
144+
} catch (error) {
145+
console.error(
146+
`[tagPatterns] Error deleting comment ${comment.id}: ${error.message}`
147+
);
148+
}
149+
}
150+
151+
console.log(
152+
`[tagPatterns] Deleted ${botPatternComments.length} previous pattern comments`
153+
);
154+
}
155+
156+
/**
157+
* 단일 파일 분석 + 코멘트 작성
158+
*/
159+
async function tagSingleFile(
160+
file,
161+
repoOwner,
162+
repoName,
163+
prNumber,
164+
headSha,
165+
appToken,
166+
openaiApiKey
167+
) {
168+
// 파일 내용 가져오기
169+
const contentResponse = await fetch(file.raw_url);
170+
if (!contentResponse.ok) {
171+
throw new Error(`Failed to fetch raw content: ${contentResponse.status}`);
172+
}
173+
174+
let fileContent = await contentResponse.text();
175+
if (fileContent.length > MAX_FILE_SIZE) {
176+
fileContent = fileContent.slice(0, MAX_FILE_SIZE);
177+
console.log(
178+
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars`
179+
);
180+
}
181+
182+
// 폴더명(=문제 이름) 추출
183+
const problemName = file.filename.split("/")[0];
184+
185+
// OpenAI 패턴 분석
186+
const analysis = await generatePatternAnalysis(
187+
fileContent,
188+
problemName,
189+
openaiApiKey
190+
);
191+
192+
// 코멘트 본문 작성
193+
const patternsText =
194+
analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "감지된 패턴 없음";
195+
const body = `${COMMENT_MARKER}
196+
### 🏷️ 알고리즘 패턴 분석
197+
198+
- **패턴**: ${patternsText}
199+
- **설명**: ${analysis.description || "(설명 없음)"}`;
200+
201+
// 파일 단위 review comment 작성
202+
const commentResponse = await fetch(
203+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments`,
204+
{
205+
method: "POST",
206+
headers: {
207+
...getGitHubHeaders(appToken),
208+
"Content-Type": "application/json",
209+
},
210+
body: JSON.stringify({
211+
body,
212+
commit_id: headSha,
213+
path: file.filename,
214+
subject_type: "file",
215+
}),
216+
}
217+
);
218+
219+
if (!commentResponse.ok) {
220+
const errorText = await commentResponse.text();
221+
throw new Error(
222+
`Failed to post review comment: ${commentResponse.status} ${errorText}`
223+
);
224+
}
225+
226+
return { patterns: analysis.patterns };
227+
}

handlers/webhooks.js

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { ALLOWED_REPO } from "../utils/constants.js";
2222
import { performAIReview, addReactionToComment } from "../utils/prReview.js";
2323
import { hasApprovedReview, safeJson } from "../utils/prActions.js";
24+
import { tagPatterns } from "./tag-patterns.js";
2425

2526
/**
2627
* GitHub webhook 이벤트 처리
@@ -203,13 +204,15 @@ async function handleProjectsV2ItemEvent(payload, env) {
203204
}
204205

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

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

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

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

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

237-
const appToken = await generateGitHubAppToken(env);
238-
const weekValue = await handleWeekComment(
239-
repoOwner,
240-
repoName,
241-
prNumber,
242-
env,
243-
appToken
244-
);
241+
// 잠시 대기 후 체크 (프로젝트 추가 시간 고려)
242+
await new Promise((resolve) => setTimeout(resolve, 3000));
243+
244+
weekValue = await handleWeekComment(
245+
repoOwner,
246+
repoName,
247+
prNumber,
248+
env,
249+
appToken
250+
);
251+
} else {
252+
console.log(`PR synchronized: #${prNumber}`);
253+
}
254+
255+
// 알고리즘 패턴 태깅 (OPENAI_API_KEY 있을 때만)
256+
if (env.OPENAI_API_KEY) {
257+
try {
258+
await tagPatterns(
259+
repoOwner,
260+
repoName,
261+
prNumber,
262+
pr.head.sha,
263+
pr,
264+
appToken,
265+
env.OPENAI_API_KEY
266+
);
267+
} catch (error) {
268+
console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`);
269+
// 패턴 태깅 실패는 전체 흐름을 중단시키지 않음
270+
}
271+
}
245272

246273
return corsResponse({
247274
message: "Processed",
248275
pr: prNumber,
276+
action,
249277
week: weekValue,
250278
});
251279
}

0 commit comments

Comments
 (0)