Skip to content

Commit e46b93d

Browse files
authored
Merge pull request #50 from parkhojeong/260729-improve-tag-patterns-48
풀이 μΆ”κ°€/μˆ˜μ • μ»€λ°‹μ‹œ κΈ°μ‘΄ 뢄석은 μœ μ§€ν•˜κ³  μΆ”κ°€/μˆ˜μ • νŒŒμΌμ— λŒ€ν•΄μ„œλ§Œ λΆ„μ„ν•˜λ„λ‘ λ³€κ²½
2 parents 3ae799e + f6a09d3 commit e46b93d

11 files changed

Lines changed: 588 additions & 249 deletions

β€ŽAGENTS.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ GitHub webhook
266266

267267
- `INTERNAL_SECRET`κ³Ό `WORKER_URL`이 λͺ¨λ‘ μ„€μ •λ˜μ–΄μ•Ό ν™œμ„±ν™”λœλ‹€. λ‘˜ 쀑 ν•˜λ‚˜λΌλ„ μ—†μœΌλ©΄ 기쑴처럼 같은 invocationμ—μ„œ 순차 μ‹€ν–‰(subrequest μ˜ˆμ‚° 곡유)λ˜μ–΄ 파일이 λ§Žμ€ PRμ—μ„œ μ˜ˆμ‚°μ„ μ΄ˆκ³Όν•  수 μžˆλ‹€.
268268
- λ‚΄λΆ€ μ—”λ“œν¬μΈνŠΈλŠ” `/internal/tag-patterns`, `/internal/learning-status`이며 `X-Internal-Secret` ν—€λ”λ‘œ μΈμ¦ν•œλ‹€.
269-
- μ°Έκ³ : `tests/subrequest-budget.test.js`κ°€ 5개 파일 λ³€κ²½ μ‹œλ‚˜λ¦¬μ˜€μ—μ„œ 각 ν•Έλ“€λŸ¬μ˜ fetch 호좜 수(각각 22, 15회)λ₯Ό νšŒκ·€ ν…ŒμŠ€νŠΈλ‘œ λ°•μ•„λ‘”λ‹€.
269+
- μ°Έκ³ : `tests/subrequest-budget.test.js`κ°€ 5개 파일 λ³€κ²½ μ‹œλ‚˜λ¦¬μ˜€μ—μ„œ 각 ν•Έλ“€λŸ¬μ˜ fetch 호좜 수(`tagPatterns` 20회, `postLearningStatus` 31회)λ₯Ό νšŒκ·€ ν…ŒμŠ€νŠΈλ‘œ λ°•μ•„λ‘”λ‹€.
270270

271271
## λ³΄μ•ˆ 및 κΆŒν•œ
272272

β€Žhandlers/internal-dispatch.jsβ€Ž

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ export async function handleInternalDispatch(request, env, pathname) {
5858
}
5959

