@@ -10,11 +10,7 @@ const MAX_PATCH_COUNT = process.env.MAX_PATCH_LENGTH
1010 : Infinity ;
1111const MAX_FILE_COUNT = 30 ;
1212
13- // 파일 본문을 가져올 때 허용하는 최대 크기 (바이트 단위)
14- // 너무 큰 파일은 그대로 두되, 이후 prompt 구성 단계에서 잘라냅니다.
15- const MAX_FILE_BYTES = process . env . MAX_FILE_BYTES
16- ? + process . env . MAX_FILE_BYTES
17- : 64 * 1024 ; // 기본 64KB
13+
1814
1915// 프롬프트에 포함할 수 있는 최대 문자 수 (가드레일)
2016// 이 값을 넘어가면 중간 부분을 잘라내고 앞/뒤 일부만 남깁니다.
@@ -27,10 +23,7 @@ const MAX_GLOBAL_CONTEXT_CHARS = process.env.MAX_GLOBAL_CONTEXT_CHARS
2723 ? + process . env . MAX_GLOBAL_CONTEXT_CHARS
2824 : 40000 ;
2925
30- // 전역 컨텍스트 제공 모드
31- // - "meta": PR 메타 정보(제목/커밋/파일목록)만 제공 (권장)
32- // - "full": 메타 + 전체 diff 발췌 제공 (레거시)
33- const GLOBAL_CONTEXT_MODE = process . env . GLOBAL_CONTEXT_MODE || "meta" ; // meta | full
26+
3427
3528// Fetch full file content (at a specific ref) to give the model context
3629const fetchFileContent = async (
@@ -71,13 +64,149 @@ const allocateBudget = (limit: number, ratio: number): number => {
7164 return Math . max ( 0 , Math . floor ( limit * ratio ) ) ;
7265} ;
7366
74- // 📌 리뷰 입력을 만드는 함수 (전역 컨텍스트 + 파일 전체 내용 + diff)
75- // - globalContext: PR 메타 정보를 주로 제공 (필요 시 관련 파일 일부 패치만 첨부)
67+ // 파일 경로에서 디렉토리 추출
68+ const getDirname = ( filepath : string ) : string => {
69+ const lastSlash = filepath . lastIndexOf ( "/" ) ;
70+ return lastSlash === - 1 ? "" : filepath . slice ( 0 , lastSlash ) ;
71+ } ;
72+
73+ // 파일 내용에서 import/require 경로 추출
74+ const extractImports = ( content : string , currentFile : string ) : string [ ] => {
75+ const imports : string [ ] = [ ] ;
76+ const currentDir = getDirname ( currentFile ) ;
77+
78+ // ES6 import: import ... from '...' or import '...'
79+ const esImportRegex = / i m p o r t \s + (?: [ \s \S ] * ?\s + f r o m \s + ) ? [ ' " ] ( [ ^ ' " ] + ) [ ' " ] / g;
80+ // CommonJS require: require('...')
81+ const requireRegex = / r e q u i r e \s * \( \s * [ ' " ] ( [ ^ ' " ] + ) [ ' " ] \s * \) / g;
82+
83+ let match ;
84+ while ( ( match = esImportRegex . exec ( content ) ) !== null ) {
85+ imports . push ( match [ 1 ] ) ;
86+ }
87+ while ( ( match = requireRegex . exec ( content ) ) !== null ) {
88+ imports . push ( match [ 1 ] ) ;
89+ }
90+
91+ return imports
92+ . filter ( ( imp ) => imp . startsWith ( "." ) )
93+ . map ( ( imp ) => {
94+ let resolved = imp ;
95+ if ( imp . startsWith ( "./" ) ) {
96+ resolved = currentDir ? `${ currentDir } /${ imp . slice ( 2 ) } ` : imp . slice ( 2 ) ;
97+ } else if ( imp . startsWith ( "../" ) ) {
98+ const parts = currentDir . split ( "/" ) ;
99+ let impParts = imp . split ( "/" ) ;
100+ while ( impParts [ 0 ] === ".." ) {
101+ parts . pop ( ) ;
102+ impParts . shift ( ) ;
103+ }
104+ resolved = [ ...parts , ...impParts ] . join ( "/" ) ;
105+ }
106+ // 확장자 정규화
107+ if ( ! resolved . match ( / \. ( t s | t s x | j s | j s x | m j s | c j s ) $ / ) ) {
108+ return resolved ; // 확장자 없으면 그대로 (매칭 시 startsWith로 처리)
109+ }
110+ return resolved ;
111+ } ) ;
112+ } ;
113+
114+ // 파일 경로가 import 경로와 매칭되는지 확인
115+ const matchesImportPath = ( filePath : string , importPath : string ) : boolean => {
116+ const fileWithoutExt = filePath . replace ( / \. ( t s | t s x | j s | j s x | m j s | c j s ) $ / , "" ) ;
117+ return (
118+ fileWithoutExt === importPath ||
119+ fileWithoutExt . startsWith ( importPath + "/" ) ||
120+ filePath . startsWith ( importPath )
121+ ) ;
122+ } ;
123+
124+ // 현재 파일과 관련된 파일들 찾기
125+ // - 정방향: 현재 파일이 import하는 파일
126+ // - 역방향: 현재 파일을 import하는 파일
127+ // - 같은 디렉토리
128+ const findRelatedFiles = (
129+ currentFile : string ,
130+ currentContent : string | null ,
131+ allChangedFiles : Array < { filename : string ; patch ?: string ; status ?: string ; content ?: string | null } >
132+ ) : Array < { filename : string ; patch : string ; relation : "imports" | "imported-by" | "same-dir" } > => {
133+ const related : Array < { filename : string ; patch : string ; relation : "imports" | "imported-by" | "same-dir" } > = [ ] ;
134+ const currentDir = getDirname ( currentFile ) ;
135+ const currentFileWithoutExt = currentFile . replace ( / \. ( t s | t s x | j s | j s x | m j s | c j s ) $ / , "" ) ;
136+
137+ const importedByCurrentFile = currentContent
138+ ? extractImports ( currentContent , currentFile )
139+ : [ ] ;
140+
141+ for ( const file of allChangedFiles ) {
142+ if ( file . filename === currentFile || ! file . patch ) continue ;
143+
144+ const fileDir = getDirname ( file . filename ) ;
145+
146+ // 정방향: 현재 파일이 이 파일을 import함
147+ const isImportedByCurrentFile = importedByCurrentFile . some ( ( imp ) =>
148+ matchesImportPath ( file . filename , imp )
149+ ) ;
150+
151+ // 역방향: 이 파일이 현재 파일을 import함
152+ let importsCurrentFile = false ;
153+ if ( file . content ) {
154+ const fileImports = extractImports ( file . content , file . filename ) ;
155+ importsCurrentFile = fileImports . some ( ( imp ) =>
156+ matchesImportPath ( currentFile , imp ) || currentFileWithoutExt === imp
157+ ) ;
158+ }
159+
160+ const isSameDir = fileDir === currentDir ;
161+
162+ if ( isImportedByCurrentFile ) {
163+ related . push ( { filename : file . filename , patch : file . patch , relation : "imports" } ) ;
164+ } else if ( importsCurrentFile ) {
165+ related . push ( { filename : file . filename , patch : file . patch , relation : "imported-by" } ) ;
166+ } else if ( isSameDir ) {
167+ related . push ( { filename : file . filename , patch : file . patch , relation : "same-dir" } ) ;
168+ }
169+ }
170+
171+ return related ;
172+ } ;
173+
174+ const RELATION_LABELS : Record < string , string > = {
175+ "imports" : "이 파일이 import함" ,
176+ "imported-by" : "이 파일을 import함" ,
177+ "same-dir" : "같은 디렉토리" ,
178+ } ;
179+
180+ const buildRelatedFilesContext = (
181+ relatedFiles : Array < { filename : string ; patch : string ; relation : string } > ,
182+ budget : number
183+ ) : string => {
184+ if ( relatedFiles . length === 0 ) return "" ;
185+
186+ const parts : string [ ] = [ "## 관련 파일 변경사항 (참고용)" ] ;
187+ let usedChars = parts [ 0 ] . length ;
188+ const perFileBudget = Math . floor ( budget / relatedFiles . length ) ;
189+
190+ for ( const file of relatedFiles ) {
191+ const relationLabel = RELATION_LABELS [ file . relation ] || file . relation ;
192+ const header = `\n### ${ file . filename } (${ relationLabel } )` ;
193+ const patchContent = truncateForPrompt ( file . patch , perFileBudget - header . length - 20 ) ;
194+ const section = `${ header } \n\`\`\`diff\n${ patchContent } \n\`\`\`` ;
195+
196+ if ( usedChars + section . length > budget ) break ;
197+ parts . push ( section ) ;
198+ usedChars += section . length ;
199+ }
200+
201+ return parts . join ( "\n" ) ;
202+ } ;
203+
76204const buildReviewInput = (
77205 filename : string ,
78206 content : string | null ,
79207 patch : string ,
80- globalContext : string | null
208+ prMeta : string | null ,
209+ relatedFilesContext : string | null
81210) : string => {
82211 const parts : string [ ] = [ ] ;
83212
@@ -118,13 +247,15 @@ const buildReviewInput = (
118247 parts . push ( "```" ) ;
119248 }
120249
121- // (선택) PR 전역 컨텍스트는 맨 아래에 위치시켜 영향 최소화
122- if ( globalContext ) {
250+ if ( relatedFilesContext ) {
123251 parts . push ( "\n---\n" ) ;
124- parts . push ( "# PR 전역 컨텍스트 (참고용)" ) ;
125- parts . push (
126- truncateForPrompt ( globalContext , allocateBudget ( MAX_CONTEXT_CHARS , 0.4 ) )
127- ) ;
252+ parts . push ( relatedFilesContext ) ;
253+ }
254+
255+ if ( prMeta ) {
256+ parts . push ( "\n---\n" ) ;
257+ parts . push ( "# PR 메타 정보 (참고용)" ) ;
258+ parts . push ( truncateForPrompt ( prMeta , allocateBudget ( MAX_CONTEXT_CHARS , 0.2 ) ) ) ;
128259 }
129260
130261 return parts . join ( "\n" ) ;
@@ -302,54 +433,19 @@ export const robot = (app: Probot) => {
302433 overallReview = tmp ?? null ;
303434 }
304435
305- // 1) PR 제목/커밋 메시지/변경 파일 목록을 제공하고
306- // 2) summarizeChanges 결과(있다면)를 먼저 보여주며
307- // 3) 전체 diff의 발췌본을 함께 전달합니다.
308- let globalContext : string | null = null ;
309- {
310- const prTitle = pull_request . title || "(no title)" ;
311- const changedFileList = changedFiles
312- . map ( ( f ) => `- ${ f . filename } (${ f . status } )` )
313- . join ( "\n" ) ;
314- const summary = overallReview ?. summary
315- ? `\n\n## 변경 요약\n${ overallReview . summary } `
316- : "" ;
317-
318- // meta 모드: 메타데이터만 제공 (제목/커밋 메시지/파일 목록)
319- if ( GLOBAL_CONTEXT_MODE === "meta" ) {
320- globalContext = [
321- `## PR 제목\n${ prTitle } ` ,
322- `\n## 커밋 메시지(집계)\n${ truncateForPrompt (
323- aggregatedCommitMessages ,
324- allocateBudget ( MAX_GLOBAL_CONTEXT_CHARS , 0.4 )
325- ) } `,
326- `\n## 변경 파일 목록\n${ changedFileList } ` ,
327- summary ,
328- ] . join ( "\n" ) ;
329- } else {
330- // full 모드: 전체 diff 발췌를 소량만 포함 (하위 호환)
331- const diffDigest = aggregatedPatch
332- ? truncateForPrompt (
333- aggregatedPatch ,
334- allocateBudget ( MAX_GLOBAL_CONTEXT_CHARS , 0.3 )
335- )
336- : "" ;
337- globalContext = [
338- `## PR 제목\n${ prTitle } ` ,
339- `\n## 커밋 메시지(집계)\n${ truncateForPrompt (
340- aggregatedCommitMessages ,
341- allocateBudget ( MAX_GLOBAL_CONTEXT_CHARS , 0.3 )
342- ) } `,
343- `\n## 변경 파일 목록\n${ changedFileList } ` ,
344- summary ,
345- diffDigest
346- ? `\n\n## 전체 diff (일부 발췌)\n\n\`\`\`diff\n${ diffDigest } \n\`\`\``
347- : "" ,
348- ] . join ( "\n" ) ;
349- }
350- }
436+ const prTitle = pull_request . title || "(no title)" ;
437+ const summary = overallReview ?. summary
438+ ? `\n## 변경 요약\n${ overallReview . summary } `
439+ : "" ;
440+ const prMeta = [
441+ `## PR 제목\n${ prTitle } ` ,
442+ `\n## 커밋 메시지\n${ truncateForPrompt (
443+ aggregatedCommitMessages ,
444+ allocateBudget ( MAX_GLOBAL_CONTEXT_CHARS , 0.3 )
445+ ) } `,
446+ summary ,
447+ ] . join ( "\n" ) ;
351448
352- // Fetch PR-wide file patches to compute accurate review comment positions
353449 let prFilePatchByPath = new Map < string , string > ( ) ;
354450 let commentablePaths = new Set < string > ( ) ;
355451 try {
@@ -369,8 +465,31 @@ export const robot = (app: Probot) => {
369465 log . debug ( "Failed to fetch PR files for position mapping" , e ) ;
370466 }
371467
372- for ( let i = 0 ; i < changedFiles . length ; i ++ ) {
373- const file = changedFiles [ i ] ;
468+ type ChangedFileWithContent = {
469+ filename : string ;
470+ patch ?: string ;
471+ status ?: string ;
472+ content ?: string | null ;
473+ } ;
474+ const filesWithContent : ChangedFileWithContent [ ] = await Promise . all (
475+ changedFiles . map ( async ( file ) => {
476+ if ( file . status !== "modified" && file . status !== "added" ) {
477+ return { ...file , content : null } ;
478+ }
479+ try {
480+ const content = await fetchFileContent (
481+ context ,
482+ file . filename ,
483+ context . payload . pull_request . head . sha
484+ ) ;
485+ return { ...file , content } ;
486+ } catch {
487+ return { ...file , content : null } ;
488+ }
489+ } )
490+ ) ;
491+
492+ for ( const file of filesWithContent ) {
374493 const patch = file . patch || "" ;
375494
376495 if ( file . status !== "modified" && file . status !== "added" ) {
@@ -384,29 +503,19 @@ export const robot = (app: Probot) => {
384503 continue ;
385504 }
386505
387- let fullContent : string | null = null ;
388- try {
389- // Only fetch content for reasonably sized files (octokit returns size in metadata, but we guard by prompt length later)
390- fullContent = await fetchFileContent (
391- context ,
392- file . filename ,
393- context . payload . pull_request . head . sha
394- ) ;
395- if (
396- fullContent &&
397- Buffer . byteLength ( fullContent , "utf-8" ) > MAX_FILE_BYTES
398- ) {
399- // Keep but it will be truncated by buildReviewInput/truncateForPrompt
400- }
401- } catch ( e ) {
402- log . debug ( `content fetch failed for ${ file . filename } ` , e ) ;
403- }
506+ const fullContent = file . content ?? null ;
507+ const relatedFiles = findRelatedFiles ( file . filename , fullContent , filesWithContent ) ;
508+ const relatedContext = buildRelatedFilesContext (
509+ relatedFiles ,
510+ allocateBudget ( MAX_CONTEXT_CHARS , 0.3 )
511+ ) ;
404512
405513 const reviewInput = buildReviewInput (
406514 file . filename ,
407515 fullContent ,
408516 patch ,
409- globalContext
517+ prMeta ,
518+ relatedContext || null
410519 ) ;
411520
412521 try {
0 commit comments