@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.
- 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/channel/notify-daemon.js +16 -13
- package/src/core/cli.js +45 -5
- package/src/core/query-engine.js +57 -54
- package/src/core/session.js +3 -1
- 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/mcp/client.js +13 -3
- package/src/security/path-guard.js +11 -1
- package/src/tools/git-tool.js +308 -0
- package/src/tools/index.js +2 -0
- package/src/tools/web-fetch.js +3 -1
package/src/core/query-engine.js
CHANGED
|
@@ -43,6 +43,8 @@ export class QueryEngineConfig {
|
|
|
43
43
|
this.costTracker = options.costTracker || null
|
|
44
44
|
this.tokenBudget = options.tokenBudget || null
|
|
45
45
|
this.initialMessages = options.initialMessages || []
|
|
46
|
+
this.onConfirmTool = options.onConfirmTool || null // ask 模式确认回调
|
|
47
|
+
this.readline = options.readline || null // 用于 AskUserQuestion 工具
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -196,39 +198,61 @@ export class QueryEngine {
|
|
|
196
198
|
|
|
197
199
|
|
|
198
200
|
/**
|
|
199
|
-
* 执行工具调用
|
|
201
|
+
* 执行工具调用 — 两阶段策略
|
|
202
|
+
* 阶段1(串行):安全检查 + ask 模式确认(需要用户交互,必须串行)
|
|
203
|
+
* 阶段2(并行):批准后的工具并行执行,互不依赖的工具同时跑
|
|
200
204
|
*/
|
|
201
205
|
async _executeToolCalls(toolCalls) {
|
|
202
|
-
|
|
206
|
+
// 阶段1:串行安全检查
|
|
207
|
+
const approved = []
|
|
203
208
|
for (const tc of toolCalls) {
|
|
204
|
-
// 安全检查
|
|
205
209
|
const permResult = await this.permissionChecker.check(tc.name, tc.input)
|
|
206
210
|
if (!permResult.allowed) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
if (permResult.requiresConfirmation && this.config.onConfirmTool) {
|
|
212
|
+
const confirmed = await this.config.onConfirmTool(tc.name, tc.input)
|
|
213
|
+
if (!confirmed) {
|
|
214
|
+
approved.push({ tc, error: '用户未确认' })
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
approved.push({ tc, error: `安全策略拒绝: ${permResult.reason || ""}` })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
210
221
|
}
|
|
211
222
|
|
|
212
|
-
// 查找工具
|
|
213
223
|
const tool = this.config.tools.find(t => t.name === tc.name)
|
|
214
224
|
if (!tool) {
|
|
215
|
-
|
|
216
|
-
results[results.length - 1].toolName = tc.name
|
|
225
|
+
approved.push({ tc, error: `未找到工具: ${tc.name}` })
|
|
217
226
|
continue
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
approved.push({ tc, tool })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 阶段2:并行执行已批准的工具
|
|
233
|
+
const execPromises = approved.map(async (item) => {
|
|
234
|
+
if (item.error) {
|
|
235
|
+
const r = new ToolResult(item.tc.id, item.error, true)
|
|
236
|
+
r.toolName = item.tc.name
|
|
237
|
+
return r
|
|
238
|
+
}
|
|
239
|
+
const { tc, tool } = item
|
|
240
|
+
tc.status = 'running'
|
|
220
241
|
try {
|
|
221
|
-
tc.
|
|
222
|
-
const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this })
|
|
242
|
+
const content = await tool.handler(tc.input, { cwd: this.config.cwd, engine: this, readline: this.config.readline })
|
|
223
243
|
tc.status = 'done'
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
const r = new ToolResult(tc.id, typeof content === 'string' ? content : JSON.stringify(content), false)
|
|
245
|
+
r.toolName = tc.name
|
|
246
|
+
return r
|
|
226
247
|
} catch (err) {
|
|
227
248
|
tc.status = 'error'
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
const r = new ToolResult(tc.id, `工具执行错误: ${err.message}`, true)
|
|
250
|
+
r.toolName = tc.name
|
|
251
|
+
return r
|
|
230
252
|
}
|
|
231
|
-
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const results = await Promise.all(execPromises)
|
|
232
256
|
return results
|
|
233
257
|
}
|
|
234
258
|
|
|
@@ -289,6 +313,12 @@ export class QueryEngine {
|
|
|
289
313
|
|
|
290
314
|
// 带重试的 fetch
|
|
291
315
|
const maxRetries = 3
|
|
316
|
+
// Jitter 退避 — 指数退避 + 随机 ±50%,防止惊群效应
|
|
317
|
+
const retryDelay = (baseMs, attempt) => {
|
|
318
|
+
const ms = baseMs * Math.pow(2, attempt - 1)
|
|
319
|
+
const jitter = ms * (0.5 + Math.random() * 0.5) // 50%-100% of base
|
|
320
|
+
return Math.round(jitter)
|
|
321
|
+
}
|
|
292
322
|
let lastError = null
|
|
293
323
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
294
324
|
try {
|
|
@@ -310,7 +340,7 @@ export class QueryEngine {
|
|
|
310
340
|
const errText = await response.text()
|
|
311
341
|
// 429/503 可重试
|
|
312
342
|
if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
|
|
313
|
-
const waitMs = response.status === 429 ? 2000
|
|
343
|
+
const waitMs = retryDelay(response.status === 429 ? 2000 : 1000, attempt)
|
|
314
344
|
if (this.config.verbose) {
|
|
315
345
|
console.error(`[retry] API ${response.status}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
316
346
|
}
|
|
@@ -322,11 +352,17 @@ export class QueryEngine {
|
|
|
322
352
|
|
|
323
353
|
// 流式或非流式处理
|
|
324
354
|
if (useStream && response.body) {
|
|
325
|
-
|
|
355
|
+
const result = await this._handleStreamResponse(response)
|
|
356
|
+
if (result.usage && this.costTracker) {
|
|
357
|
+
this.costTracker.recordUsage(result.usage)
|
|
358
|
+
}
|
|
359
|
+
if (this.tokenBudget && result.usage) {
|
|
360
|
+
this.tokenBudget.recordUsage(result.usage)
|
|
361
|
+
}
|
|
362
|
+
return result
|
|
326
363
|
} else {
|
|
327
364
|
const data = await response.json()
|
|
328
365
|
const result = parseNonStreamResponse(data)
|
|
329
|
-
// M4: 记录非流式响应费用
|
|
330
366
|
if (result.usage && this.costTracker) {
|
|
331
367
|
this.costTracker.recordUsage(result.usage)
|
|
332
368
|
}
|
|
@@ -339,7 +375,7 @@ export class QueryEngine {
|
|
|
339
375
|
lastError = err
|
|
340
376
|
// 网络错误重试
|
|
341
377
|
if (err.name !== 'AbortError' && attempt < maxRetries && !err.message.startsWith('API 错误')) {
|
|
342
|
-
const waitMs = 1000
|
|
378
|
+
const waitMs = retryDelay(1000, attempt)
|
|
343
379
|
if (this.config.verbose) {
|
|
344
380
|
console.error(`[retry] Network error: ${err.message}, waiting ${waitMs}ms (attempt ${attempt}/${maxRetries})`)
|
|
345
381
|
}
|
|
@@ -394,42 +430,9 @@ export class QueryEngine {
|
|
|
394
430
|
return result
|
|
395
431
|
}
|
|
396
432
|
|
|
397
|
-
/**
|
|
398
|
-
* 解析 OpenAI 兼容响应
|
|
399
|
-
*/
|
|
400
|
-
_parseResponse(data) {
|
|
401
|
-
const result = { content: '', toolCalls: [] }
|
|
402
|
-
const choice = data.choices?.[0]
|
|
403
|
-
if (!choice) return result
|
|
404
|
-
|
|
405
|
-
const message = choice.message
|
|
406
|
-
if (message.content) {
|
|
407
|
-
result.content = message.content
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
for (const tc of (message.tool_calls || [])) {
|
|
411
|
-
let input = {}
|
|
412
|
-
try {
|
|
413
|
-
input = JSON.parse(tc.function.arguments || '{}')
|
|
414
|
-
} catch {
|
|
415
|
-
input = { _raw: tc.function.arguments }
|
|
416
|
-
}
|
|
417
|
-
result.toolCalls.push(new ToolCall(tc.id, tc.function.name, input))
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// M4: 记录 API 调用费用
|
|
421
|
-
if (result.usage && this.costTracker) {
|
|
422
|
-
this.costTracker.recordUsage(result.usage)
|
|
423
|
-
}
|
|
424
|
-
if (this.tokenBudget && result.usage) {
|
|
425
|
-
this.tokenBudget.recordUsage(result.usage)
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return result
|
|
429
|
-
}
|
|
430
|
-
|
|
431
433
|
/** 格式化内容 */
|
|
432
434
|
_formatContent(content) {
|
|
435
|
+
if (content == null) return ''
|
|
433
436
|
if (typeof content === 'string') return content
|
|
434
437
|
if (typeof content === 'object') return JSON.stringify(content)
|
|
435
438
|
return String(content)
|
package/src/core/session.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFile, writeFile, mkdir, readdir, rm, chmod } from 'fs/promises'
|
|
6
6
|
import { resolve, join } from 'path'
|
|
7
|
+
import { randomBytes } from 'crypto'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_SESSIONS_DIR = '.claude-code/sessions'
|
|
9
10
|
|
|
@@ -21,7 +22,8 @@ export class SessionManager {
|
|
|
21
22
|
/** 创建新会话 */
|
|
22
23
|
async create(title = '') {
|
|
23
24
|
await this.ensureDir()
|
|
24
|
-
|
|
25
|
+
// M4 fix: 使用 crypto.randomBytes 生成不可预测的会话 ID
|
|
26
|
+
const id = `session-${Date.now()}-${randomBytes(8).toString('hex')}`
|
|
25
27
|
const session = {
|
|
26
28
|
id,
|
|
27
29
|
title: title || `Session ${new Date().toISOString().slice(0, 19)}`,
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub REST API 封装(零外部依赖)
|
|
3
|
+
* 使用原生 fetch (Node.js ≥ 18)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class GitHubAPI {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} config
|
|
9
|
+
* @param {string} config.token - GitHub Personal Access Token
|
|
10
|
+
* @param {string} config.owner - Repository owner
|
|
11
|
+
* @param {string} config.repo - Repository name
|
|
12
|
+
* @param {string} [config.baseUrl] - API base URL (for enterprise)
|
|
13
|
+
*/
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.token = config.token || process.env.GITHUB_TOKEN
|
|
16
|
+
this.owner = config.owner
|
|
17
|
+
this.repo = config.repo
|
|
18
|
+
this.baseUrl = config.baseUrl || 'https://api.github.com'
|
|
19
|
+
this.apiVersion = '2022-11-28' // GitHub API version
|
|
20
|
+
|
|
21
|
+
if (!this.token) {
|
|
22
|
+
throw new Error('GitHub token required (set GITHUB_TOKEN or pass token param)')
|
|
23
|
+
}
|
|
24
|
+
if (!this.owner || !this.repo) {
|
|
25
|
+
throw new Error('GitHub repository owner and repo required')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 发送请求到 GitHub API
|
|
31
|
+
*/
|
|
32
|
+
async request(endpoint, options = {}) {
|
|
33
|
+
const url = `${this.baseUrl}${endpoint}`
|
|
34
|
+
const headers = {
|
|
35
|
+
'Authorization': `Bearer ${this.token}`,
|
|
36
|
+
'Accept': 'application/vnd.github+json',
|
|
37
|
+
'X-GitHub-Api-Version': this.apiVersion,
|
|
38
|
+
...options.headers
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 速率限制检查
|
|
42
|
+
const shouldRetry = this._shouldRetryAfter(options)
|
|
43
|
+
if (shouldRetry.retry) {
|
|
44
|
+
await this._delay(shouldRetry.after)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
...options,
|
|
49
|
+
headers
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// 更新速率限制信息
|
|
53
|
+
this._updateRateLimitInfo(response)
|
|
54
|
+
|
|
55
|
+
// 处理错误
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const error = await this._parseError(response)
|
|
58
|
+
if (response.status === 403 && this._isRateLimited(response)) {
|
|
59
|
+
throw new GitHubRateLimitError(error.message, response)
|
|
60
|
+
}
|
|
61
|
+
throw new GitHubError(error.message, response.status, error.documentation_url)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 204 No Content
|
|
65
|
+
if (response.status === 204) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return await response.json()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* GET 请求
|
|
74
|
+
*/
|
|
75
|
+
async get(endpoint, params = {}) {
|
|
76
|
+
const fullUrl = `${this.baseUrl}${endpoint}`
|
|
77
|
+
const queryParts = []
|
|
78
|
+
Object.keys(params).forEach(key => {
|
|
79
|
+
if (params[key] !== undefined && params[key] !== null) {
|
|
80
|
+
queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
|
|
84
|
+
|
|
85
|
+
return this.request(`${endpoint}${queryString}`, { method: 'GET' })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* POST 请求
|
|
90
|
+
*/
|
|
91
|
+
async post(endpoint, body) {
|
|
92
|
+
return this.request(endpoint, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
headers: { 'Content-Type': 'application/json' }
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* PUT 请求
|
|
101
|
+
*/
|
|
102
|
+
async put(endpoint, body) {
|
|
103
|
+
return this.request(endpoint, {
|
|
104
|
+
method: 'PUT',
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
headers: { 'Content-Type': 'application/json' }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PATCH 请求
|
|
112
|
+
*/
|
|
113
|
+
async patch(endpoint, body) {
|
|
114
|
+
return this.request(endpoint, {
|
|
115
|
+
method: 'PATCH',
|
|
116
|
+
body: JSON.stringify(body),
|
|
117
|
+
headers: { 'Content-Type': 'application/json' }
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* DELETE 请求
|
|
123
|
+
*/
|
|
124
|
+
async delete(endpoint) {
|
|
125
|
+
return this.request(endpoint, { method: 'DELETE' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============ Pull Requests API ============
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 列出 PRs
|
|
132
|
+
* GET /repos/{owner}/{repo}/pulls
|
|
133
|
+
*/
|
|
134
|
+
async listPRs(params = {}) {
|
|
135
|
+
// 默认只获取 open 状态的 PR
|
|
136
|
+
const defaultParams = { state: 'open', per_page: 100 }
|
|
137
|
+
const query = { ...defaultParams, ...params }
|
|
138
|
+
return this.get(`/repos/${this.owner}/${this.repo}/pulls`, query)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 获取单个 PR
|
|
143
|
+
* GET /repos/{owner}/{repo}/pulls/{pr_number}
|
|
144
|
+
*/
|
|
145
|
+
async getPR(prNumber) {
|
|
146
|
+
return this.get(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 获取 PR 的 diff
|
|
151
|
+
* GET /repos/{owner}/{repo}/pulls/{pr_number}/files
|
|
152
|
+
*/
|
|
153
|
+
async getPRFiles(prNumber) {
|
|
154
|
+
return this.get(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/files`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 获取 PR 提交的 diff (未压缩)
|
|
159
|
+
*/
|
|
160
|
+
async getPRDiff(prNumber) {
|
|
161
|
+
const endpoint = `/repos/${this.owner}/${this.repo}/pulls/${prNumber}`
|
|
162
|
+
// 使用统一的 request 方法,但走 diff 类型的 Accept
|
|
163
|
+
// 单独 fetch diff,因为需要 text() 而非 json()
|
|
164
|
+
const diffUrl = `${this.baseUrl}/repos/${this.owner}/${this.repo}/pulls/${prNumber}.diff`
|
|
165
|
+
const diffResponse = await fetch(diffUrl, {
|
|
166
|
+
method: 'GET',
|
|
167
|
+
headers: {
|
|
168
|
+
'Authorization': `Bearer ${this.token}`,
|
|
169
|
+
'Accept': 'application/vnd.github.v3.diff'
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!diffResponse.ok) {
|
|
174
|
+
const errMsg = `Failed to fetch PR diff: ${diffResponse.status} ${diffResponse.statusText}`
|
|
175
|
+
throw new GitHubError(errMsg, diffResponse.status)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return await diffResponse.text()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 创建 PR 评论
|
|
183
|
+
* POST /repos/{owner}/{repo}/pulls/{pr_number}/reviews
|
|
184
|
+
*/
|
|
185
|
+
async createReview(prNumber, body, event = 'COMMENT', comments = []) {
|
|
186
|
+
return this.post(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/reviews`, {
|
|
187
|
+
body,
|
|
188
|
+
event, // 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'
|
|
189
|
+
comments // [{ path, position, body }]
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 在 PR diff 的特定行添加评论
|
|
195
|
+
* POST /repos/{owner}/{repo}/pulls/{pr_number}/comments
|
|
196
|
+
*/
|
|
197
|
+
async createComment(prNumber, body, { path, position, commitId }) {
|
|
198
|
+
return this.post(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/comments`, {
|
|
199
|
+
body,
|
|
200
|
+
path,
|
|
201
|
+
position,
|
|
202
|
+
commit_id: commitId
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 更新 PR 评论(回复)
|
|
208
|
+
* PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}
|
|
209
|
+
*/
|
|
210
|
+
async updateComment(commentId, body) {
|
|
211
|
+
return this.patch(`/repos/${this.owner}/${this.repo}/pulls/comments/${commentId}`, { body })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 删除 PR 评论
|
|
216
|
+
* DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}
|
|
217
|
+
*/
|
|
218
|
+
async deleteComment(commentId) {
|
|
219
|
+
return this.delete(`/repos/${this.owner}/${this.repo}/pulls/comments/${commentId}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 获取 PR 的评论线程
|
|
224
|
+
* GET /repos/{owner}/{repo}/pulls/{pr_number}/comments
|
|
225
|
+
*/
|
|
226
|
+
async listPRComments(prNumber) {
|
|
227
|
+
return this.get(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/comments`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Approve PR
|
|
232
|
+
*/
|
|
233
|
+
async approvePR(prNumber, body) {
|
|
234
|
+
return this.createReview(prNumber, body || 'Approved', 'APPROVE')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Request changes on PR
|
|
239
|
+
*/
|
|
240
|
+
async requestChanges(prNumber, body) {
|
|
241
|
+
return this.createReview(prNumber, body || 'Changes requested', 'REQUEST_CHANGES')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 合并 PR
|
|
246
|
+
* POST /repos/{owner}/{repo}/pulls/{pr_number}/merge
|
|
247
|
+
*/
|
|
248
|
+
async mergePR(prNumber, options = {}) {
|
|
249
|
+
const { commitTitle, commitMessage, mergeMethod = 'merge' } = options
|
|
250
|
+
return this.post(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/merge`, {
|
|
251
|
+
commit_title: commitTitle,
|
|
252
|
+
commit_message: commitMessage,
|
|
253
|
+
merge_method: mergeMethod // 'merge' | 'squash' | 'rebase'
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 检查 PR 是否可合并
|
|
259
|
+
* GET /repos/{owner}/{repo}/pulls/{pr_number}/mergeable
|
|
260
|
+
*/
|
|
261
|
+
async isMergeable(prNumber) {
|
|
262
|
+
const pr = await this.getPR(prNumber)
|
|
263
|
+
// GitHub 返回 null 表示还在计算中,不视为不可合并
|
|
264
|
+
return pr.mergeable !== false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 获取 PR 的状态检查(CI checks)
|
|
269
|
+
* GET /repos/{owner}/{repo}/commits/{commit_sha}/status
|
|
270
|
+
*/
|
|
271
|
+
/**
|
|
272
|
+
* 获取 PR 的合并状态(combine status)
|
|
273
|
+
* GET /repos/{owner}/{repo}/commits/{commit_sha}/status
|
|
274
|
+
*/
|
|
275
|
+
async getCombinedStatus(commitSha) {
|
|
276
|
+
if (!commitSha) {
|
|
277
|
+
throw new Error('commitSha is required for getCombinedStatus')
|
|
278
|
+
}
|
|
279
|
+
return this.get(`/repos/${this.owner}/${this.repo}/commits/${commitSha}/status`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 列出 PR 的审查评论(reviews)
|
|
284
|
+
* GET /repos/{owner}/{repo}/pulls/{pr_number}/reviews
|
|
285
|
+
*/
|
|
286
|
+
async listReviews(prNumber) {
|
|
287
|
+
return this.get(`/repos/${this.owner}/${this.repo}/pulls/${prNumber}/reviews`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============ 辅助方法 ============
|
|
291
|
+
|
|
292
|
+
_updateRateLimitInfo(response) {
|
|
293
|
+
this.rateLimitRemaining = parseInt(response.headers.get('X-RateLimit-Remaining'), 10)
|
|
294
|
+
this.rateLimitReset = parseInt(response.headers.get('X-RateLimit-Reset'), 10) // Unix timestamp
|
|
295
|
+
this.rateLimitLimit = parseInt(response.headers.get('X-RateLimit-Limit'), 10)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_isRateLimited(response) {
|
|
299
|
+
return response.status === 403 && response.headers.get('X-RateLimit-Remaining') === '0'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_shouldRetryAfter(options) {
|
|
303
|
+
if (options._retryCount && options._retryCount > 3) {
|
|
304
|
+
return { retry: false }
|
|
305
|
+
}
|
|
306
|
+
if (this.rateLimitRemaining === 0 && this.rateLimitReset) {
|
|
307
|
+
const now = Math.floor(Date.now() / 1000)
|
|
308
|
+
const waitSeconds = Math.max(this.rateLimitReset - now + 1, 1)
|
|
309
|
+
return { retry: true, after: waitSeconds * 1000 }
|
|
310
|
+
}
|
|
311
|
+
return { retry: false }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_delay(ms) {
|
|
315
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async _parseError(response) {
|
|
319
|
+
let message = `${response.status} ${response.statusText}`
|
|
320
|
+
let documentationUrl = null
|
|
321
|
+
try {
|
|
322
|
+
const data = await response.json()
|
|
323
|
+
message = data.message || message
|
|
324
|
+
documentationUrl = data.documentation_url || null
|
|
325
|
+
} catch {
|
|
326
|
+
// ignore parse error, keep fallback message
|
|
327
|
+
}
|
|
328
|
+
return { message, documentation_url: documentationUrl }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* GitHub API 错误
|
|
334
|
+
*/
|
|
335
|
+
export class GitHubError extends Error {
|
|
336
|
+
constructor(message, status, documentationUrl = null) {
|
|
337
|
+
super(message)
|
|
338
|
+
this.name = 'GitHubError'
|
|
339
|
+
this.status = status
|
|
340
|
+
this.documentationUrl = documentationUrl
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 速率限制错误
|
|
346
|
+
*/
|
|
347
|
+
export class GitHubRateLimitError extends GitHubError {
|
|
348
|
+
constructor(message, response) {
|
|
349
|
+
super(message, response.status)
|
|
350
|
+
this.name = 'GitHubRateLimitError'
|
|
351
|
+
this.resetAt = parseInt(response.headers.get('X-RateLimit-Reset'), 10)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 创建 GitHub API 实例的工厂函数
|
|
357
|
+
*/
|
|
358
|
+
export function createGitHubAPI(options) {
|
|
359
|
+
return new GitHubAPI(options)
|
|
360
|
+
}
|
package/src/git/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git 模块统一导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { GitHubAPI, createGitHubAPI, GitHubError, GitHubRateLimitError } from './github-api.js'
|
|
6
|
+
export { PRReviewer } from './pr-reviewer.js'
|
|
7
|
+
export { PRMergePolicy, DEFAULT_MERGE_POLICY, findEligiblePRs, isPRMergeable } from './pr-merge-policy.js'
|
|
8
|
+
|
|
9
|
+
// Diff 解析工具
|
|
10
|
+
export {
|
|
11
|
+
parseDiff,
|
|
12
|
+
splitDiffByFile,
|
|
13
|
+
getPositionInDiff,
|
|
14
|
+
buildCommentPayload,
|
|
15
|
+
getHunksForFileRaw,
|
|
16
|
+
fileExistsInDiff,
|
|
17
|
+
countFileChanges,
|
|
18
|
+
mapLineToPosition,
|
|
19
|
+
findAddedLines,
|
|
20
|
+
debugPrintHunks
|
|
21
|
+
} from './utils/diff-parser.js'
|
|
22
|
+
|
|
23
|
+
// LLM 助手
|
|
24
|
+
export { callLLM, buildPRReviewPrompt } from './llm-assistant.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 快速创建 GitTool
|
|
28
|
+
*/
|
|
29
|
+
import { gitTool } from '../tools/git-tool.js'
|
|
30
|
+
|
|
31
|
+
export function createGitTool(config = {}) {
|
|
32
|
+
// 这里返回的是一个配置对象,实际 ToolDef 已注册
|
|
33
|
+
return {
|
|
34
|
+
config,
|
|
35
|
+
tool: gitTool
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 助手 - 调用 LLM API 进行智能分析
|
|
3
|
+
* 复用 cc-node 的 LLM 调用模式
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 调用 LLM 分析 PR
|
|
8
|
+
* @param {string} systemPrompt
|
|
9
|
+
* @param {string} userPrompt
|
|
10
|
+
* @param {Object} apiConfig - { apiKey, apiBase, model }
|
|
11
|
+
*/
|
|
12
|
+
export async function callLLM(systemPrompt, userPrompt, apiConfig = {}) {
|
|
13
|
+
const {
|
|
14
|
+
apiKey = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY,
|
|
15
|
+
apiBase = process.env.LLM_API_BASE || 'https://api.deepseek.com/v1',
|
|
16
|
+
model = process.env.LLM_MODEL || 'deepseek-chat'
|
|
17
|
+
} = apiConfig
|
|
18
|
+
|
|
19
|
+
if (!apiKey) {
|
|
20
|
+
throw new Error('LLM API key required (set LLM_API_KEY or DEEPSEEK_API_KEY)')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${apiBase}/chat/completions`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
'Authorization': `Bearer ${apiKey}`
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
model,
|
|
31
|
+
messages: [
|
|
32
|
+
{ role: 'system', content: systemPrompt },
|
|
33
|
+
{ role: 'user', content: userPrompt }
|
|
34
|
+
],
|
|
35
|
+
temperature: 0.3,
|
|
36
|
+
max_tokens: 2000
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const error = await response.json().catch(() => ({}))
|
|
42
|
+
throw new Error(`LLM API error: ${response.status} ${error.message || response.statusText}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = await response.json()
|
|
46
|
+
return data.choices[0].message.content
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PR 智能审查 prompt
|
|
51
|
+
* @param {Object} pr - PR 信息
|
|
52
|
+
* @param {string} diff - diff 内容
|
|
53
|
+
* @param {Array} findings - 已发现的规则检查问题
|
|
54
|
+
*/
|
|
55
|
+
export function buildPRReviewPrompt(pr, diff, findings) {
|
|
56
|
+
const findingsText = findings.map(f => `- ${f.file}:${f.line} [${f.severity}] ${f.message}`).join('\n')
|
|
57
|
+
|
|
58
|
+
return `
|
|
59
|
+
You are an experienced code reviewer. Analyze the following PR and provide a concise review summary.
|
|
60
|
+
|
|
61
|
+
## PR Information
|
|
62
|
+
- Title: ${pr.title}
|
|
63
|
+
- Author: ${pr.user?.login}
|
|
64
|
+
- Files changed: ${pr.changed_files}
|
|
65
|
+
- Additions: ${pr.additions}, Deletions: ${pr.deletions}
|
|
66
|
+
|
|
67
|
+
## Automated Findings
|
|
68
|
+
${findingsText || 'No issues found by automated checks.'}
|
|
69
|
+
|
|
70
|
+
## Diff
|
|
71
|
+
${diff.substring(0, 5000)}... (truncated)
|
|
72
|
+
|
|
73
|
+
## Instructions
|
|
74
|
+
Provide a review summary with:
|
|
75
|
+
1. Overall assessment (POSITIVE / NEEDS_WORK / REJECT)
|
|
76
|
+
2. Key strengths (if any)
|
|
77
|
+
3. Critical issues that must be fixed
|
|
78
|
+
4. Suggestions for improvement
|
|
79
|
+
5. Merging recommendation (YES / NO)
|
|
80
|
+
|
|
81
|
+
Keep it concise (3-5 bullet points).
|
|
82
|
+
`
|
|
83
|
+
}
|