Skip to content

Commit 3b21a2e

Browse files
committed
feat: 파일 관련성 추적 기능 추가 및 전역 컨텍스트 처리 개선
1 parent ce2ecd9 commit 3b21a2e

1 file changed

Lines changed: 194 additions & 85 deletions

File tree

src/bot.ts

Lines changed: 194 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@ const MAX_PATCH_COUNT = process.env.MAX_PATCH_LENGTH
1010
: Infinity;
1111
const 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
3629
const 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 = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
80+
// CommonJS require: require('...')
81+
const requireRegex = /require\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(/\.(ts|tsx|js|jsx|mjs|cjs)$/)) {
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(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "");
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(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "");
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+
76204
const 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

Comments
 (0)