6060
async function handleTagPatterns(payload, appToken, env) {
61-
const { repoOwner, repoName, prNumber, headSha, prData } = payload;
61+
const {
62+
repoOwner,
63+
repoName,
64+
prNumber,
65+
headSha,
66+
prData,
67+
} = payload;
6268
const result = await tagPatterns(
6369
repoOwner,
6470
repoName,

β€Žhandlers/tag-patterns.jsβ€Ž

Lines changed: 145 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
* νŒŒμΌλ³„ review comment둜 남긴닀. λ³΅μž‘λ„ 뢄석은 νŒ¨ν„΄ 뢄석 루프와 λ³‘λ ¬λ‘œ
66
* OpenAI 1μ½œμ—μ„œ λͺ¨λ“  νŒŒμΌμ„ ν•œ λ²ˆμ— μ²˜λ¦¬ν•˜κ³ , κ·Έ κ²°κ³Όλ₯Ό νŒŒμΌλ³„ λŒ“κΈ€
77
* 본문에 ν•œ μ„Ήμ…˜ 더 λΆ™μ΄λŠ” ν˜•νƒœλ‘œ 묻어간닀.
8+
* μž¬μ‹€ν–‰ μ‹œ κΈ°μ‘΄ νŒ¨ν„΄ λŒ“κΈ€κ³Ό 닡글은 λ³΄μ‘΄ν•˜κ³ , λ³€κ²½λœ νŒŒμΌμ— μƒˆ 뢄석 λŒ“κΈ€μ„ μΆ”κ°€ν•œλ‹€.
89
*
9-
* 주의: μ†”λ£¨μ…˜ 파일이 12개λ₯Ό λ„˜μœΌλ©΄ subrequest ν•œλ„(50)에 κ°€κΉŒμ›Œμ§„λ‹€.
10-
* κΈ°μ‘΄ νŒ¨ν„΄ νƒœκΉ… μžμ²΄λ„ 13파일 μ΄μƒμ—μ„œ ν•œλ„λ₯Ό λ„˜λŠ” cliff κ°€ μžˆμœΌλ‹ˆ
11-
* λ³΅μž‘λ„ 합본은 κ·Έ cliff λ₯Ό 1νŒŒμΌλΆ„(13β†’12) λ‹ΉκΈ°λŠ” 정도닀.
10+
* 주의: Workers Free ν”Œλžœμ˜ μ™ΈλΆ€ subrequest ν•œλ„λŠ” invocationλ‹Ή 50νšŒμ΄λ―€λ‘œ μ€€μˆ˜ν•΄μ•Ό ν•œλ‹€.
11+
* @see https://developers.cloudflare.com/workers/platform/limits/#subrequests
1212
*/
1313

14+
import {
15+
parseFileShaMarker,
16+
renderFileShaMarker,
17+
} from "../utils/commentMarker.js";
1418
import { getGitHubHeaders } from "../utils/github.js";
19+
import { createCodeFence } from "../utils/markdown.js";
1520
import { hasMaintenanceLabel } from "../utils/validation.js";
1621
import { generatePatternAnalysis } from "../utils/openai.js";
1722
import {
@@ -23,7 +28,7 @@ const COMMENT_MARKER = "<!-- dalestudy-pattern-tag -->";
2328
// λ ˆκ±°μ‹œ 단독 λ³΅μž‘λ„ issue comment μ‹λ³„μš©. μƒˆ ν•©λ³Έ λŒ“κΈ€μ—λŠ” λ°•μ§€ μ•ŠλŠ”λ‹€.
2429
const LEGACY_COMPLEXITY_MARKER = "<!-- dalestudy-complexity-analysis -->";
2530
const SOLUTION_PATH_REGEX = /^[^/]+\/[^/]+\.[^.]+$/;
26-
const MAX_FILE_SIZE = 20000; // 20K 문자 μ œν•œ (OpenAI 토큰 μ•ˆμ „μž₯치)
31+
const MAX_FILE_CONTENT_LENGTH = 20000; // OpenAI μž…λ ₯ 크기 μ•ˆμ „μž₯치
2732

2833
/**
2934
* PR의 μ†”λ£¨μ…˜ νŒŒμΌλ“€μ— μ•Œκ³ λ¦¬μ¦˜ νŒ¨ν„΄ νƒœκ·Έ 달기
@@ -35,7 +40,6 @@ const MAX_FILE_SIZE = 20000; // 20K 문자 μ œν•œ (OpenAI 토큰 μ•ˆμ „μž₯치)
3540
* @param {object} prData - PR 객체 (draft, labels 포함)
3641
* @param {string} appToken - GitHub App installation token
3742
* @param {string} openaiApiKey
38-
* @param {string[]|null} [changedFilenames=null] - synchronize μ‹œ λ³€κ²½λœ 파일λͺ… λͺ©λ‘ (null이면 전체 뢄석)
3943
*/
4044
export async function tagPatterns(
4145
repoOwner,
@@ -44,8 +48,7 @@ export async function tagPatterns(
4448
headSha,
4549
prData,
4650
appToken,
47-
openaiApiKey,
48-
changedFilenames = null
51+
openaiApiKey
4952
) {
5053
// 2-1. Skip 쑰건
5154
if (prData.draft === true) {
@@ -78,13 +81,20 @@ export async function tagPatterns(
7881
SOLUTION_PATH_REGEX.test(f.filename)
7982
);
8083

81-
// changedFilenamesκ°€ 제곡되면 ν•΄λ‹Ή 파일만 λŒ€μƒμœΌλ‘œ 쒁힘 (synchronize μ΅œμ ν™”)
82-
if (changedFilenames !== null) {
83-
const changedSet = new Set(changedFilenames);
84-
solutionFiles = solutionFiles.filter((f) => changedSet.has(f.filename));
85-
console.log(
86-
`[tagPatterns] PR #${prNumber}: narrowed to ${solutionFiles.length} changed solution files`
87-
);
84+
if (solutionFiles.length === 0) {
85+
return { skipped: "no-solution-files" };
86+
}
87+
88+
solutionFiles = await getUnanalyzedOrChangedFiles(
89+
repoOwner,
90+
repoName,
91+
prNumber,
92+
appToken,
93+
solutionFiles
94+
);
95+
96+
if (solutionFiles === null) {
97+
return { skipped: "review-comments-unavailable" };
8898
}
8999

90100
console.log(
@@ -95,23 +105,17 @@ export async function tagPatterns(
95105
return { skipped: "no-solution-files" };
96106
}
97107

98-
// 2-3. κΈ°μ‘΄ Bot νŒ¨ν„΄ νƒœκ·Έ μ½”λ©˜νŠΈ μ‚­μ œ (λ³€κ²½ 파일만)
99-
const targetFilenames = solutionFiles.map((f) => f.filename);
100-
await deletePreviousPatternComments(
101-
repoOwner, repoName, prNumber, appToken, targetFilenames
102-
);
103-
104-
// 2-4. λͺ¨λ“  파일 raw λ‹€μš΄λ‘œλ“œ (ν•œ 번만, λ³΅μž‘λ„ 뢄석과 곡유)
108+
// 2-3. λͺ¨λ“  파일 raw λ‹€μš΄λ‘œλ“œ (ν•œ 번만, λ³΅μž‘λ„ 뢄석과 곡유)
105109
const fileEntries = await downloadFileEntries(solutionFiles);
106110

107-
// 2-5. λ³΅μž‘λ„ 뢄석은 1μ½œμ΄λ―€λ‘œ νŒ¨ν„΄ 루프와 병렬 μ§„ν–‰. μ‹€νŒ¨ν•΄λ„ νŒ¨ν„΄ λŒ“κΈ€μ€ μž‘μ„±.
111+
// 2-4. λ³΅μž‘λ„ 뢄석은 1μ½œμ΄λ―€λ‘œ νŒ¨ν„΄ 루프와 병렬 μ§„ν–‰. μ‹€νŒ¨ν•΄λ„ νŒ¨ν„΄ λŒ“κΈ€μ€ μž‘μ„±.
108112
const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey)
109113
.catch((err) => {
110114
console.error(`[tagPatterns] complexity analysis failed: ${err.message}`);
111115
return [];
112116
});
113117

114-
// 2-6. νŒŒμΌλ³„ OpenAI 뢄석 + μ½”λ©˜νŠΈ μž‘μ„± (각 파일 try/catch λž˜ν•‘)
118+
// 2-5. νŒŒμΌλ³„ OpenAI 뢄석 + μ½”λ©˜νŠΈ μž‘μ„± (각 파일 try/catch λž˜ν•‘)
115119
const results = [];
116120
for (const fe of fileEntries) {
117121
try {
@@ -134,78 +138,18 @@ export async function tagPatterns(
134138
}
135139
}
136140

137-
// 2-7. λ§ˆμ΄κ·Έλ ˆμ΄μ…˜: ꡬ버전이 남긴 단독 λ³΅μž‘λ„ issue comment κ°€ 있으면 μ‚­μ œ
141+
// 2-6. λ§ˆμ΄κ·Έλ ˆμ΄μ…˜: ꡬ버전이 남긴 단독 λ³΅μž‘λ„ issue comment κ°€ 있으면 μ‚­μ œ
138142
await deleteLegacyComplexityIssueComment(
139143
repoOwner, repoName, prNumber, appToken
140144
);
141145

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

145-
/**
146-
* κΈ°μ‘΄ Bot νŒ¨ν„΄ νƒœκ·Έ μ½”λ©˜νŠΈ μ‚­μ œ (λŒ€μƒ 파일만, λ‹€λ₯Έ μ‚¬μš©μž μ½”λ©˜νŠΈλŠ” μ ˆλŒ€ κ±΄λ“œλ¦¬μ§€ μ•ŠμŒ)
147-
*
148-
* @param {string[]} targetFilenames - μ‚­μ œ λŒ€μƒ 파일λͺ… λͺ©λ‘
149-
*/
150-
async function deletePreviousPatternComments(
151-
repoOwner,
152-
repoName,
153-
prNumber,
154-
appToken,
155-
targetFilenames
156-
) {
157-
const response = await fetch(
158-
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?per_page=100`,
159-
{ headers: getGitHubHeaders(appToken) }
160-
);
161-
162-
if (!response.ok) {
163-
console.error(
164-
`[tagPatterns] Failed to fetch review comments: ${response.status}`
165-
);
166-
return;
167-
}
168-
169-
const comments = await response.json();
170-
const targetSet = new Set(targetFilenames);
171-
const botPatternComments = comments.filter(
172-
(c) =>
173-
c.user?.type === "Bot" &&
174-
c.body?.includes(COMMENT_MARKER) &&
175-
targetSet.has(c.path)
176-
);
177-
178-
for (const comment of botPatternComments) {
179-
try {
180-
const deleteResponse = await fetch(
181-
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/comments/${comment.id}`,
182-
{
183-
method: "DELETE",
184-
headers: getGitHubHeaders(appToken),
185-
}
186-
);
187-
188-
if (!deleteResponse.ok) {
189-
console.error(
190-
`[tagPatterns] Failed to delete comment ${comment.id}: ${deleteResponse.status}`
191-
);
192-
}
193-
} catch (error) {
194-
console.error(
195-
`[tagPatterns] Error deleting comment ${comment.id}: ${error.message}`
196-
);
197-
}
198-
}
199-
200-
console.log(
201-
`[tagPatterns] Deleted ${botPatternComments.length} previous pattern comments for ${targetFilenames.length} files`
202-
);
203-
}
204-
205149
/**
206150
* 단일 파일 뢄석 + μ½”λ©˜νŠΈ μž‘μ„±
207151
*
208-
* @param {{file: object, problemName: string, content: string}} fileEntry
152+
* @param {{file: object, problemName: string, content: string, isContentTruncated: boolean}} fileEntry
209153
* @param {Promise<Array>} complexityPromise - λͺ¨λ“  파일의 λ³΅μž‘λ„ 뢄석 κ²°κ³Ό (병렬 μ§„ν–‰)
210154
*/
211155
async function tagSingleFile(
@@ -218,7 +162,12 @@ async function tagSingleFile(
218162
appToken,
219163
openaiApiKey
220164
) {
221-
const { file, problemName, content: fileContent } = fileEntry;
165+
const {
166+
file,
167+
problemName,
168+
content: fileContent,
169+
isContentTruncated,
170+
} = fileEntry;
222171

223172
// OpenAI νŒ¨ν„΄ 뢄석
224173
const analysis = await generatePatternAnalysis(
@@ -231,7 +180,9 @@ async function tagSingleFile(
231180
const patternsText =
232181
analysis.patterns.length > 0 ? analysis.patterns.join(", ") : "κ°μ§€λœ νŒ¨ν„΄ μ—†μŒ";
233182
let body = `${COMMENT_MARKER}
234-
### 🏷️ μ•Œκ³ λ¦¬μ¦˜ νŒ¨ν„΄ 뢄석
183+
${file.sha ? `${renderFileShaMarker(file.sha)}\n` : ""}### 🏷️ μ•Œκ³ λ¦¬μ¦˜ νŒ¨ν„΄ 뢄석
184+
185+
${renderAnalyzedSource(file.filename, fileContent, isContentTruncated)}
235186
236187
- **νŒ¨ν„΄**: ${patternsText}
237188
- **μ„€λͺ…**: ${analysis.description || "(μ„€λͺ… μ—†μŒ)"}`;
@@ -273,6 +224,108 @@ async function tagSingleFile(
273224
return { patterns: analysis.patterns };
274225
}
275226

227+
function renderAnalyzedSource(filename, content, isContentTruncated) {
228+
const language = filename.includes(".") ? filename.split(".").pop() : "";
229+
const truncationNotice = isContentTruncated ? "\n... (μ΄ν•˜ μƒλž΅)" : "";
230+
const codeFence = createCodeFence(content);
231+
232+
return `<details>
233+
<summary>${filename}</summary>
234+
235+
${codeFence}${language}
236+
${content}${truncationNotice}
237+
${codeFence}
238+
239+
</details>`;
240+
}
241+
242+
async function fetchReviewCommentsNewestFirst(
243+
repoOwner,
244+
repoName,
245+
prNumber,
246+
appToken
247+
) {
248+
try {
249+
const response = await fetch(
250+
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/comments?sort=created&direction=desc&per_page=100`,
251+
{ headers: getGitHubHeaders(appToken) }
252+
);
253+
254+
if (!response.ok) {
255+
throw new Error(`GitHub API responded with ${response.status}`);
256+
}
257+
258+
return await response.json();
259+
} catch (error) {
260+
console.error(
261+
`[tagPatterns] Failed to load review comments: ${error.message}`
262+
);
263+
return null;
264+
}
265+
}
266+
267+
function extractLatestAnalyzedFileShas(commentsNewestFirst) {
268+
const latestAnalyzedFileShas = new Map();
269+
270+
for (const comment of commentsNewestFirst) {
271+
const hasLatestAnalysisForFile = latestAnalyzedFileShas.has(comment.path);
272+
const isTopLevelComment = comment.in_reply_to_id == null;
273+
const isBotPatternAnalysisComment =
274+
comment.user?.type === "Bot" &&
275+
comment.body?.includes(COMMENT_MARKER);
276+
277+
if (
278+
!hasLatestAnalysisForFile &&
279+
isTopLevelComment &&
280+
isBotPatternAnalysisComment
281+
) {
282+
latestAnalyzedFileShas.set(
283+
comment.path,
284+
parseFileShaMarker(comment.body)
285+
);
286+
}
287+
}
288+
289+
return latestAnalyzedFileShas;
290+
}
291+
292+
/**
293+
* 이전에 λΆ„μ„ν•˜μ§€ μ•Šμ•˜κ±°λ‚˜ 파일 λ‚΄μš©μ΄ λ³€κ²½λœ 파일만 λ°˜ν™˜ν•œλ‹€.
294+
* κΈ°μ‘΄ 뢄석 λŒ“κΈ€μ„ μ‘°νšŒν•˜μ§€ λͺ»ν•˜λ©΄ null을 λ°˜ν™˜ν•œλ‹€.
295+
*/
296+
async function getUnanalyzedOrChangedFiles(
297+
repoOwner,
298+
repoName,
299+
prNumber,
300+
appToken,
301+
solutionFiles
302+
) {
303+
const reviewCommentsNewestFirst = await fetchReviewCommentsNewestFirst(
304+
repoOwner,
305+
repoName,
306+
prNumber,
307+
appToken
308+
);
309+
310+
if (reviewCommentsNewestFirst === null) {
311+
return null;
312+
}
313+
314+
const latestAnalyzedFileShas = extractLatestAnalyzedFileShas(
315+
reviewCommentsNewestFirst
316+
);
317+
318+
function needsAnalysis(file) {
319+
const latestAnalyzedFileSha = latestAnalyzedFileShas.get(file.filename);
320+
const isFileShaMissing = file.sha == null;
321+
const hasFileChanged = file.sha !== latestAnalyzedFileSha;
322+
323+
return isFileShaMissing || hasFileChanged;
324+
}
325+
326+
return solutionFiles.filter(needsAnalysis);
327+
}
328+
276329
/**
277330
* μ†”λ£¨μ…˜ νŒŒμΌλ“€μ˜ raw λ‚΄μš©μ„ ν•œ λ²ˆμ— λ‹€μš΄λ‘œλ“œν•œλ‹€.
278331
* νŒ¨ν„΄ 뢄석 + λ³΅μž‘λ„ 뢄석이 같은 fileEntries λ₯Ό κ³΅μœ ν•œλ‹€.
@@ -287,16 +340,18 @@ async function downloadFileEntries(solutionFiles) {
287340
);
288341
}
289342
let content = await res.text();
290-
if (content.length > MAX_FILE_SIZE) {
291-
content = content.slice(0, MAX_FILE_SIZE);
343+
const isContentTruncated = content.length > MAX_FILE_CONTENT_LENGTH;
344+
if (isContentTruncated) {
345+
content = content.slice(0, MAX_FILE_CONTENT_LENGTH);
292346
console.log(
293-
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_SIZE} chars`
347+
`[tagPatterns] Truncated ${file.filename} to ${MAX_FILE_CONTENT_LENGTH} chars`
294348
);
295349
}
296350
return {
297351
file,
298352
problemName: file.filename.split("/")[0],
299353
content,
354+
isContentTruncated,
300355
};
301356
})
302357
);

0 commit comments

Comments
Β (0)