hong-review-cli 1.0.3 → 1.0.6

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.
@@ -55,6 +55,7 @@ configGroup
55
55
  program
56
56
  .command('mr <mr_id>')
57
57
  .description('智能审查指定的 GitLab Merge Request')
58
+ .option('-p, --project <id>', '指定 GitLab Project ID')
58
59
  .option('-R, --report', '生成本地 Markdown 审查报告文件')
59
60
  .option('-c, --comment', '审查完成后,自动将结果作为评论发至 MR 讨论区')
60
61
  .option('-m, --merge', '审查通过且无明显问题时,自动合并该 MR')
@@ -76,14 +77,16 @@ program
76
77
  program
77
78
  .command('comment <mr_id> <text>')
78
79
  .description('给指定的 MR 快捷留言/评论')
79
- .action((mr_id, text) => {
80
- actionsCmd.comment(mr_id, text);
80
+ .option('-p, --project <id>', '指定 GitLab Project ID')
81
+ .action((mr_id, text, options) => {
82
+ actionsCmd.comment(mr_id, text, options);
81
83
  });
82
84
 
83
85
  program
84
86
  .command('merge <mr_id>')
85
87
  .description('检查权限并快捷合并指定的 MR')
86
88
  .option('-y, --yes', '非交互式静默执行')
89
+ .option('-p, --project <id>', '指定 GitLab Project ID')
87
90
  .action((mr_id, options) => {
88
91
  actionsCmd.merge(mr_id, options);
89
92
  });
@@ -91,8 +94,9 @@ program
91
94
  program
92
95
  .command('view <mr_id>')
93
96
  .description('在终端直接查看一个 MR 的文件差异 (Diff) 详情')
94
- .action((mr_id) => {
95
- actionsCmd.view(mr_id);
97
+ .option('-p, --project <id>', '指定 GitLab Project ID')
98
+ .action((mr_id, options) => {
99
+ actionsCmd.view(mr_id, options);
96
100
  });
97
101
 
98
102
  program.parse(process.argv);
@@ -0,0 +1,107 @@
1
+ const http = require('http');
2
+ const path = require('path');
3
+
4
+ // 强制使用环境变量加载测试配置
5
+ process.env.HONG_REVIEW_CONFIG = path.join(__dirname, 'test-config.json');
6
+
7
+ const storage = require('./src/utils/storage');
8
+ const hooks = require('./src/utils/hooks');
9
+ const GitLabClient = require('./src/core/gitlab');
10
+ const AIClient = require('./src/core/ai');
11
+
12
+ // 1. 启动一个本地 Mock Webhook Server 来接收并打印 hook 被触发的情况
13
+ const server = http.createServer((req, res) => {
14
+ let body = '';
15
+ req.on('data', chunk => body += chunk);
16
+ req.on('end', () => {
17
+ try {
18
+ const data = JSON.parse(body);
19
+ console.log(`\n📦 [Webhook 收信端] 捕获到事件: \x1b[36m${data.event}\x1b[0m`);
20
+ if (data.status) {
21
+ console.log(` └─ Status (进度): ${data.status}`);
22
+ }
23
+ if (data.result) {
24
+ console.log(` └─ Result (结果风险等级): ${data.result.riskLevel}`);
25
+ }
26
+ } catch(e) {}
27
+ res.writeHead(200);
28
+ res.end('OK');
29
+ });
30
+ });
31
+
32
+ server.listen(33011, async () => {
33
+ console.log('🚀 本地 Mock Webhook Server 已启动在 33011 端口,准备拦截 Hook');
34
+
35
+ // 备份原有配置
36
+ const originalHook = storage.get('hook');
37
+
38
+ // 把 hook 地址强制设为咱们自己这个监听的服务
39
+ storage.set('hook', 'http://127.0.0.1:33011/test-hook');
40
+ // 假装有 Token 和 Key 绕过前提校验
41
+ storage.set('gitlabToken', 'mock-token');
42
+ storage.set('aiKey', 'mock-ai-key');
43
+
44
+ // 2. 模拟霸占 GitLabClient 的网络请求方法,不让它真去请求外网
45
+ GitLabClient.prototype.getMergeRequestDetail = async () => ({ title: 'Mock 功能:测试 Hooks 触发和多轮交互', source_branch: 'feature-hook-test' });
46
+ GitLabClient.prototype.getMergeRequestChanges = async () => ([{ new_path: 'src/app.js', diff: '+ console.log("hello");' }]);
47
+ GitLabClient.prototype.getFileContent = async () => ('const a = 1;');
48
+ GitLabClient.prototype.addMergeRequestNote = async () => ({});
49
+
50
+ // 3. 模拟霸占 AIClient 的核心方法,我们让它硬编码返回几个动作:
51
+ // 第一圈:请求文件 (request_files)
52
+ // 第二圈:再次请求文件 (request_files)
53
+ // 第三圈:出报告 (generate_report)
54
+ let aiCallCount = 0;
55
+ AIClient.prototype.chatWithJsonMode = async () => {
56
+ aiCallCount++;
57
+ if (aiCallCount === 1) {
58
+ return JSON.stringify({
59
+ action: 'request_files',
60
+ files: ['src/utils.js'],
61
+ contentTypes: ['full'],
62
+ reasoning: '我需要看看 utils.js 里的工具函数实现逻辑'
63
+ });
64
+ } else if (aiCallCount === 2) {
65
+ return JSON.stringify({
66
+ action: 'request_files',
67
+ files: ['package.json'],
68
+ contentTypes: ['diff'],
69
+ reasoning: '我还想看看是不是引入了不安全的新依赖'
70
+ });
71
+ } else {
72
+ return JSON.stringify({
73
+ action: 'generate_report',
74
+ result: {
75
+ riskLevel: 'medium', // 测试一下不是 high 时,走成功钩子
76
+ summary: '模拟 AI 分析:经过 3 轮查询,文件整体没什么大问题,只需小调整。',
77
+ issues: [
78
+ { severity: 'warning', file: 'src/app.js', line: 10, title: '控制台输出', description: '代码里留了一个 console.log' }
79
+ ],
80
+ suggestions: ['建议把测试用的 console 删掉']
81
+ }
82
+ });
83
+ }
84
+ };
85
+
86
+ // 4. 调用核心审查流程 mrCmd
87
+ const mrCmd = require('./src/commands/mr');
88
+ console.log('\n======================================================');
89
+ console.log('>>> 开始执行 MR 审查流程 (使用 -y 自动跳过交互)');
90
+ console.log('======================================================\n');
91
+
92
+ try {
93
+ // -y 代表 yes: true 参数,纯命令行静默执行,否则会有 inquirer 会卡在这
94
+ await mrCmd('999', { project: '10001', yes: true });
95
+ } catch(e) {
96
+ console.error(e);
97
+ }
98
+
99
+ console.log('\n======================================================');
100
+ console.log('>>> 审查流程结束,正在清理测试环境配置...');
101
+
102
+ // 还原原始配置
103
+ storage.set('hook', originalHook);
104
+
105
+ server.close();
106
+ process.exit(0);
107
+ });
package/openclaw.json ADDED
@@ -0,0 +1,299 @@
1
+ {
2
+ "meta": {
3
+ "lastTouchedVersion": "2026.2.21-2",
4
+ "lastTouchedAt": "2026-02-22T18:56:21.832Z"
5
+ },
6
+ "wizard": {
7
+ "lastRunAt": "2026-02-20T18:04:07.103Z",
8
+ "lastRunVersion": "2026.2.15",
9
+ "lastRunCommand": "doctor",
10
+ "lastRunMode": "local"
11
+ },
12
+ "update": {
13
+ "checkOnStart": true
14
+ },
15
+ "browser": {
16
+ "defaultProfile": "chrome"
17
+ },
18
+ "auth": {
19
+ "profiles": {
20
+ "zai:default": {
21
+ "provider": "zai",
22
+ "mode": "api_key"
23
+ },
24
+ "xai:default": {
25
+ "provider": "xai",
26
+ "mode": "api_key"
27
+ }
28
+ }
29
+ },
30
+ "models": {
31
+ "mode": "merge",
32
+ "providers": {
33
+ "custom-ai-askhong-com": {
34
+ "baseUrl": "https://ai.askhong.com/v1",
35
+ "apiKey": "a3VwNTViNHQ3cjZqOWY4cDJrMW4yZDBuM3AxaTRsNXU2dzc4eHk",
36
+ "api": "openai-completions",
37
+ "models": [
38
+ {
39
+ "id": "gemini-3-pro",
40
+ "name": "gemini-3-pro (Custom Provider)",
41
+ "reasoning": false,
42
+ "input": [
43
+ "text"
44
+ ],
45
+ "cost": {
46
+ "input": 0,
47
+ "output": 0,
48
+ "cacheRead": 0,
49
+ "cacheWrite": 0
50
+ },
51
+ "contextWindow": 128000,
52
+ "maxTokens": 8192
53
+ }
54
+ ]
55
+ },
56
+ "zai": {
57
+ "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4",
58
+ "api": "openai-completions",
59
+ "models": [
60
+ {
61
+ "id": "glm-5",
62
+ "name": "GLM-5",
63
+ "reasoning": true,
64
+ "input": [
65
+ "text"
66
+ ],
67
+ "cost": {
68
+ "input": 0,
69
+ "output": 0,
70
+ "cacheRead": 0,
71
+ "cacheWrite": 0
72
+ },
73
+ "contextWindow": 204800,
74
+ "maxTokens": 131072
75
+ },
76
+ {
77
+ "id": "glm-4.7",
78
+ "name": "GLM-4.7",
79
+ "reasoning": true,
80
+ "input": [
81
+ "text"
82
+ ],
83
+ "cost": {
84
+ "input": 0,
85
+ "output": 0,
86
+ "cacheRead": 0,
87
+ "cacheWrite": 0
88
+ },
89
+ "contextWindow": 204800,
90
+ "maxTokens": 131072
91
+ },
92
+ {
93
+ "id": "glm-4.7-flash",
94
+ "name": "GLM-4.7 Flash",
95
+ "reasoning": true,
96
+ "input": [
97
+ "text"
98
+ ],
99
+ "cost": {
100
+ "input": 0,
101
+ "output": 0,
102
+ "cacheRead": 0,
103
+ "cacheWrite": 0
104
+ },
105
+ "contextWindow": 204800,
106
+ "maxTokens": 131072
107
+ },
108
+ {
109
+ "id": "glm-4.7-flashx",
110
+ "name": "GLM-4.7 FlashX",
111
+ "reasoning": true,
112
+ "input": [
113
+ "text"
114
+ ],
115
+ "cost": {
116
+ "input": 0,
117
+ "output": 0,
118
+ "cacheRead": 0,
119
+ "cacheWrite": 0
120
+ },
121
+ "contextWindow": 204800,
122
+ "maxTokens": 131072
123
+ }
124
+ ]
125
+ }
126
+ }
127
+ },
128
+ "agents": {
129
+ "defaults": {
130
+ "model": {
131
+ "primary": "zai/glm-4.7"
132
+ },
133
+ "models": {
134
+ "zai/glm-5": {
135
+ "alias": "GLM"
136
+ },
137
+ "zai/glm-4.7": {}
138
+ },
139
+ "workspace": "/Users/hong/.openclaw/workspace",
140
+ "memorySearch": {
141
+ "enabled": true,
142
+ "sources": [
143
+ "memory"
144
+ ],
145
+ "extraPaths": [
146
+ "MEMORY.md",
147
+ "memory/*.md"
148
+ ],
149
+ "provider": "local",
150
+ "sync": {
151
+ "onSessionStart": true,
152
+ "onSearch": true,
153
+ "watch": true
154
+ }
155
+ },
156
+ "compaction": {
157
+ "mode": "safeguard"
158
+ },
159
+ "blockStreamingDefault": "on",
160
+ "blockStreamingBreak": "text_end",
161
+ "maxConcurrent": 4,
162
+ "subagents": {
163
+ "maxConcurrent": 8
164
+ },
165
+ "timeoutSeconds": 180
166
+ },
167
+ "list": [
168
+ {
169
+ "id": "main",
170
+ "subagents": {
171
+ "allowAgents": [
172
+ "tech-group",
173
+ "invest-group"
174
+ ]
175
+ }
176
+ },
177
+ {
178
+ "id": "tech-group",
179
+ "name": "tech-group",
180
+ "workspace": "/Users/hong/.openclaw/workspace-tech",
181
+ "agentDir": "/Users/hong/.openclaw/agents/tech-group/agent"
182
+ },
183
+ {
184
+ "id": "invest-group",
185
+ "name": "invest-group",
186
+ "workspace": "/Users/hong/.openclaw/workspace-invest",
187
+ "agentDir": "/Users/hong/.openclaw/agents/invest-group/agent"
188
+ }
189
+ ]
190
+ },
191
+ "bindings": [
192
+ {
193
+ "agentId": "tech-group",
194
+ "match": {
195
+ "channel": "telegram",
196
+ "peer": {
197
+ "kind": "group",
198
+ "id": "-5063603196"
199
+ }
200
+ }
201
+ },
202
+ {
203
+ "agentId": "invest-group",
204
+ "match": {
205
+ "channel": "telegram",
206
+ "peer": {
207
+ "kind": "group",
208
+ "id": "-5196600027"
209
+ }
210
+ }
211
+ }
212
+ ],
213
+ "messages": {
214
+ "ackReactionScope": "all"
215
+ },
216
+ "commands": {
217
+ "native": "auto",
218
+ "nativeSkills": "auto",
219
+ "restart": true,
220
+ "ownerDisplay": "raw"
221
+ },
222
+ "hooks": {
223
+ "token": "e4e2136cb731cfb14f1ad190833738c5bdadfbf79cf7c0f5",
224
+ "internal": {
225
+ "enabled": true,
226
+ "entries": {
227
+ "command-logger": {
228
+ "enabled": true
229
+ },
230
+ "session-memory": {
231
+ "enabled": false
232
+ }
233
+ }
234
+ },
235
+ "mappings": [
236
+ {
237
+ "id": "hong-review-notification",
238
+ "match": {
239
+ "path": "code-review"
240
+ },
241
+ "action": "agent",
242
+ "agentId": "tech-group",
243
+ "name": "AI Code Reviewer",
244
+ "channel": "telegram",
245
+ "to": "-5063603196",
246
+ "messageTemplate": "代码审查完成 🔔\n- **MR ID**: {{payload.mrId}}\n- **状态**: {{payload.status}}\n*(日志及报告已由系统分析进程执行完毕)*"
247
+ }
248
+ ]
249
+ },
250
+ "channels": {
251
+ "telegram": {
252
+ "enabled": true,
253
+ "dmPolicy": "pairing",
254
+ "botToken": "8489148682:AAFrvw72YgSP6SO6Sj082twbMOaLLodDrhI",
255
+ "groups": {
256
+ "*": {
257
+ "requireMention": false
258
+ }
259
+ },
260
+ "groupPolicy": "open",
261
+ "streaming": true
262
+ }
263
+ },
264
+ "gateway": {
265
+ "port": 18790,
266
+ "mode": "local",
267
+ "bind": "loopback",
268
+ "controlUi": {
269
+ "allowedOrigins": [
270
+ "https://mini.askhong.com:18789",
271
+ "http://mini.askhong.com:18789"
272
+ ]
273
+ },
274
+ "auth": {
275
+ "mode": "token",
276
+ "token": "e4e2136cb731cfb14f1ad190833738c5bdadfbf79cf7c0f5"
277
+ },
278
+ "tailscale": {
279
+ "mode": "off",
280
+ "resetOnExit": false
281
+ },
282
+ "nodes": {
283
+ "denyCommands": [
284
+ ]
285
+ }
286
+ },
287
+ "skills": {
288
+ "install": {
289
+ "nodeManager": "npm"
290
+ }
291
+ },
292
+ "plugins": {
293
+ "entries": {
294
+ "telegram": {
295
+ "enabled": true
296
+ }
297
+ }
298
+ }
299
+ }
@@ -18,26 +18,39 @@ hong-review setup-openclaw
18
18
 
19
19
  在您的 OpenClaw 配置文件 (`openclaw.yaml` 或 [.json](file:///Users/hong/Desktop/cli/package.json)) 中,找到 `hooks` 配置块。你需要新增一条映射规则,让 OpenClaw 知道收到 `code-review` 路径的请求时,发送什么消息给 Telegram。
20
20
 
21
- **示例配置(YAML 风格):**
22
-
23
- ```yaml
24
- hooks:
25
- token: "e4e2136cb731cfb14f1ad190833738c5bdadfbf79cf7c0f5" # 这必须与第一步 CURL 里的 Token 一致
26
- basePath: "/hooks" # 这也对应第一步的路径
27
- mappings:
28
- - id: "hong-review-notification"
29
- match:
30
- path: "code-review" # 匹配 /hooks/code-review
31
- action: "agent" # 以 Agent 代理的口吻发送
32
- agentId: "telegram-bot" # 你的 Telegram Bot 的 Agent 标识
33
- name: "AI Code Reviewer"
34
- channel: "telegram" # 明确发往 Telegram 通道
35
- to: "@your_telegram_username" # 你的真实触达账号
36
- messageTemplate: |
37
- 代码审查完成 🔔
38
- - **MR ID**: {{payload.mrId}}
39
- - **状态**: {{payload.status}}
40
- *(日志及报告已由系统分析进程执行完毕)*
21
+ **针对您当前 JSON 配置的示例配置:**
22
+
23
+ 您可以直接将以下 `hooks` 代码片段复制替换掉您当前配置文件里的 `hooks` 部分。已经为您配置好了正确的 `tech-group` 发送代理目标和 Telegram 群组 ID:
24
+
25
+ ```json
26
+ "hooks": {
27
+ "token": "e4e2136cb731cfb14f1ad190833738c5bdadfbf79cf7c0f5",
28
+ "internal": {
29
+ "enabled": true,
30
+ "entries": {
31
+ "command-logger": {
32
+ "enabled": true
33
+ },
34
+ "session-memory": {
35
+ "enabled": false
36
+ }
37
+ }
38
+ },
39
+ "mappings": [
40
+ {
41
+ "id": "hong-review-notification",
42
+ "match": {
43
+ "path": "code-review"
44
+ },
45
+ "action": "agent",
46
+ "agentId": "tech-group",
47
+ "name": "AI Code Reviewer",
48
+ "channel": "telegram",
49
+ "to": "-5063603196",
50
+ "messageTemplate": "代码审查完成 🔔\n- **MR ID**: {{payload.mrId}}\n- **状态**: {{payload.status}}\n*(日志及报告已由系统分析进程执行完毕)*"
51
+ }
52
+ ]
53
+ }
41
54
  ```
42
55
  *(这段配置使用了 OpenClaw 原生的 [src/gateway/hooks-mapping.ts](file:///tmp/openclaw/src/gateway/hooks-mapping.ts) 解析能力,它允许插值获取 Payload 里的变量并组合成文本推出去)*
43
56
 
@@ -56,16 +69,34 @@ hooks:
56
69
  因为你处于非人机交互环境,你每次调用该程序时,**必须加上 `-y` 或 `--yes` 参数**,这代表启用静默安全模式。
57
70
  程序在执行结束或出错后会自动处理,**你永远不要尝试去读取它的标准输出日志来提取长篇大论的报告**,程序的内建 Hooks 系统会自己将带有排版的最终结果推送到 Telegram,你要做的这只是触发它即可。
58
71
 
59
- **你可以使用它来回复用户的三种核心意图:**
72
+ **你可以使用它来回复用户的各种核心意图(请务必携带对应的命令和必要参数):**
60
73
 
61
- 1. 如果用户问“我的待办列表里有什么 MR?”或“列出待审查代码”:
74
+ 1. 审查和分析具体合并请求:
75
+ 如果用户说“去帮我分析一下 101 这个 MR” 或 “查下 101”
76
+ 请在底层执行命令:`hong-review mr 101 -y`
77
+
78
+ 2. 查看待办列表:
79
+ 如果用户问“我的待办列表里有什么 MR?”或“列出待审查代码”
62
80
  请在底层执行命令:`hong-review ls -y`
63
81
 
64
- 2. 如果用户说“去帮我分析一下 101 这个 MR” 或 “查下 101”:
65
- 请在底层执行命令:`hong-review mr 101 -y`
82
+ 3. 留言与评论:
83
+ 如果用户想给 MR 直接留言,例如“给 101 说这个写的很棒”
84
+ 请在底层执行命令:`hong-review comment 101 "这个写的很棒"`
66
85
 
67
- 3. 如果用户经过阅读报告后说“确认合并 101 吧”:
86
+ 4. 自动代码合并:
87
+ 如果用户在代码分析并确保安全后说“确认合并 101 吧”
68
88
  请在底层执行命令:`hong-review merge 101 -y`
89
+
90
+ 5. 快速查看代码的原始变更 (Diff):
91
+ 如果用户问“我想直接看看 101 改了啥代码”
92
+ 请在底层执行命令:`hong-review view 101`
93
+
94
+ **附录: 额外的底层管理员命令集**
95
+ *(在绝大数情况下不需要触发,仅供全能 AI 掌握系统全貌,切勿对普通用户暴露)*
96
+ - 程序功能登录与初始化配置:`hong-review login` (或 `init`)
97
+ - 查看配置信息:`hong-review config list`
98
+ - 修改配置:`hong-review config set <key> <value>`
99
+ - 路由挂载初始化:`hong-review setup-openclaw`
69
100
  ```
70
101
 
71
102
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hong-review-cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.6",
4
4
  "main": "index.js",
5
5
  "bin": {
6
6
  "hong-review": "bin/hong-review.js"
@@ -27,4 +27,4 @@
27
27
  "inquirer": "^8.2.7",
28
28
  "ora": "^5.4.1"
29
29
  }
30
- }
30
+ }
@@ -11,117 +11,116 @@ function getClient() {
11
11
  return new GitLabClient({ host: config.gitlabUrl, token: config.gitlabToken });
12
12
  }
13
13
 
14
- async function getProjectId() {
15
- let projectId = storage.get('defaultProjectId');
14
+ async function getProjectId(options = {}) {
15
+ let projectId = options.project;
16
16
  if (!projectId) {
17
- const answer = await inquirer.prompt([{
18
- type: 'input',
19
- name: 'pid',
20
- message: '请输入当前 Git 仓库对应的 GitLab Project ID:'
21
- }]);
22
- projectId = answer.pid;
23
- storage.set('defaultProjectId', projectId);
17
+ const answer = await inquirer.prompt([{
18
+ type: 'input',
19
+ name: 'pid',
20
+ message: '请输入当前 Git 仓库对应的 GitLab Project ID (或使用 -p 参数):'
21
+ }]);
22
+ projectId = answer.pid;
24
23
  }
25
24
  return projectId;
26
25
  }
27
26
 
28
27
  module.exports = {
29
- async comment(mrId, text) {
28
+ async comment(mrId, text, options = {}) {
30
29
  if (!text) {
31
- logger.error('请输入评论内容。用法: hong-review comment <mr_id> "您的评论"');
32
- return;
30
+ logger.error('请输入评论内容。用法: hong-review comment <mr_id> "您的评论"');
31
+ return;
33
32
  }
34
33
  try {
35
- const projectId = await getProjectId();
36
- const gitlab = getClient();
37
-
38
- logger.startSpinner('comment', `正在向 MR !${mrId} 发送评论...`);
39
- await gitlab.addMergeRequestNote(projectId, mrId, { body: text });
40
- logger.stopSpinner('comment', true, '评论发送成功!');
34
+ const projectId = await getProjectId(options);
35
+ const gitlab = getClient();
36
+
37
+ logger.startSpinner('comment', `正在向 MR !${mrId} 发送评论...`);
38
+ await gitlab.addMergeRequestNote(projectId, mrId, { body: text });
39
+ logger.stopSpinner('comment', true, '评论发送成功!');
41
40
 
42
41
  } catch (e) {
43
- logger.stopSpinner('comment', false, `评论发送失败: ${e.message}`);
42
+ logger.stopSpinner('comment', false, `评论发送失败: ${e.message}`);
44
43
  }
45
44
  },
46
45
 
47
46
  async merge(mrId, options = {}) {
48
47
  try {
49
- const projectId = await getProjectId();
50
- const gitlab = getClient();
51
-
52
- if (!options.yes) logger.startSpinner('merge-check', `正在检查 MR !${mrId} 合并权限...`);
53
- const check = await gitlab.canMergeMR(projectId, mrId);
54
-
55
- if (!check.canMerge) {
56
- if (!options.yes) logger.stopSpinner('merge-check', false, `无法合并: ${check.reason}`);
57
- else console.error(`Failed to merge: ${check.reason}`);
58
- return;
59
- }
60
- if (!options.yes) logger.stopSpinner('merge-check', true, '权限检查通过。');
61
-
62
- let confirm = options.yes;
63
- if (!confirm) {
64
- const answer = await inquirer.prompt([{
65
- type: 'confirm',
66
- name: 'confirmMerge',
67
- message: `即将自动合并 MR !${mrId},是否确认?`,
68
- default: false
69
- }]);
70
- confirm = answer.confirmMerge;
71
- }
72
-
73
- if (confirm) {
74
- if (!options.yes) logger.startSpinner('merge', '合并中...');
75
- await gitlab.merge(projectId, mrId);
76
- if (!options.yes) logger.stopSpinner('merge', true, '合并成功!');
77
- else console.log(`✅ MR !${mrId} 合并成功!`);
78
- } else {
79
- logger.info('已取消合并。');
80
- }
48
+ const projectId = await getProjectId(options);
49
+ const gitlab = getClient();
50
+
51
+ if (!options.yes) logger.startSpinner('merge-check', `正在检查 MR !${mrId} 合并权限...`);
52
+ const check = await gitlab.canMergeMR(projectId, mrId);
53
+
54
+ if (!check.canMerge) {
55
+ if (!options.yes) logger.stopSpinner('merge-check', false, `无法合并: ${check.reason}`);
56
+ else console.error(`Failed to merge: ${check.reason}`);
57
+ return;
58
+ }
59
+ if (!options.yes) logger.stopSpinner('merge-check', true, '权限检查通过。');
60
+
61
+ let confirm = options.yes;
62
+ if (!confirm) {
63
+ const answer = await inquirer.prompt([{
64
+ type: 'confirm',
65
+ name: 'confirmMerge',
66
+ message: `即将自动合并 MR !${mrId},是否确认?`,
67
+ default: false
68
+ }]);
69
+ confirm = answer.confirmMerge;
70
+ }
71
+
72
+ if (confirm) {
73
+ if (!options.yes) logger.startSpinner('merge', '合并中...');
74
+ await gitlab.merge(projectId, mrId);
75
+ if (!options.yes) logger.stopSpinner('merge', true, '合并成功!');
76
+ else console.log(`✅ MR !${mrId} 合并成功!`);
77
+ } else {
78
+ logger.info('已取消合并。');
79
+ }
81
80
 
82
81
  } catch (e) {
83
- logger.error(`合并失败: ${e.message}`);
82
+ logger.error(`合并失败: ${e.message}`);
84
83
  }
85
84
  },
86
85
 
87
- async view(mrId) {
86
+ async view(mrId, options = {}) {
88
87
  try {
89
- const projectId = await getProjectId();
90
- const gitlab = getClient();
91
-
92
- logger.startSpinner('view', `拉取 MR !${mrId} 的文件变更...`);
93
- const changes = await gitlab.getMergeRequestChanges(projectId, mrId);
94
- logger.stopSpinner('view', true, `共拉取到 ${changes.length} 个文件的变更:\n`);
95
-
96
- if (changes.length === 0) {
97
- logger.info('该 MR 无文件变更。');
98
- return;
99
- }
100
-
101
- const choices = changes.map((c, i) => ({
102
- name: `[${c.new_file ? '新增' : (c.deleted_file ? '删除' : '修改')}] ${c.new_path}`,
103
- value: i
104
- }));
105
-
106
- const answer = await inquirer.prompt([{
107
- type: 'list',
108
- name: 'fileIndex',
109
- message: '选择要查看 diff 的文件:',
110
- choices: [...choices, new inquirer.Separator(), { name: '退出', value: -1 }]
111
- }]);
112
-
113
- if (answer.fileIndex !== -1) {
114
- const selectedFile = changes[answer.fileIndex];
115
- console.log('\n--- Diff 开始 ---');
116
- console.log(selectedFile.diff);
117
- console.log('--- Diff 结束 ---\n');
118
-
119
- // 如果有需求可以加个无限循环,看完一个文件还能选另一个。
120
- // 但为了 CLI 简洁,目前设计看完即退
121
- }
88
+ const projectId = await getProjectId(options);
89
+ const gitlab = getClient();
90
+
91
+ logger.startSpinner('view', `拉取 MR !${mrId} 的文件变更...`);
92
+ const changes = await gitlab.getMergeRequestChanges(projectId, mrId);
93
+ logger.stopSpinner('view', true, `共拉取到 ${changes.length} 个文件的变更:\n`);
94
+
95
+ if (changes.length === 0) {
96
+ logger.info('该 MR 无文件变更。');
97
+ return;
98
+ }
99
+
100
+ const choices = changes.map((c, i) => ({
101
+ name: `[${c.new_file ? '新增' : (c.deleted_file ? '删除' : '修改')}] ${c.new_path}`,
102
+ value: i
103
+ }));
104
+
105
+ const answer = await inquirer.prompt([{
106
+ type: 'list',
107
+ name: 'fileIndex',
108
+ message: '选择要查看 diff 的文件:',
109
+ choices: [...choices, new inquirer.Separator(), { name: '退出', value: -1 }]
110
+ }]);
111
+
112
+ if (answer.fileIndex !== -1) {
113
+ const selectedFile = changes[answer.fileIndex];
114
+ console.log('\n--- Diff 开始 ---');
115
+ console.log(selectedFile.diff);
116
+ console.log('--- Diff 结束 ---\n');
117
+
118
+ // 如果有需求可以加个无限循环,看完一个文件还能选另一个。
119
+ // 但为了 CLI 简洁,目前设计看完即退
120
+ }
122
121
 
123
122
  } catch (e) {
124
- logger.error(`拉取变更失败: ${e.message}`);
123
+ logger.error(`拉取变更失败: ${e.message}`);
125
124
  }
126
125
  }
127
126
  };
@@ -3,7 +3,7 @@ const logger = require('../utils/logger');
3
3
  const GitLabClient = require('../core/gitlab');
4
4
  const chalk = require('chalk');
5
5
 
6
- module.exports = async function(options = {}) {
6
+ module.exports = async function (options = {}) {
7
7
  const config = storage.getAll();
8
8
  if (!config.gitlabToken) {
9
9
  logger.error('缺少 GitLab Token 配置,请运行 hong-review login');
@@ -11,12 +11,12 @@ module.exports = async function(options = {}) {
11
11
  }
12
12
 
13
13
  const gitlab = new GitLabClient({ host: config.gitlabUrl, token: config.gitlabToken });
14
-
14
+
15
15
  if (!options.yes) logger.startSpinner('list', '正在向 GitLab 拉取您需要处理的 Merge Requests...');
16
-
16
+
17
17
  try {
18
18
  const mrs = await gitlab.getMyReviewMergeRequests();
19
-
19
+
20
20
  if (options.yes) {
21
21
  // 静默模式下,直接输出纯文本列表供其他程序抓取/处理
22
22
  console.log(`找到 ${mrs.length} 个待处理的 MR:`);
@@ -27,7 +27,7 @@ module.exports = async function(options = {}) {
27
27
  }
28
28
 
29
29
  logger.stopSpinner('list', true, `成功拉取!共分配给您或待您审核的 MR 有 ${mrs.length} 个。\n`);
30
-
30
+
31
31
  if (mrs.length === 0) {
32
32
  logger.info('暂时没有需要您处理的 Merge Request。喝杯咖啡休息一下吧 ☕️\n');
33
33
  return;
@@ -61,14 +61,12 @@ module.exports = async function(options = {}) {
61
61
 
62
62
  if (answer.selectedMr) {
63
63
  console.log('');
64
- // 自动配置 projectId 避免下一次询问
65
- storage.set('defaultProjectId', String(answer.selectedMr.project_id));
66
64
  // 启动智能审查流程
67
- await mrCmd(answer.selectedMr.iid, {});
65
+ await mrCmd(answer.selectedMr.iid, { project: answer.selectedMr.project_id });
68
66
  } else {
69
67
  logger.info('已退出。');
70
68
  }
71
-
69
+
72
70
  } catch (e) {
73
71
  if (!options.yes) logger.stopSpinner('list', false, `拉取 MR 列表失败: ${e.message}`);
74
72
  else console.error(`Error: 拉取 MR 列表失败 - ${e.message}`);
@@ -7,38 +7,34 @@ const logger = require('../utils/logger');
7
7
  const hooks = require('../utils/hooks');
8
8
  const GitLabClient = require('../core/gitlab');
9
9
  const AIClient = require('../core/ai');
10
- const {
11
- AGENT_SYSTEM_PROMPT,
12
- parseAgentResponse,
13
- formatFileList,
10
+ const {
11
+ AGENT_SYSTEM_PROMPT,
12
+ parseAgentResponse,
13
+ formatFileList,
14
14
  formatFileContents,
15
15
  createUserMessage,
16
16
  createAssistantMessage
17
17
  } = require('../core/agent');
18
18
 
19
- module.exports = async function(mrId, options) {
19
+ module.exports = async function (mrId, options) {
20
20
  const config = storage.getAll();
21
21
  if (!config.gitlabToken || !config.aiKey) {
22
22
  logger.error('缺少必要的配置,请先运行 `hong-review login` 初始化配置。');
23
23
  return;
24
24
  }
25
25
 
26
- // 这里简化处理:因为我们需要 projectId mrIid,
27
- // 在真实场景中,项目 ID 通常从本地 git remote 推断或配置中读取。
28
- // 为了 demo 的通用性,我们直接要求用户确认或配置 projectId。
29
- let projectId = config.defaultProjectId;
26
+ let projectId = options.project;
30
27
  if (!projectId) {
31
- if (options.yes) {
32
- logger.error('静默模式下未找到 defaultProjectId 配置,请先配置或在有历史审查记录后再尝试。');
33
- return;
34
- }
35
- const answer = await inquirer.prompt([{
36
- type: 'input',
37
- name: 'pid',
38
- message: '请输入当前 Git 仓库对应的 GitLab Project ID:'
39
- }]);
40
- projectId = answer.pid;
41
- storage.set('defaultProjectId', projectId);
28
+ if (options.yes) {
29
+ logger.error('静默模式下未通过 -p 指定 Project ID,操作中止。');
30
+ return;
31
+ }
32
+ const answer = await inquirer.prompt([{
33
+ type: 'input',
34
+ name: 'pid',
35
+ message: '请输入当前 Git 仓库对应的 GitLab Project ID:'
36
+ }]);
37
+ projectId = answer.pid;
42
38
  }
43
39
 
44
40
  const mrIid = parseInt(mrId);
@@ -48,7 +44,7 @@ module.exports = async function(mrId, options) {
48
44
  }
49
45
 
50
46
  logger.info(`开始准备审查 Project [${projectId}] 的 MR !${mrIid}`);
51
-
47
+
52
48
  // 触发 Hook
53
49
  await hooks.emit('onReviewStart', { projectId, mrId: mrIid });
54
50
 
@@ -57,6 +53,7 @@ module.exports = async function(mrId, options) {
57
53
 
58
54
  try {
59
55
  logger.startSpinner('fetching', '正在从 GitLab 拉取合并请求详情与代码变更...');
56
+ await hooks.emit('onReviewProgress', { projectId, mrId: mrIid, status: '拉取合并请求代码中' });
60
57
  const detail = await gitlab.getMergeRequestDetail(projectId, mrIid);
61
58
  const changes = await gitlab.getMergeRequestChanges(projectId, mrIid);
62
59
  logger.stopSpinner('fetching', true, `成功拉取 MR [${detail.title}] 分析所需的 ${changes.length} 个文件变更。`);
@@ -68,7 +65,8 @@ module.exports = async function(mrId, options) {
68
65
  }
69
66
 
70
67
  if (!options.yes) logger.startSpinner('ai-thinking', `🤖 AI (${config.aiModel}) 正在深度分析中,请稍候...`);
71
-
68
+ await hooks.emit('onReviewProgress', { projectId, mrId: mrIid, status: 'AI 深度分析代码中' });
69
+
72
70
  let reviewResult = null;
73
71
  let round = 1;
74
72
  const fileOverviews = GitLabClient.getFileOverview(changes);
@@ -78,6 +76,7 @@ module.exports = async function(mrId, options) {
78
76
  // 开始 Agent 轮询
79
77
  while (true) {
80
78
  if (!options.yes) logger.startSpinner('ai-thinking', `🤖 AI 思考中 (第 ${round} 轮)...`);
79
+ await hooks.emit('onReviewProgress', { projectId, mrId: mrIid, status: `AI 思考中 (第 ${round} 轮)` });
81
80
  const aiResponse = await ai.chatWithJsonMode(AGENT_SYSTEM_PROMPT, conversationHistory);
82
81
  const action = parseAgentResponse(aiResponse);
83
82
 
@@ -88,11 +87,14 @@ module.exports = async function(mrId, options) {
88
87
  conversationHistory.push({ role: 'assistant', content: aiResponse });
89
88
 
90
89
  if (action.action === 'generate_report') {
90
+ await hooks.emit('onReviewProgress', { projectId, mrId: mrIid, status: 'AI 审查完成,正在生成报告' });
91
91
  reviewResult = action.result;
92
92
  break;
93
93
  } else if (action.action === 'request_files') {
94
- if (!options.yes) logger.stopSpinner('ai-thinking', true, `AI 请求查看额外文件: ${action.files.join(', ')}`);
95
-
94
+ const reqFilesStr = action.files.join(', ');
95
+ if (!options.yes) logger.stopSpinner('ai-thinking', true, `AI 请求查看额外文件: ${reqFilesStr}`);
96
+ await hooks.emit('onReviewProgress', { projectId, mrId: mrIid, status: `AI 正在拉取依赖文件: ${reqFilesStr}` });
97
+
96
98
  const contents = await fetchFileContents(gitlab, projectId, detail.source_branch, changes, action.files, action.contentTypes);
97
99
  const fileContentMessage = formatFileContents(contents);
98
100
  conversationHistory.push({ role: 'user', content: fileContentMessage });
@@ -101,7 +103,7 @@ module.exports = async function(mrId, options) {
101
103
  }
102
104
 
103
105
  if (!options.yes) logger.stopSpinner('ai-thinking', true, 'AI 审查完成!');
104
-
106
+
105
107
  // 打印报告
106
108
  printReport(mrIid, detail, reviewResult);
107
109
 
@@ -181,10 +183,10 @@ async function fetchFileContents(gitlabClient, projectId, ref, changes, requeste
181
183
  contents.push({ path: filePath, contentType: 'full', content });
182
184
  } else {
183
185
  const fileChange = changes.find(f => f.new_path === filePath);
184
- contents.push({
185
- path: filePath,
186
- contentType: 'diff',
187
- content: fileChange ? fileChange.diff : '(文件未找到)'
186
+ contents.push({
187
+ path: filePath,
188
+ contentType: 'diff',
189
+ content: fileChange ? fileChange.diff : '(文件未找到)'
188
190
  });
189
191
  }
190
192
  } catch (err) {
@@ -201,30 +203,30 @@ function printReport(mrIid, detail, result) {
201
203
 
202
204
  let errorCount = 0;
203
205
  let warningCount = 0;
204
-
206
+
205
207
  console.log(chalk.bold('⚠️ 发现的问题:'));
206
208
  if (!result.issues || result.issues.length === 0) {
207
- console.log(chalk.green(' 没有任何问题,代码很棒!\n'));
209
+ console.log(chalk.green(' 没有任何问题,代码很棒!\n'));
208
210
  } else {
209
- result.issues.forEach(issue => {
210
- let prefix = '';
211
- let colorMsg = issue.description;
212
- if (issue.severity === 'error') {
213
- prefix = chalk.bgRed.white(' ERROR ');
214
- colorMsg = chalk.red(issue.description);
215
- errorCount++;
216
- } else if (issue.severity === 'warning') {
217
- prefix = chalk.bgYellow.black(' WARN ');
218
- colorMsg = chalk.yellow(issue.description);
219
- warningCount++;
220
- } else {
221
- prefix = chalk.bgBlue.white(' INFO ');
222
- colorMsg = chalk.blue(issue.description);
223
- }
224
-
225
- console.log(`${prefix} ${chalk.underline(issue.file)}${issue.line ? ':'+issue.line : ''} - ${chalk.bold(issue.title)}`);
226
- console.log(` 💡 ${colorMsg}\n`);
227
- });
211
+ result.issues.forEach(issue => {
212
+ let prefix = '';
213
+ let colorMsg = issue.description;
214
+ if (issue.severity === 'error') {
215
+ prefix = chalk.bgRed.white(' ERROR ');
216
+ colorMsg = chalk.red(issue.description);
217
+ errorCount++;
218
+ } else if (issue.severity === 'warning') {
219
+ prefix = chalk.bgYellow.black(' WARN ');
220
+ colorMsg = chalk.yellow(issue.description);
221
+ warningCount++;
222
+ } else {
223
+ prefix = chalk.bgBlue.white(' INFO ');
224
+ colorMsg = chalk.blue(issue.description);
225
+ }
226
+
227
+ console.log(`${prefix} ${chalk.underline(issue.file)}${issue.line ? ':' + issue.line : ''} - ${chalk.bold(issue.title)}`);
228
+ console.log(` 💡 ${colorMsg}\n`);
229
+ });
228
230
  }
229
231
 
230
232
  if (result.suggestions && result.suggestions.length > 0) {
@@ -244,7 +246,7 @@ function buildMarkdownReport(mrIid, detail, result) {
244
246
  md += `**标题**: ${detail.title}\n`;
245
247
  md += `**风险评级**: ${result.riskLevel.toUpperCase()}\n\n`;
246
248
  md += `## 整体概述\n${result.summary}\n\n`;
247
-
249
+
248
250
  if (result.issues && result.issues.length > 0) {
249
251
  md += `## 发现的问题\n\n`;
250
252
  result.issues.forEach(issue => {
@@ -257,8 +259,8 @@ function buildMarkdownReport(mrIid, detail, result) {
257
259
  }
258
260
 
259
261
  if (result.suggestions && result.suggestions.length > 0) {
260
- md += `## 改进建议\n`;
261
- result.suggestions.forEach(s => md += `- ${s}\n`);
262
+ md += `## 改进建议\n`;
263
+ result.suggestions.forEach(s => md += `- ${s}\n`);
262
264
  }
263
265
 
264
266
  md += `\n> *本报告由 \`hong-review\` CLI 工具自动生成。*`;
@@ -24,11 +24,13 @@ module.exports = async function setupOpenClaw() {
24
24
 
25
25
  const spinner = ora('正在写入 hooks 配置...').start();
26
26
 
27
- const successCurl = `curl -s -X POST ${answers.webhookUrl} -H 'Authorization: Bearer ${answers.token}' -H 'Content-Type: application/json' -d '{"mrId": "$MR_ID", "status": "Success"}' > /dev/null`;
28
- const failedCurl = `curl -s -X POST ${answers.webhookUrl} -H 'Authorization: Bearer ${answers.token}' -H 'Content-Type: application/json' -d '{"mrId": "$MR_ID", "status": "Failed"}' > /dev/null`;
27
+ // 使用 \" 保证 JSON 结构合法,同时允许 bash 解析其中的 $MR_ID $REVIEW_STATUS
28
+ const generateCurl = (statusValue) => `curl -s -X POST ${answers.webhookUrl} -H "Authorization: Bearer ${answers.token}" -H "Content-Type: application/json" -d "{\\"mrId\\": \\"$MR_ID\\", \\"status\\": \\"${statusValue}\\"}" > /dev/null`;
29
29
 
30
- configCmd.set('hooks.onReviewSuccess', successCurl);
31
- configCmd.set('hooks.onReviewFailed', failedCurl);
30
+ configCmd.set('hooks.onReviewStart', generateCurl('Started'));
31
+ configCmd.set('hooks.onReviewProgress', generateCurl('$REVIEW_STATUS'));
32
+ configCmd.set('hooks.onReviewSuccess', generateCurl('Success'));
33
+ configCmd.set('hooks.onReviewFailed', generateCurl('Failed'));
32
34
 
33
- spinner.succeed('配置完成!您已成功接入 OpenClaw 路由,审查结果将自动进行广播推送。');
35
+ spinner.succeed('配置完成!您已成功接入 OpenClaw 路由,审查过程的所有动态都将自动进行广播推送。');
34
36
  };
@@ -5,25 +5,28 @@ const logger = require('./logger');
5
5
 
6
6
  class Hooks {
7
7
  constructor() {
8
- this.hooksConfig = storage.get('hooks') || {};
8
+ this.hookConfig = storage.get('hook') || '';
9
9
  }
10
10
 
11
11
  /**
12
- * 执行指定生命周期的 Hook
12
+ * 执行统一的生命周期 Hook,自动注入事件名
13
13
  */
14
14
  async emit(eventName, payload = {}) {
15
- const hookAction = this.hooksConfig[eventName];
16
- if (!hookAction) return;
15
+ // 每次执行时都实时拉取 config
16
+ this.hookConfig = storage.get('hook');
17
17
 
18
- logger.info(`触发 Hook: ${eventName}...`);
18
+ if (!this.hookConfig) return;
19
+
20
+ logger.info(`触发 Hook 事件: ${eventName}...`);
19
21
 
20
22
  try {
21
- if (hookAction.startsWith('http')) {
22
- // 如果是 URL,执行 Webhook 发送
23
- await this.executeWebhook(hookAction, payload);
23
+ if (this.hookConfig.startsWith('http')) {
24
+ // 如果是 URL,执行 Webhook 发送,把事件名称混入 payload
25
+ const hookPayload = { event: eventName, ...payload };
26
+ await this.executeWebhook(this.hookConfig, hookPayload);
24
27
  } else {
25
28
  // 否则认为是本地的 shell command
26
- await this.executeShell(hookAction, payload);
29
+ await this.executeShell(this.hookConfig, eventName, payload);
27
30
  }
28
31
  logger.success(`Hook [${eventName}] 执行成功`);
29
32
  } catch (err) {
@@ -35,17 +38,18 @@ class Hooks {
35
38
  return axios.post(url, payload, { timeout: 5000 });
36
39
  }
37
40
 
38
- executeShell(command, payload) {
41
+ executeShell(command, eventName, payload) {
39
42
  return new Promise((resolve, reject) => {
40
- // 将 payload 转换为环境变量传入 shell
41
43
  const env = { ...process.env };
42
-
43
- // 将 JSON payload 添加到全局环境变量中,以便 bash 脚本获取
44
+
45
+ // 注入通用环境变量
46
+ env['HONG_REVIEW_EVENT'] = eventName; // 将被触发的事件名通过独立环境变量输出
44
47
  env['HONG_REVIEW_PAYLOAD'] = JSON.stringify(payload);
45
- // 也可以把特定的字段展开
48
+
46
49
  if (payload.mrId) env['MR_ID'] = payload.mrId;
47
50
  if (payload.status) env['REVIEW_STATUS'] = payload.status;
48
-
51
+ if (payload.error) env['REVIEW_ERROR'] = payload.error;
52
+
49
53
  exec(command, { env }, (error, stdout, stderr) => {
50
54
  if (error) {
51
55
  reject(error);
@@ -2,7 +2,8 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
4
 
5
- const CONFIG_FILE = path.join(os.homedir(), '.hong-review-config.json');
5
+ // 支持通过环境变量指定自定义配置文件,非常适合测试和调试
6
+ const CONFIG_FILE = process.env.HONG_REVIEW_CONFIG || path.join(os.homedir(), '.hong-review-config.json');
6
7
 
7
8
  /**
8
9
  * 缺省配置
@@ -13,12 +14,7 @@ const DEFAULT_CONFIG = {
13
14
  aiKey: '',
14
15
  aiBaseUrl: 'https://api.openai.com/v1',
15
16
  aiModel: 'gpt-4o',
16
- hooks: {
17
- onReviewStart: '',
18
- onReviewSuccess: '',
19
- onReviewFailed: '',
20
- onMerged: ''
21
- }
17
+ hook: ''
22
18
  };
23
19
 
24
20
  class Storage {