|
| 1 | +import { parseArgs } from 'util'; |
| 2 | +import { Agent } from '../src/core/agent'; |
| 3 | +import { loadInstructions } from '../src/core/prompt'; |
| 4 | +import { createModelFromEnv } from '../src/providers/modelFactory'; |
| 5 | +import { getPullRequestDiff, createPullRequestReview } from '../src/tools/github'; |
| 6 | +import { parseCommand } from '../src/tools/execCommand'; |
| 7 | +import { mkdirSync, existsSync } from 'fs'; |
| 8 | +import * as fsPromises from 'fs/promises'; |
| 9 | +import { join, resolve, sep } from 'path'; |
| 10 | +import { config } from '../src/config'; |
| 11 | +import { spawn } from 'child_process'; |
| 12 | +import type { Tool } from '../src/types'; |
| 13 | + |
| 14 | +// 機密情報をマスクする(ログ出力用) |
| 15 | +function maskSecret(value: string | undefined): string { |
| 16 | + if (!value) return '(未設定)'; |
| 17 | + if (value.length <= 8) return '***'; |
| 18 | + return value.slice(0, 4) + '***' + value.slice(-4); |
| 19 | +} |
| 20 | + |
| 21 | +const REPO_ROOT = process.cwd(); |
| 22 | +const WORKSPACE_ROOT = join(REPO_ROOT, 'workspace'); |
| 23 | +const MAX_FILE_SIZE = 100 * 1024; |
| 24 | +const ALLOWED_COMMANDS = ['bun', 'ls', 'pwd', 'mkdir', 'git', 'gh']; |
| 25 | +const MAX_OUTPUT_LENGTH = 2000; |
| 26 | + |
| 27 | +// PRレビュー用にリポジトリルート (REPO_ROOT) のファイルを読み込めるようにした readFile ツール |
| 28 | +const reviewReadFile: Tool = { |
| 29 | + name: 'readFile', |
| 30 | + description: 'リポジトリ内の指定されたファイルのパスから内容を読み込む。100KB以下のファイルのみ読み込めます。', |
| 31 | + needsApproval: false, |
| 32 | + parameters: { |
| 33 | + type: 'object', |
| 34 | + properties: { |
| 35 | + path: { |
| 36 | + type: 'string', |
| 37 | + description: '読み込むファイルの相対パス(例: "src/tools/github.ts")', |
| 38 | + }, |
| 39 | + }, |
| 40 | + required: ['path'], |
| 41 | + }, |
| 42 | + execute: async (args: { path: string }) => { |
| 43 | + // エージェントが "workspace/" または "./workspace/" から始まるパスでアクセスしてきた場合、 |
| 44 | + // 実際のファイルがリポジトリルートに存在することを想定してパスをクリーンアップ |
| 45 | + let cleanPath = args.path; |
| 46 | + if (cleanPath.startsWith('workspace/')) { |
| 47 | + cleanPath = cleanPath.slice(10); |
| 48 | + } else if (cleanPath.startsWith('./workspace/')) { |
| 49 | + cleanPath = cleanPath.slice(12); |
| 50 | + } else if (cleanPath.startsWith('../')) { |
| 51 | + // エージェントが ../ からアクセスしようとした場合 |
| 52 | + cleanPath = cleanPath.slice(3); |
| 53 | + } |
| 54 | + |
| 55 | + const absolutePath = resolve(REPO_ROOT, cleanPath); |
| 56 | + const allowedPrefix = REPO_ROOT + sep; |
| 57 | + |
| 58 | + if (!absolutePath.startsWith(allowedPrefix) && absolutePath !== REPO_ROOT) { |
| 59 | + throw new Error(`アクセス拒否: ${args.path} はリポジトリの外部です`); |
| 60 | + } |
| 61 | + |
| 62 | + try { |
| 63 | + // シンボリックリンクを解決して実パスを検証(セキュリティ対策) |
| 64 | + const realPath = await fsPromises.realpath(absolutePath); |
| 65 | + if (!realPath.startsWith(allowedPrefix) && realPath !== REPO_ROOT) { |
| 66 | + throw new Error(`アクセス拒否: ${args.path} はシンボリックリンク経由でリポジトリ外を参照しています`); |
| 67 | + } |
| 68 | + |
| 69 | + const stat = await fsPromises.stat(realPath); |
| 70 | + if (!stat.isFile()) { |
| 71 | + throw new Error(`通常ファイルではありません: ${args.path}`); |
| 72 | + } |
| 73 | + if (stat.size > MAX_FILE_SIZE) { |
| 74 | + throw new Error(`ファイルが大きすぎます: ${args.path}`); |
| 75 | + } |
| 76 | + return await fsPromises.readFile(realPath, 'utf-8'); |
| 77 | + } catch (error: any) { |
| 78 | + if (error.code === 'ENOENT') { |
| 79 | + throw new Error(`ファイルが見つかりません: ${args.path}`); |
| 80 | + } |
| 81 | + throw error; |
| 82 | + } |
| 83 | + } |
| 84 | +}; |
| 85 | + |
| 86 | +// PRレビュー用にリポジトリルート (REPO_ROOT) を基準にコマンドを実行し、パスチェックも緩和した execCommand ツール |
| 87 | +const reviewExecCommand: Tool = { |
| 88 | + name: 'execCommand', |
| 89 | + description: 'リポジトリルート内で許可された汎用コマンドを実行する。利用可能:bun、ls、cat、grep、find、pwd、mkdir、git、gh。', |
| 90 | + needsApproval: true, |
| 91 | + parameters: { |
| 92 | + type: 'object', |
| 93 | + properties: { |
| 94 | + command: { |
| 95 | + type: 'string', |
| 96 | + description: '実行するコマンド(例: "bun test", "git diff")', |
| 97 | + }, |
| 98 | + }, |
| 99 | + required: ['command'], |
| 100 | + }, |
| 101 | + execute: async (args: { command: string }) => { |
| 102 | + const command = args.command; |
| 103 | + // $ は正規表現や引数の文字列(ドル記号など)で頻出するため除外(shell: false で実行されるため安全です) |
| 104 | + const dangerousChars = /[;&`]/; |
| 105 | + if (dangerousChars.test(command)) { |
| 106 | + throw new Error('セキュリティ上の理由により、シェルメタ文字を含むコマンドは実行できません'); |
| 107 | + } |
| 108 | + |
| 109 | + const parts = parseCommand(command); |
| 110 | + const commandName = parts[0] || ''; |
| 111 | + const commandArgs = parts.slice(1); |
| 112 | + |
| 113 | + if (!ALLOWED_COMMANDS.includes(commandName)) { |
| 114 | + throw new Error(`コマンド ${commandName} は許可されていません`); |
| 115 | + } |
| 116 | + |
| 117 | + // 引数のパス制限を REPO_ROOT 基準にする |
| 118 | + for (const arg of commandArgs) { |
| 119 | + if (arg.startsWith('/') || arg.startsWith('.') || arg.includes('/') || arg.includes('\\')) { |
| 120 | + let cleanArg = arg; |
| 121 | + if (cleanArg.startsWith('workspace/')) { |
| 122 | + cleanArg = cleanArg.slice(10); |
| 123 | + } else if (cleanArg.startsWith('./workspace/')) { |
| 124 | + cleanArg = cleanArg.slice(12); |
| 125 | + } else if (cleanArg.startsWith('../')) { |
| 126 | + cleanArg = cleanArg.slice(3); |
| 127 | + } |
| 128 | + const resolvedPath = resolve(REPO_ROOT, cleanArg); |
| 129 | + const allowedPrefix = REPO_ROOT + sep; |
| 130 | + if (!resolvedPath.startsWith(allowedPrefix) && resolvedPath !== REPO_ROOT) { |
| 131 | + throw new Error(`アクセス拒否: ${arg} はリポジトリ外です`); |
| 132 | + } |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + return new Promise((resolvePromise, reject) => { |
| 137 | + const child = spawn(commandName, commandArgs, { |
| 138 | + cwd: REPO_ROOT, // リポジトリルートで実行 |
| 139 | + timeout: 30000, |
| 140 | + shell: false, |
| 141 | + }); |
| 142 | + |
| 143 | + let stdout = ''; |
| 144 | + let stderr = ''; |
| 145 | + let stdoutTruncated = false; |
| 146 | + let stderrTruncated = false; |
| 147 | + |
| 148 | + child.stdout.on('data', (data: Buffer) => { |
| 149 | + if (stdout.length < MAX_OUTPUT_LENGTH) { |
| 150 | + stdout += data.toString(); |
| 151 | + if (stdout.length >= MAX_OUTPUT_LENGTH) { |
| 152 | + stdoutTruncated = true; |
| 153 | + } |
| 154 | + } |
| 155 | + }); |
| 156 | + |
| 157 | + child.stderr.on('data', (data: Buffer) => { |
| 158 | + if (stderr.length < MAX_OUTPUT_LENGTH) { |
| 159 | + stderr += data.toString(); |
| 160 | + if (stderr.length >= MAX_OUTPUT_LENGTH) { |
| 161 | + stderrTruncated = true; |
| 162 | + } |
| 163 | + } |
| 164 | + }); |
| 165 | + |
| 166 | + child.on('close', (code: number | null) => { |
| 167 | + if (stdoutTruncated) { |
| 168 | + stdout = stdout.slice(0, MAX_OUTPUT_LENGTH) + '\n... (出力が長いため省略されました)'; |
| 169 | + } |
| 170 | + if (stderrTruncated) { |
| 171 | + stderr = stderr.slice(0, MAX_OUTPUT_LENGTH) + '\n... (出力が長いため省略されました)'; |
| 172 | + } |
| 173 | + |
| 174 | + if (code === 0) { |
| 175 | + resolvePromise(stdout + (stderr ? `\n(stderr: ${stderr.trim()})` : '')); |
| 176 | + } else { |
| 177 | + reject(new Error(`コマンドが異常終了しました (exit code: ${code})\n${stderr}`)); |
| 178 | + } |
| 179 | + }); |
| 180 | + |
| 181 | + child.on('error', (error: Error) => { |
| 182 | + reject(new Error(`コマンド実行エラー: ${error.message}`)); |
| 183 | + }); |
| 184 | + }); |
| 185 | + } |
| 186 | +}; |
| 187 | + |
| 188 | +async function main() { |
| 189 | + const { values } = parseArgs({ |
| 190 | + args: process.argv.slice(2), |
| 191 | + options: { |
| 192 | + 'yolo': { type: 'boolean', default: false }, |
| 193 | + 'sandbox': { type: 'boolean', default: false }, |
| 194 | + }, |
| 195 | + }); |
| 196 | + |
| 197 | + const yoloMode = values['yolo'] ?? false; |
| 198 | + config.sandbox = values['sandbox'] ?? false; |
| 199 | + |
| 200 | + // 1. 環境変数 PULL_REQUEST_NUMBER からPR番号を取得 |
| 201 | + const prNumberStr = process.env.PULL_REQUEST_NUMBER; |
| 202 | + if (!prNumberStr) { |
| 203 | + console.error('エラー: 環境変数 PULL_REQUEST_NUMBER を指定してください'); |
| 204 | + process.exit(1); |
| 205 | + } |
| 206 | + const prNumber = parseInt(prNumberStr, 10); |
| 207 | + if (isNaN(prNumber) || prNumber <= 0) { |
| 208 | + console.error('エラー: PULL_REQUEST_NUMBER は正の整数である必要があります'); |
| 209 | + process.exit(1); |
| 210 | + } |
| 211 | + |
| 212 | + // ワークスペースディレクトリを作成 |
| 213 | + if (!existsSync(WORKSPACE_ROOT)) { |
| 214 | + mkdirSync(WORKSPACE_ROOT, { recursive: true }); |
| 215 | + } |
| 216 | + |
| 217 | + // ベース指示(prompt.md + AGENTS.md) |
| 218 | + const baseInstructions = loadInstructions(WORKSPACE_ROOT); |
| 219 | + |
| 220 | + // PRレビュー専用のシステムプロンプト |
| 221 | + const prReviewInstructions = `${baseInstructions} |
| 222 | + |
| 223 | +あなたは GitHub Actions で実行されるコードレビューエージェントです。 |
| 224 | +あなたの役割は、指定されたプルリクエストの差分(コード変更点)を分析し、コードレビューを行うことです。 |
| 225 | +
|
| 226 | +## 効率的な実行のためのルール |
| 227 | +- ファイルの内容を確認・検索する際は、何度も \`grep\` や \`cat\` などのコマンドを実行して一部を探索するのではなく、ファイル全体を \`readFile\` ツールで一回で読み込み、あなたの能力で内容を検索・把握してください。無駄なコマンド実行によるステップ消費を避けてください。 |
| 228 | +- レビュー対象の変更差分に直接関係のない、実行環境のライブラリやコード全体の動作について、過度に深掘りして調査することは避けてください。PRの差分レビューに焦点を当て、スマートにTODOリストを完了させてください。 |
| 229 | +
|
| 230 | +## ワークフロー |
| 231 | +以下の手順で作業を進めてください: |
| 232 | +
|
| 233 | +1. **TODOリストの作成**: |
| 234 | + - [ ] PRの差分を取得する (getPullRequestDiff) |
| 235 | + - [ ] 差分に含まれるファイルとコード内容を読み込んで理解する (必要に応じて readFile) |
| 236 | + - [ ] 変更内容に対してバグ、改善点、セキュリティ上の問題、または優れた設計についてレビューする |
| 237 | + - [ ] レビュー結果をPRにコメントとして投稿する (createPullRequestReview) |
| 238 | +
|
| 239 | +2. **タスクの実行**: TODOリストに従って作業を進める。 |
| 240 | + - 変更があったすべてのファイルと内容を詳細に確認してください。 |
| 241 | + - テストの追加やドキュメントの更新が抜けていないかもチェックしてください。 |
| 242 | + - 指摘は具体的かつ constructive(建設的)に行い、良い実装に対しては褒めるようにしてください。 |
| 243 | + - 最後に \`createPullRequestReview\` を呼び出して、レビューコメント(全体コメント)を投稿してください。 |
| 244 | +
|
| 245 | +3. **完了報告**: レビューコメントを投稿したら、その内容を要約して結果報告をしてください。 |
| 246 | +`; |
| 247 | + |
| 248 | + const provider = process.env.LLM_PROVIDER; |
| 249 | + const modelName = process.env.LLM_MODEL; |
| 250 | + const apiKey = process.env.LLM_API_KEY; |
| 251 | + |
| 252 | + // GitHub Actions環境での実行かどうかを簡易判定(CI=trueなど) |
| 253 | + const isCI = process.env.CI === 'true'; |
| 254 | + |
| 255 | + console.log('=== Nano Code Reviewer ===\n'); |
| 256 | + console.log(`Provider: ${provider || '(未設定)'}`); |
| 257 | + console.log(`Model: ${modelName || '(未設定)'}`); |
| 258 | + |
| 259 | + if (isCI) { |
| 260 | + console.log(`API Key: ${maskSecret(apiKey)}`); |
| 261 | + if (apiKey) { |
| 262 | + console.log(`::add-mask::${apiKey}`); |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + console.log(`Workspace: ${WORKSPACE_ROOT}`); |
| 267 | + console.log(`Target PR: #${prNumber}`); |
| 268 | + if (yoloMode) { |
| 269 | + console.log('[モード] 自動承認モード (--yolo)'); |
| 270 | + } |
| 271 | + if (config.sandbox) { |
| 272 | + console.log('[モード] サンドボックスモード (--sandbox)'); |
| 273 | + } |
| 274 | + |
| 275 | + if (!provider || !modelName || !apiKey) { |
| 276 | + console.error('[ERROR] LLM設定が不足しています'); |
| 277 | + process.exit(1); |
| 278 | + } |
| 279 | + |
| 280 | + const model = createModelFromEnv({ useResponses: false }); |
| 281 | + |
| 282 | + const agent = new Agent({ |
| 283 | + name: 'nano-code-reviewer', |
| 284 | + model, |
| 285 | + instructions: prReviewInstructions, |
| 286 | + tools: { |
| 287 | + readFile: reviewReadFile, // PRレビュー用の制限緩和版ツールを渡す |
| 288 | + execCommand: reviewExecCommand, // PRレビュー用の制限緩和版ツールを渡す |
| 289 | + getPullRequestDiff, |
| 290 | + createPullRequestReview, |
| 291 | + }, |
| 292 | + maxSteps: 60, |
| 293 | + // Yoloモードなら自動承認 |
| 294 | + approvalFunc: yoloMode ? async (name) => { |
| 295 | + console.log(`[自動承認] ツール ${name} の実行を承認しました`); |
| 296 | + return true; |
| 297 | + } : undefined, |
| 298 | + }); |
| 299 | + |
| 300 | + try { |
| 301 | + const result = await agent.generate(`プルリクエスト #${prNumber} のコードレビューを行い、コメントを投稿してください。`); |
| 302 | + |
| 303 | + if (isCI) { |
| 304 | + console.log('\n' + '─'.repeat(60)); |
| 305 | + console.log(`[完了] レビューが正常に終了しました`); |
| 306 | + if (result.usage) { |
| 307 | + console.log(`[使用トークン] ${result.usage.totalTokens} tokens`); |
| 308 | + } |
| 309 | + } |
| 310 | + } catch (error) { |
| 311 | + console.error('\n' + '─'.repeat(60)); |
| 312 | + console.error('[ERROR] エージェント実行中にエラーが発生しました\n'); |
| 313 | + |
| 314 | + if (error instanceof Error) { |
| 315 | + let message = error.message; |
| 316 | + if (apiKey) { |
| 317 | + message = message.replace(new RegExp(apiKey, 'g'), maskSecret(apiKey)); |
| 318 | + } |
| 319 | + console.error(`原因: ${message}`); |
| 320 | + } |
| 321 | + process.exit(1); |
| 322 | + } |
| 323 | +} |
| 324 | + |
| 325 | +main(); |
0 commit comments