Skip to content

Commit 42e8778

Browse files
sounmindjylee2033claude
committed
feat: add algorithm pattern tagging for PR files (#4)
Analyze solution files with OpenAI and post per-file review comments tagging the algorithm patterns used (Two Pointers, DP, BFS/DFS, etc.). - utils/openai.js: add generatePatternAnalysis() using gpt-4.1-nano with JSON response format - handlers/tag-patterns.js: new handler that filters solution files, replaces stale bot comments, and posts pattern analysis per file - handlers/webhooks.js: handle synchronize action and skip week check on re-push to avoid unnecessary latency - wrap per-file analysis in try/catch so one failure does not block other files Closes #4 Co-Authored-By: jylee2033 <jylee2033@gmail.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1bdc1c6 commit 42e8778

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)