@raolin2025/claude-code-node 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Diff 解析器 - 解析 GitHub unified diff format
3
+ * 支持行级别定位,为 PR 评论提供 position
4
+ */
5
+
6
+ /**
7
+ * 解析 diff 字符串
8
+ * @param {string} diff - 原始 diff 内容
9
+ * @returns {Array<DiffHunk>} hunk 数组
10
+ */
11
+ export function parseDiff(diff) {
12
+ const hunks = []
13
+ const lines = diff.split('\n')
14
+ let currentHunk = null
15
+
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i]
18
+
19
+ // Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
20
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
21
+ if (hunkMatch) {
22
+ if (currentHunk) {
23
+ hunks.push(currentHunk)
24
+ }
25
+ currentHunk = {
26
+ oldStart: parseInt(hunkMatch[1], 10),
27
+ oldCount: parseInt(hunkMatch[2] || '1', 10),
28
+ newStart: parseInt(hunkMatch[3], 10),
29
+ newCount: parseInt(hunkMatch[4] || '1', 10),
30
+ lines: [],
31
+ headerLine: line
32
+ }
33
+ continue
34
+ }
35
+
36
+ if (currentHunk) {
37
+ const diffLine = parseDiffLine(line, currentHunk)
38
+ currentHunk.lines.push(diffLine)
39
+ }
40
+ }
41
+
42
+ if (currentHunk) {
43
+ hunks.push(currentHunk)
44
+ }
45
+
46
+ return hunks
47
+ }
48
+
49
+ /**
50
+ * 解析单行 diff
51
+ */
52
+ function parseDiffLine(line, hunk) {
53
+ const type = line[0]
54
+ const content = line.slice(1)
55
+
56
+ // 计算行号
57
+ let oldLineNum = null
58
+ let newLineNum = null
59
+
60
+ if (type === '+' && hunk.oldStart !== null) {
61
+ newLineNum = hunk.newStart + (hunk.newLineCount || 0)
62
+ hunk.newLineCount = (hunk.newLineCount || 0) + 1
63
+ } else if (type === '-' && hunk.oldStart !== null) {
64
+ oldLineNum = hunk.oldStart + (hunk.oldLineCount || 0)
65
+ hunk.oldLineCount = (hunk.oldLineCount || 0) + 1
66
+ } else if (type === ' ') {
67
+ // context line
68
+ const oldCount = hunk.oldLineCount || 0
69
+ const newCount = hunk.newLineCount || 0
70
+ oldLineNum = hunk.oldStart + oldCount
71
+ newLineNum = hunk.newStart + newCount
72
+ hunk.oldLineCount = oldCount + 1
73
+ hunk.newLineCount = newCount + 1
74
+ }
75
+
76
+ return {
77
+ type, // ' ' | '+' | '-'
78
+ content,
79
+ oldLineNum,
80
+ newLineNum,
81
+ raw: line
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 在 diff 中查找文件对应的所有 hunk
87
+ * @param {Array<DiffHunk>} hunks
88
+ * @param {string} filePath - 文件路径
89
+ * @returns {Array<DiffHunk>} 该文件的所有 hunk
90
+ */
91
+ export function getHunksForFile(hunks, filePath) {
92
+ // 实际 diff 中,每个文件的开头会有:
93
+ // --- a/path/to/file
94
+ // +++ b/path/to/file
95
+ // 然后跟上多个 @@ headers
96
+ // 简化版:我们假设调用者已经根据文件筛选了 hunks
97
+ // 完整版需要扫描整个 diff 找到文件边界
98
+
99
+ // 这里我们使用简化的方法:传入的 hunks 已经是该文件的所有 hunk
100
+ return hunks
101
+ }
102
+
103
+ /**
104
+ * 根据新文件行号计算在 diff 中的 position
105
+ * GitHub API 的 position 是从文件 diff 开始处计数的行号(包含 hunk headers)
106
+ *
107
+ * @param {Array<DiffHunk>} hunks - 该文件的所有 hunk
108
+ * @param {number} lineNum - 新文件中的行号
109
+ * @returns {number} position (null if not found)
110
+ */
111
+ export function getPositionInDiff(hunks, lineNum) {
112
+ let position = 0
113
+
114
+ for (const hunk of hunks) {
115
+ // hunk header 占 1 行
116
+ position += 1
117
+
118
+ for (const line of hunk.lines) {
119
+ position += 1
120
+
121
+ const isLineMatch = (
122
+ (line.type === '+' || line.type === ' ') &&
123
+ line.newLineNum === lineNum
124
+ )
125
+
126
+ if (isLineMatch) {
127
+ return position
128
+ }
129
+ }
130
+ }
131
+
132
+ return null // 该行不在 diff 中(可能是未修改的行)
133
+ }
134
+
135
+ /**
136
+ * 构建 PR 评论的 payload
137
+ * @param {string} body - 评论内容
138
+ * @param {string} path - 文件路径
139
+ * @param {number} position - diff position
140
+ * @param {string} commitId - commit SHA(可选)
141
+ */
142
+ export function buildCommentPayload(body, path, position, commitId = null) {
143
+ const payload = {
144
+ body,
145
+ path,
146
+ position
147
+ }
148
+ if (commitId) {
149
+ payload.commit_id = commitId
150
+ }
151
+ return payload
152
+ }
153
+
154
+ /**
155
+ * Diff Hunk 数据结构
156
+ * @typedef {Object} DiffHunk
157
+ * @property {number} oldStart
158
+ * @property {number} oldCount
159
+ * @property {number} newStart
160
+ * @property {number} newCount
161
+ * @property {string} headerLine
162
+ * @property {Array<DiffLine>} lines
163
+ */
164
+
165
+ /**
166
+ * Diff Line 数据结构
167
+ * @typedef {Object} DiffLine
168
+ * @property {string} type - ' ', '+', '-'
169
+ * @property {string} content - 行内容
170
+ * @property {number|null} oldLineNum
171
+ * @property {number|null} newLineNum
172
+ * @property {string} raw - 原始行
173
+ */
174
+
175
+ /**
176
+ * 将 diff 按文件分割
177
+ * @param {string} diff
178
+ * @returns {Map<string, Array<DiffHunk>}} 文件路径 → hunk 数组
179
+ */
180
+ export function splitDiffByFile(diff) {
181
+ const fileMap = new Map()
182
+ const lines = diff.split('\n')
183
+
184
+ let currentFile = null
185
+ let currentHunk = null
186
+ let fileStartLine = 0
187
+
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const line = lines[i]
190
+
191
+ // 检测文件边界
192
+ // --- a/path/to/file
193
+ // +++ b/path/to/file
194
+ const oldFileMatch = line.match(/^--- a\/(.+)/)
195
+ const newFileMatch = lines[i + 1]?.match(/^\+\+\+ b\/(.+)/)
196
+
197
+ if (oldFileMatch && newFileMatch) {
198
+ // 切换到新文件
199
+ if (currentFile && currentHunk) {
200
+ fileMap.get(currentFile).push(currentHunk)
201
+ }
202
+
203
+ // 新文件的路径(来自 b/ 侧)
204
+ currentFile = newFileMatch[1]
205
+ if (!fileMap.has(currentFile)) {
206
+ fileMap.set(currentFile, [])
207
+ }
208
+
209
+ fileStartLine = i + 2 // 跳过 ---/+++ 行
210
+ i++ // 跳过 +++ 行
211
+ currentHunk = { file: currentFile, hunks: [], startLine: fileStartLine }
212
+ continue
213
+ }
214
+
215
+ // Hunk header
216
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
217
+ if (hunkMatch && currentFile !== null) {
218
+ // 保存上一个 hunk
219
+ if (currentHunk && currentHunk.hunks.length > 0) {
220
+ fileMap.get(currentFile).push(currentHunk)
221
+ }
222
+
223
+ // 开始新 hunk
224
+ currentHunk = {
225
+ oldStart: parseInt(hunkMatch[1], 10),
226
+ oldCount: parseInt(hunkMatch[2] || '1', 10),
227
+ newStart: parseInt(hunkMatch[3], 10),
228
+ newCount: parseInt(hunkMatch[4] || '1', 10),
229
+ lines: [],
230
+ headerLine: line,
231
+ file: currentFile,
232
+ startLine: i
233
+ }
234
+ fileMap.get(currentFile).push(currentHunk)
235
+ continue
236
+ }
237
+
238
+ // diff 内容行
239
+ if (currentHunk) {
240
+ const diffLine = parseDiffLine(line, currentHunk)
241
+ currentHunk.lines.push(diffLine)
242
+ }
243
+ }
244
+
245
+ // 保存最后一个 hunk
246
+ if (currentHunk && currentHunk.lines.length > 0) {
247
+ fileMap.get(currentFile)?.push(currentHunk)
248
+ }
249
+
250
+ return fileMap
251
+ }
252
+
253
+ /**
254
+ * 验证文件路径是否存在
255
+ * @param {Map<string, Array>} fileMap
256
+ * @param {string} path
257
+ */
258
+ export function fileExistsInDiff(fileMap, path) {
259
+ return fileMap.has(path)
260
+ }
261
+
262
+ /**
263
+ * 获取文件的所有 hunks(包含 empty hunks)
264
+ */
265
+ export function getHunksForFileRaw(fileMap, filePath) {
266
+ return fileMap.get(filePath) || []
267
+ }
268
+
269
+ /**
270
+ * 统计文件修改情况
271
+ * @param {Array} hunks
272
+ * @returns {{ additions: number, deletions: number, changes: number }}
273
+ */
274
+ export function countFileChanges(hunks) {
275
+ let additions = 0
276
+ let deletions = 0
277
+
278
+ for (const hunk of hunks) {
279
+ for (const line of hunk.lines) {
280
+ if (line.type === '+') additions++
281
+ else if (line.type === '-') deletions++
282
+ }
283
+ }
284
+
285
+ return {
286
+ additions,
287
+ deletions,
288
+ changes: additions + deletions
289
+ }
290
+ }
291
+
292
+ // 测试辅助函数:打印解析结果
293
+ export function debugPrintHunks(hunks) {
294
+ for (const hunk of hunks) {
295
+ console.log(`Hunk: @@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`)
296
+ for (const line of hunk.lines) {
297
+ const marker = line.type === ' ' ? ' ' : line.type
298
+ console.log(` ${marker} ${line.newLineNum || ''} | ${line.content}`)
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 实用函数:将行号映射为 GitHub API 的 position
305
+ * 注意:position 是从 diff 文件开头计数的行号(包括 @@ headers)
306
+ */
307
+ export function mapLineToPosition(diffText, filePath, lineNum) {
308
+ const fileMap = splitDiffByFile(diffText)
309
+ const hunks = getHunksForFileRaw(fileMap, filePath)
310
+ return getPositionInDiff(hunks, lineNum)
311
+ }
312
+
313
+ /**
314
+ * 实用函数:找出 diff 中新增的代码块的位置范围
315
+ */
316
+ export function findAddedLines(hunks) {
317
+ const ranges = []
318
+ for (const hunk of hunks) {
319
+ for (const line of hunk.lines) {
320
+ if (line.type === '+' && line.newLineNum) {
321
+ ranges.push({
322
+ start: line.newLineNum,
323
+ end: line.newLineNum,
324
+ content: line.content
325
+ })
326
+ }
327
+ }
328
+ }
329
+ return ranges
330
+ }
package/src/mcp/client.js CHANGED
@@ -212,7 +212,7 @@ export class MCPClient {
212
212
  }
213
213
 
214
214
  /** 发送 JSON-RPC 请求 */
215
- _sendRequest(method, params) {
215
+ _sendRequest(method, params, timeoutMs = 30000) {
216
216
  return new Promise((resolve, reject) => {
217
217
  const id = nextId()
218
218
  const message = JSON.stringify({
@@ -222,11 +222,19 @@ export class MCPClient {
222
222
  params,
223
223
  })
224
224
 
225
- this.pending.set(id, { resolve, reject })
225
+ // M8 fix: 请求超时保护,防止 MCP 服务器无响应时永远挂起
226
+ const timer = setTimeout(() => {
227
+ if (this.pending.has(id)) {
228
+ this.pending.delete(id)
229
+ reject(new Error(`MCP request timeout: ${method} (${timeoutMs}ms)`))
230
+ }
231
+ }, timeoutMs)
232
+ this.pending.set(id, { resolve, reject, timer })
226
233
 
227
234
  // 每条消息以换行符分隔
228
235
  this.process.stdin.write(message + '\n', (err) => {
229
236
  if (err) {
237
+ clearTimeout(timer)
230
238
  this.pending.delete(id)
231
239
  reject(new Error(`Failed to send message: ${err.message}`))
232
240
  }
@@ -255,8 +263,10 @@ export class MCPClient {
255
263
  const message = JSON.parse(line)
256
264
 
257
265
  if (message.id && this.pending.has(message.id)) {
258
- const { resolve, reject } = this.pending.get(message.id)
266
+ const { resolve, reject, timer } = this.pending.get(message.id)
259
267
  this.pending.delete(message.id)
268
+ // M8 fix: 清除超时定时器
269
+ if (timer) clearTimeout(timer)
260
270
 
261
271
  if (message.error) {
262
272
  reject(new Error(message.error.message || 'MCP error'))
@@ -8,6 +8,7 @@
8
8
  * - 双重编码绕过检测(%252e 等)
9
9
  */
10
10
  import { resolve, normalize, isAbsolute, relative, sep } from 'path'
11
+ import { realpathSync } from 'fs'
11
12
 
12
13
  /**
13
14
  * 敏感路径列表 — 禁止读写
@@ -53,7 +54,16 @@ export function sanitizePath(filePath, cwd = process.cwd()) {
53
54
  // 如果是相对路径,基于 cwd 解析
54
55
  const absPath = isAbsolute(decoded) ? decoded : resolve(cwd, decoded)
55
56
  // 规范化:消除 .. 和 .
56
- return normalize(absPath)
57
+ const normalized = normalize(absPath)
58
+
59
+ // M6 fix: 解析符号链接,防止通过 symlink 绕过路径安全检查
60
+ // 例如: /tmp/link → /etc/shadow
61
+ try {
62
+ return realpathSync(normalized)
63
+ } catch {
64
+ // 文件不存在时 realpathSync 会抛错,返回规范化路径即可
65
+ return normalized
66
+ }
57
67
  }
58
68
 
59
69
  /**
@@ -0,0 +1,308 @@
1
+ /**
2
+ * GitTool - GitHub PR 自动化管理工具
3
+ * 符合 ToolDef 接口规范
4
+ */
5
+
6
+ import { GitHubAPI, createGitHubAPI } from '../git/github-api.js'
7
+ import { PRReviewer } from '../git/pr-reviewer.js'
8
+ import { PRMergePolicy } from '../git/pr-merge-policy.js'
9
+ import { ToolDef } from '../types/index.js'
10
+
11
+ const TOOL_NAME = 'GitTool'
12
+ const TOOL_DESCRIPTION = `
13
+ GitHub PR 自动化管理工具。
14
+
15
+ 前置条件:
16
+ - 设置 GITHUB_TOKEN 环境变量(有 repo 权限)
17
+ - 设置 GITHUB_OWNER 和 GITHUB_REPO(或在 ~/.claude-code/config.json 中配置)
18
+ - 可选:DEEPSEEK_API_KEY for LLM 智能分析
19
+
20
+ 主要功能:
21
+ - 列出 PR(list-prs)
22
+ - 获取 PR 详情(get-pr)
23
+ - 自动审查(review-pr):代码质量、安全、测试、文档
24
+ - 智能合并(merge-pr):策略检查
25
+ - 评论、approve、request changes
26
+ - 批量操作(auto-review-all, auto-merge-eligible)
27
+ `
28
+
29
+ const TOOL_PARAMETERS = {
30
+ type: 'object',
31
+ properties: {
32
+ action: {
33
+ type: 'string',
34
+ enum: [
35
+ 'list-prs',
36
+ 'get-pr',
37
+ 'review-pr',
38
+ 'merge-pr',
39
+ 'auto-review-all',
40
+ 'comment',
41
+ 'approve',
42
+ 'request-changes',
43
+ 'check-mergeable',
44
+ 'auto-merge-eligible'
45
+ ],
46
+ description: '要执行的操作'
47
+ },
48
+ owner: { type: 'string', description: 'GitHub repository owner' },
49
+ repo: { type: 'string', description: 'GitHub repository name' },
50
+ prNumber: { type: 'number', description: 'PR number' },
51
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'PR state filter' },
52
+ head: { type: 'string', description: 'Filter by head branch' },
53
+ base: { type: 'string', description: 'Filter by base branch' },
54
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label filter' },
55
+ limit: { type: 'number', description: 'Max PRs to fetch' },
56
+ checks: {
57
+ type: 'array',
58
+ items: { type: 'string', enum: ['code-quality', 'security', 'tests', 'docs', 'complexity', 'duplication'] },
59
+ description: '检查项列表'
60
+ },
61
+ analyzeLLM: { type: 'boolean', description: '是否使用 LLM 智能分析' },
62
+ commentThreshold: { type: 'string', enum: ['INFO', 'WARNING', 'ERROR'], description: '评论阈值' },
63
+ autoComment: { type: 'boolean', description: '是否自动发表审查评论' },
64
+ method: { type: 'string', enum: ['merge', 'squash', 'rebase'], description: 'Merge method' },
65
+ body: { type: 'string', description: 'Comment body' },
66
+ label: { type: 'string', description: 'Label filter for batch operations' }
67
+ },
68
+ required: ['action']
69
+ }
70
+
71
+ /**
72
+ * 内部 GitTool 类(无状态)
73
+ */
74
+ class GitTool {
75
+ constructor(config = {}) {
76
+ this.config = config
77
+ this.github = null
78
+ this.reviewer = null
79
+ this.mergePolicy = null
80
+ }
81
+
82
+ ensureGitHubClient(options = {}) {
83
+ if (this.github) return
84
+
85
+ const owner = options.owner || this.config.owner || this._readConfig('github.owner')
86
+ const repo = options.repo || this.config.repo || this._readConfig('github.repo')
87
+
88
+ if (!owner || !repo) {
89
+ throw new Error('GitHub owner and repo required. Set in config or pass as parameters.')
90
+ }
91
+
92
+ this.github = createGitHubAPI({
93
+ token: process.env.GITHUB_TOKEN,
94
+ owner,
95
+ repo,
96
+ baseUrl: this.config.baseUrl
97
+ })
98
+
99
+ const policyConfig = this.config.mergePolicy || {}
100
+ this.reviewer = new PRReviewer(this.github, this.config.reviewRules, {
101
+ enableLLM: this.config.enableLLM,
102
+ apiConfig: this.config.llm
103
+ })
104
+ this.mergePolicy = new PRMergePolicy(this.github, policyConfig)
105
+ }
106
+
107
+ async execute(params) {
108
+ const { action } = params
109
+ switch (action) {
110
+ case 'list-prs': return this.listPRs(params)
111
+ case 'get-pr': return this.getPR(params)
112
+ case 'review-pr': return this.reviewPR(params)
113
+ case 'merge-pr': return this.mergePR(params)
114
+ case 'auto-review-all': return this.autoReviewAll(params)
115
+ case 'comment': return this.comment(params)
116
+ case 'approve': return this.approve(params)
117
+ case 'request-changes': return this.requestChanges(params)
118
+ case 'check-mergeable': return this.checkMergeable(params)
119
+ case 'auto-merge-eligible': return this.autoMergeEligible(params)
120
+ default: throw new Error(`Unknown action: ${action}`)
121
+ }
122
+ }
123
+
124
+ async listPRs({ state = 'open', head, base, labels, limit = 50 }) {
125
+ this.ensureGitHubClient()
126
+ if (labels && !Array.isArray(labels)) {
127
+ throw new Error('labels must be an array of strings')
128
+ }
129
+ const query = { state, per_page: Math.min(limit, 100) }
130
+ if (head) query.head = head
131
+ if (base) query.base = base
132
+ if (labels?.length) query.labels = labels.join(',')
133
+ const prs = await this.github.listPRs(query)
134
+ const prList = Array.isArray(prs) ? prs : []
135
+ return {
136
+ count: prList.length,
137
+ prs: prList.map(pr => ({
138
+ number: pr.number,
139
+ title: pr.title,
140
+ state: pr.state,
141
+ user: pr.user?.login,
142
+ head: pr.head?.ref,
143
+ base: pr.base?.ref,
144
+ createdAt: pr.created_at,
145
+ updatedAt: pr.updated_at,
146
+ comments: pr.comments,
147
+ labels: pr.labels?.map(l => l.name) || []
148
+ }))
149
+ }
150
+ }
151
+
152
+ async getPR({ prNumber }) {
153
+ this.ensureGitHubClient()
154
+ const pr = await this.github.getPR(prNumber)
155
+ const files = await this.github.getPRFiles(prNumber)
156
+ const reviews = await this.github.listReviews(prNumber)
157
+ return {
158
+ number: pr.number,
159
+ title: pr.title,
160
+ body: pr.body,
161
+ user: pr.user?.login,
162
+ head: pr.head?.ref,
163
+ base: pr.base?.ref,
164
+ mergeable: pr.mergeable,
165
+ changedFiles: pr.changed_files,
166
+ additions: pr.additions,
167
+ deletions: pr.deletions,
168
+ files: files.map(f => ({ filename: f.filename, additions: f.additions, deletions: f.deletions })),
169
+ reviews: reviews.map(r => ({ user: r.user?.login, state: r.state }))
170
+ }
171
+ }
172
+
173
+ async reviewPR({ prNumber, checks, analyzeLLM = true, commentThreshold = 'WARNING', autoComment = false }) {
174
+ this.ensureGitHubClient()
175
+ const reviewRules = {}
176
+ if (checks) {
177
+ reviewRules.checks = {}
178
+ checks.forEach(c => reviewRules.checks[c] = true)
179
+ }
180
+ const reviewer = new PRReviewer(this.github, reviewRules, {
181
+ enableLLM: this.config.enableLLM,
182
+ apiConfig: this.config.llm
183
+ })
184
+ const result = await reviewer.reviewPR(prNumber, { analyzeLLM, commentThreshold })
185
+ if (autoComment && result.comments.length > 0) {
186
+ const pr = await this.github.getPR(prNumber)
187
+ const summary = result.comments.find(c => !c.position)
188
+ if (summary) {
189
+ await this.github.createReview(prNumber, summary.body)
190
+ }
191
+ }
192
+ return result
193
+ }
194
+
195
+ async mergePR({ prNumber, method }) {
196
+ if (!prNumber) throw new Error('prNumber required')
197
+ this.ensureGitHubClient()
198
+ return await this.mergePolicy.merge(prNumber, { method })
199
+ }
200
+
201
+ async autoReviewAll({ labelFilter, limit = 50 }) {
202
+ this.ensureGitHubClient()
203
+ const prs = await this.github.listPRs({ state: 'open', per_page: limit })
204
+ const results = []
205
+ const prList = Array.isArray(prs) ? prs : []
206
+ for (const pr of prList) {
207
+ if (labelFilter && !pr.labels?.some(l => l.name === labelFilter)) continue
208
+ try {
209
+ const review = await this.reviewer.reviewPR(pr.number, { commentThreshold: 'WARNING' })
210
+ results.push({ prNumber: pr.number, title: pr.title, findings: review.findings.length, status: 'reviewed' })
211
+ } catch (e) {
212
+ results.push({ prNumber: pr.number, title: pr.title, status: 'error', error: e.message })
213
+ }
214
+ }
215
+ return { total: prList.length, reviewed: results.filter(r => r.status === 'reviewed').length, errors: results.filter(r => r.status === 'error'), details: results }
216
+ }
217
+
218
+ async comment({ prNumber, body, path, line }) {
219
+ if (!prNumber || !body) throw new Error('prNumber and body required')
220
+ this.ensureGitHubClient()
221
+
222
+ if (path && line) {
223
+ // Get PR diff, parse and find the position for the specific line
224
+ const pr = await this.github.getPR(prNumber)
225
+ const diffText = await this.github.getPRDiff(prNumber)
226
+
227
+ // Import diff parser dynamically (ESM)
228
+ const { splitDiffByFile, getPositionInDiff, getHunksForFileRaw } = await import('../git/utils/diff-parser.js')
229
+
230
+ const fileMap = splitDiffByFile(diffText)
231
+ const hunks = getHunksForFileRaw(fileMap, path)
232
+
233
+ if (!hunks || hunks.length === 0) {
234
+ throw new Error('File ' + path + ' not found in PR diff')
235
+ }
236
+
237
+ const position = getPositionInDiff(hunks, line)
238
+ if (position === null) {
239
+ throw new Error('Line ' + line + ' not found in diff for ' + path)
240
+ }
241
+
242
+ // Use latest commit SHA as commit_id
243
+ const commitId = pr.head?.sha || null
244
+
245
+ return await this.github.createComment(prNumber, body, { path, position, commitId })
246
+ }
247
+
248
+ return await this.github.createReview(prNumber, body)
249
+ }
250
+
251
+ async approve({ prNumber, body }) {
252
+ this.ensureGitHubClient()
253
+ if (!prNumber) throw new Error('prNumber required')
254
+ return await this.github.approvePR(prNumber, body || 'Approved')
255
+ }
256
+
257
+ async requestChanges({ prNumber, body }) {
258
+ this.ensureGitHubClient()
259
+ if (!prNumber) throw new Error('prNumber required')
260
+ return await this.github.requestChanges(prNumber, body || 'Changes requested')
261
+ }
262
+
263
+ async checkMergeable({ prNumber }) {
264
+ this.ensureGitHubClient()
265
+ return await this.mergePolicy.checkMergeable(prNumber)
266
+ }
267
+
268
+ async autoMergeEligible({ label = 'auto-merge', limit = 50 }) {
269
+ this.ensureGitHubClient()
270
+ const prs = await this.github.listPRs({ state: 'open', per_page: limit, labels: label })
271
+ const eligible = []
272
+ const prList = Array.isArray(prs) ? prs : []
273
+ for (const pr of prList) {
274
+ try {
275
+ const check = await this.mergePolicy.checkMergeable(pr.number)
276
+ if (check.mergeable) eligible.push({ pr, check })
277
+ } catch (e) {
278
+ console.error(`Check failed for PR ${pr.number}:`, e.message)
279
+ }
280
+ }
281
+ return { label, total: prList.length, eligible: eligible.length, items: eligible.map(e => ({ number: e.pr.number, title: e.pr.title })) }
282
+ }
283
+
284
+ _readConfig(path) {
285
+ try {
286
+ const configPath = `${process.env.HOME}/.claude-code/config.json`
287
+ const data = require('fs').readFileSync(configPath, 'utf-8')
288
+ const config = JSON.parse(data)
289
+ return path.split('.').reduce((obj, key) => obj?.[key], config)
290
+ } catch { return null }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * ToolDef 工厂函数
296
+ */
297
+ function createToolDef(config = {}) {
298
+ const tool = new GitTool(config)
299
+ return new ToolDef(TOOL_NAME, TOOL_DESCRIPTION, TOOL_PARAMETERS, (input, ctx) => tool.execute(input), 'high')
300
+ }
301
+
302
+ /**
303
+ * 导出 ToolDef 实例
304
+ */
305
+ export const gitTool = createToolDef()
306
+
307
+ export { GitTool, createToolDef }
308
+ export default gitTool