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" ;
1418import { getGitHubHeaders } from "../utils/github.js" ;
19+ import { createCodeFence } from "../utils/markdown.js" ;
1520import { hasMaintenanceLabel } from "../utils/validation.js" ;
1621import { generatePatternAnalysis } from "../utils/openai.js" ;
1722import {
@@ -23,7 +28,7 @@ const COMMENT_MARKER = "<!-- dalestudy-pattern-tag -->";
2328// λ κ±°μ λ¨λ
볡μ‘λ issue comment μλ³μ©. μ ν©λ³Έ λκΈμλ λ°μ§ μλλ€.
2429const LEGACY_COMPLEXITY_MARKER = "<!-- dalestudy-complexity-analysis -->" ;
2530const 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 */
4044export 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 */
211155async 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