Skip to content

Commit d51353a

Browse files
authored
Merge pull request #14 from DaleStudy/8-big-o
[feat/#8] μ‹œκ°„/곡간 λ³΅μž‘λ„ μžλ™ 뢄석
2 parents 2a47983 + d0f54ef commit d51353a

File tree

3 files changed

+426
-0
lines changed

3 files changed

+426
-0
lines changed
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* μ‹œκ°„/곡간 λ³΅μž‘λ„ μžλ™ 뢄석 ν•Έλ“€λŸ¬
3+
*
4+
* PR opened/reopened/synchronize μ‹œ, λ³€κ²½λœ μ†”λ£¨μ…˜ νŒŒμΌλ“€μ˜ μ‹œκ°„/곡간
5+
* λ³΅μž‘λ„λ₯Ό OpenAI둜 λΆ„μ„ν•˜μ—¬ PR에 단 ν•˜λ‚˜μ˜ issue λŒ“κΈ€λ‘œ upsert ν•œλ‹€.
6+
*/
7+
8+
import { getGitHubHeaders } from "../utils/github.js";
9+
import { hasMaintenanceLabel } from "../utils/validation.js";
10+
import { generateComplexityAnalysis } from "../utils/openai.js";
11+
12+
const COMMENT_MARKER = "<!-- dalestudy-complexity-analysis -->";
13+
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
14+
const MAX_FILE_SIZE = 15000; // 15K 문자 μ œν•œ (OpenAI 토큰 μ•ˆμ „μž₯치)
15+
16+
/**
17+
* PR의 μ†”λ£¨μ…˜ νŒŒμΌλ“€μ— λŒ€ν•΄ μ‹œκ°„/곡간 λ³΅μž‘λ„ 뢄석 λŒ“κΈ€μ„ μž‘μ„±ν•œλ‹€.
18+
*
19+
* @param {string} repoOwner
20+
* @param {string} repoName
21+
* @param {number} prNumber
22+
* @param {object} prData - PR 객체 (draft, labels 포함)
23+
* @param {string} appToken - GitHub App installation token
24+
* @param {string} openaiApiKey
25+
*/
26+
export async function analyzeComplexity(
27+
repoOwner,
28+
repoName,
29+
prNumber,
30+
prData,
31+
appToken,
32+
openaiApiKey
33+
) {
34+
// Skip 쑰건
35+
if (prData.draft === true) {
36+
console.log(`[complexity] Skipping PR #${prNumber}: draft`);
37+
return { skipped: "draft" };
38+
}
39+
40+
const labels = (prData.labels || []).map((l) => l.name);
41+
if (hasMaintenanceLabel(labels)) {
42+
console.log(`[complexity] Skipping PR #${prNumber}: maintenance label`);
43+
return { skipped: "maintenance" };
44+
}
45+
46+
// 1) PR λ³€κ²½ 파일 λͺ©λ‘ 쑰회
47+
const filesResponse = await fetch(
48+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
49+
{ headers: getGitHubHeaders(appToken) }
50+
);
51+
52+
if (!filesResponse.ok) {
53+
throw new Error(
54+
`Failed to list PR files: ${filesResponse.status} ${filesResponse.statusText}`
55+
);
56+
}
57+
58+
const allFiles = await filesResponse.json();
59+
const solutionFiles = allFiles.filter(
60+
(f) =>
61+
(f.status === "added" || f.status === "modified") &&
62+
SOLUTION_PATH_REGEX.test(f.filename)
63+
);
64+
65+
console.log(
66+
`[complexity] PR #${prNumber}: ${allFiles.length} files, ${solutionFiles.length} solution files`
67+
);
68+
69+
if (solutionFiles.length === 0) {
70+
return { skipped: "no-solution-files" };
71+
}
72+
73+
// 2) νŒŒμΌλ³„ 뢄석 (각 파일 try/catch β€” ν•œ 파일 μ‹€νŒ¨κ°€ 전체λ₯Ό 막지 μ•ŠμŒ)
74+
const entries = [];
75+
for (const file of solutionFiles) {
76+
const problemName = file.filename.split("/")[0];
77+
78+
try {
79+
const rawResponse = await fetch(file.raw_url);
80+
if (!rawResponse.ok) {
81+
throw new Error(
82+
`Failed to fetch raw content: ${rawResponse.status} ${rawResponse.statusText}`
83+
);
84+
}
85+
86+
let fileContent = await rawResponse.text();
87+
if (fileContent.length > MAX_FILE_SIZE) {
88+
fileContent = fileContent.slice(0, MAX_FILE_SIZE);
89+
console.log(
90+
`[complexity] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars`
91+
);
92+
}
93+
94+
const analysis = await generateComplexityAnalysis(
95+
fileContent,
96+
problemName,
97+
openaiApiKey
98+
);
99+
100+
entries.push({ problemName, analysis });
101+
} catch (error) {
102+
console.error(
103+
`[complexity] Failed to analyze ${file.filename}: ${error.message}`
104+
);
105+
entries.push({ problemName, analysis: null, error: error.message });
106+
}
107+
}
108+
109+
// 3) λŒ“κΈ€ λ³Έλ¬Έ λΉŒλ“œ + upsert
110+
const body = formatCommentBody(entries);
111+
await upsertComment(repoOwner, repoName, prNumber, body, appToken);
112+
113+
return {
114+
analyzed: entries.filter((e) => e.analysis).length,
115+
failed: entries.filter((e) => e.error).length,
116+
};
117+
}
118+
119+
/**
120+
* λ§ˆμ»€κ°€ ν¬ν•¨λœ Bot λŒ“κΈ€μΈμ§€ 확인
121+
*/
122+
function isComplexityComment(comment) {
123+
return (
124+
comment.user?.type === "Bot" && comment.body?.includes(COMMENT_MARKER)
125+
);
126+
}
127+
128+
/**
129+
* 뢄석 결과듀을 ν•˜λ‚˜μ˜ λ§ˆν¬λ‹€μš΄ λŒ“κΈ€ 본문으둜 ν¬λ§·ν•œλ‹€.
130+
*
131+
* @param {Array<{
132+
* problemName: string,
133+
* analysis: {
134+
* hasUserAnnotation: boolean,
135+
* userTime: string|null,
136+
* userSpace: string|null,
137+
* actualTime: string,
138+
* actualSpace: string,
139+
* matches: { time: boolean, space: boolean },
140+
* feedback: string,
141+
* suggestion: string
142+
* } | null,
143+
* error?: string
144+
* }>} entries
145+
* @returns {string}
146+
*/
147+
function formatCommentBody(entries) {
148+
const lines = [];
149+
150+
lines.push(COMMENT_MARKER);
151+
lines.push("### πŸ“Š μ‹œκ°„/곡간 λ³΅μž‘λ„ 뢄석");
152+
lines.push("");
153+
154+
for (const { problemName, analysis, error } of entries) {
155+
lines.push(`### ${problemName}`);
156+
lines.push("");
157+
158+
if (error || !analysis) {
159+
lines.push(`> ⚠️ 뢄석 μ‹€νŒ¨: ${error || "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`);
160+
lines.push("");
161+
continue;
162+
}
163+
164+
if (analysis.hasUserAnnotation) {
165+
// μΌ€μ΄μŠ€ 1/2: 비ꡐ ν‘œ
166+
const timeMark = analysis.userTime
167+
? analysis.matches.time
168+
? "βœ…"
169+
: "❌"
170+
: "-";
171+
const spaceMark = analysis.userSpace
172+
? analysis.matches.space
173+
? "βœ…"
174+
: "❌"
175+
: "-";
176+
177+
lines.push("| | μœ μ € 뢄석 | μ‹€μ œ 뢄석 | κ²°κ³Ό |");
178+
lines.push("|---|---|---|---|");
179+
lines.push(
180+
`| **Time** | ${analysis.userTime ?? "-"} | ${analysis.actualTime} | ${timeMark} |`
181+
);
182+
lines.push(
183+
`| **Space** | ${analysis.userSpace ?? "-"} | ${analysis.actualSpace} | ${spaceMark} |`
184+
);
185+
} else {
186+
// μΌ€μ΄μŠ€ 3: λΆ„μ„κ°’λ§Œ
187+
lines.push("| | λ³΅μž‘λ„ |");
188+
lines.push("|---|---|");
189+
lines.push(`| **Time** | ${analysis.actualTime} |`);
190+
lines.push(`| **Space** | ${analysis.actualSpace} |`);
191+
}
192+
193+
lines.push("");
194+
195+
if (analysis.feedback) {
196+
lines.push(`**ν”Όλ“œλ°±**: ${analysis.feedback}`);
197+
lines.push("");
198+
}
199+
200+
if (analysis.suggestion) {
201+
lines.push(`**κ°œμ„  μ œμ•ˆ**: ${analysis.suggestion}`);
202+
lines.push("");
203+
}
204+
205+
if (!analysis.hasUserAnnotation) {
206+
lines.push("> πŸ’‘ 풀이에 μ‹œκ°„/곡간 λ³΅μž‘λ„λ₯Ό μ£Όμ„μœΌλ‘œ λ‚¨κ²¨λ³΄μ„Έμš”!");
207+
lines.push("");
208+
}
209+
}
210+
211+
lines.push("---");
212+
lines.push("πŸ€– 이 λŒ“κΈ€μ€ GitHub App을 톡해 μžλ™μœΌλ‘œ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.");
213+
214+
return lines.join("\n") + "\n";
215+
}
216+
217+
/**
218+
* λ³΅μž‘λ„ 뢄석 λŒ“κΈ€μ„ μƒμ„±ν•˜κ±°λ‚˜ κΈ°μ‘΄ λŒ“κΈ€μ„ μ—…λ°μ΄νŠΈν•œλ‹€.
219+
*/
220+
async function upsertComment(repoOwner, repoName, prNumber, commentBody, appToken) {
221+
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;
222+
223+
const listResponse = await fetch(
224+
`${baseUrl}/issues/${prNumber}/comments?per_page=100`,
225+
{ headers: getGitHubHeaders(appToken) }
226+
);
227+
228+
if (!listResponse.ok) {
229+
throw new Error(
230+
`[complexity] Failed to list comments on PR #${prNumber}: ${listResponse.status} ${listResponse.statusText}`
231+
);
232+
}
233+
234+
const comments = await listResponse.json();
235+
const existing = comments.find(isComplexityComment);
236+
237+
const headers = {
238+
...getGitHubHeaders(appToken),
239+
"Content-Type": "application/json",
240+
};
241+
242+
if (existing) {
243+
console.log(
244+
`[complexity] Updating existing comment ${existing.id} on PR #${prNumber}`
245+
);
246+
247+
const patchResponse = await fetch(
248+
`${baseUrl}/issues/comments/${existing.id}`,
249+
{
250+
method: "PATCH",
251+
headers,
252+
body: JSON.stringify({ body: commentBody }),
253+
}
254+
);
255+
256+
if (!patchResponse.ok) {
257+
const errorText = await patchResponse.text();
258+
throw new Error(
259+
`[complexity] Failed to update comment ${existing.id} on PR #${prNumber}: ${patchResponse.status} ${errorText}`
260+
);
261+
}
262+
263+
console.log(
264+
`[complexity] Updated comment ${existing.id} on PR #${prNumber}`
265+
);
266+
} else {
267+
console.log(
268+
`[complexity] Posting new complexity comment on PR #${prNumber}`
269+
);
270+
271+
const postResponse = await fetch(
272+
`${baseUrl}/issues/${prNumber}/comments`,
273+
{
274+
method: "POST",
275+
headers,
276+
body: JSON.stringify({ body: commentBody }),
277+
}
278+
);
279+
280+
if (!postResponse.ok) {
281+
const errorText = await postResponse.text();
282+
throw new Error(
283+
`[complexity] Failed to post comment on PR #${prNumber}: ${postResponse.status} ${errorText}`
284+
);
285+
}
286+
287+
console.log(`[complexity] Created new comment on PR #${prNumber}`);
288+
}
289+
}

β€Žhandlers/webhooks.jsβ€Ž

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { performAIReview, addReactionToComment } from "../utils/prReview.js";
2323
import { hasApprovedReview, safeJson } from "../utils/prActions.js";
2424
import { tagPatterns } from "./tag-patterns.js";
2525
import { postLearningStatus } from "./learning-status.js";
26+
import { analyzeComplexity } from "./complexity-analysis.js";
2627

2728
/**
2829
* GitHub webhook 이벀트 처리
@@ -288,6 +289,23 @@ async function handlePullRequestEvent(payload, env) {
288289
}
289290
}
290291

292+
// μ‹œκ°„/곡간 λ³΅μž‘λ„ 뢄석 (OPENAI_API_KEY μžˆμ„ λ•Œλ§Œ)
293+
if (env.OPENAI_API_KEY) {
294+
try {
295+
await analyzeComplexity(
296+
repoOwner,
297+
repoName,
298+
prNumber,
299+
pr,
300+
appToken,
301+
env.OPENAI_API_KEY
302+
);
303+
} catch (error) {
304+
console.error(`[handlePullRequestEvent] complexityAnalysis failed: ${error.message}`);
305+
// λ³΅μž‘λ„ 뢄석 μ‹€νŒ¨λŠ” 전체 흐름을 μ€‘λ‹¨μ‹œν‚€μ§€ μ•ŠμŒ
306+
}
307+
}
308+
291309
return corsResponse({
292310
message: "Processed",
293311
pr: prNumber,

0 commit comments

Comments
Β (0)