@raolin2025/claude-code-node 2.1.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.
- package/README.md +117 -0
- package/package.json +1 -1
- package/src/__tests__/git-tool.integration.test.js +89 -0
- package/src/__tests__/git-tool.test.js +144 -0
- package/src/git/github-api.js +360 -0
- package/src/git/index.js +37 -0
- package/src/git/llm-assistant.js +83 -0
- package/src/git/pr-merge-policy.js +367 -0
- package/src/git/pr-reviewer.js +533 -0
- package/src/git/utils/diff-parser.js +330 -0
- package/src/tools/git-tool.js +308 -0
- package/src/tools/index.js +2 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
package/src/tools/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { grepTool } from './grep.js'
|
|
|
10
10
|
import { webFetchTool } from './web-fetch.js'
|
|
11
11
|
import { webSearchTool } from './web-search.js'
|
|
12
12
|
import { askUserTool } from './ask-user.js'
|
|
13
|
+
import { gitTool } from './git-tool.js'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* 所有内置工具列表
|
|
@@ -24,6 +25,7 @@ export const builtinTools = [
|
|
|
24
25
|
webFetchTool,
|
|
25
26
|
webSearchTool,
|
|
26
27
|
askUserTool,
|
|
28
|
+
gitTool,
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
/**
|