@zhuxb-clouds/ai-code-review 1.2.0 → 1.3.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,46 @@
1
+ # AI Code Review 忽略文件配置
2
+ # 语法类似 .gitignore,支持以下模式:
3
+ # - 支持 * 通配符(匹配非 / 的任意字符)
4
+ # - 支持 ** 通配符(匹配任意路径)
5
+ # - 支持 ? 通配符(匹配单个字符)
6
+ # - 以 / 开头表示从根目录匹配
7
+ # - 以 / 结尾表示只匹配目录
8
+ # - 以 ! 开头表示取消忽略
9
+ # - 以 # 开头表示注释
10
+
11
+ # 锁文件
12
+ package-lock.json
13
+ pnpm-lock.yaml
14
+ yarn.lock
15
+
16
+ # 生成的文件
17
+ *.min.js
18
+ *.min.css
19
+ *.bundle.js
20
+ dist/
21
+ build/
22
+
23
+ # 配置文件
24
+ .env
25
+ .env.*
26
+ *.config.js
27
+ *.config.ts
28
+
29
+ # 文档和资源
30
+ *.md
31
+ *.txt
32
+ *.svg
33
+ *.png
34
+ *.jpg
35
+ *.ico
36
+
37
+ # IDE 配置
38
+ .vscode/
39
+ .idea/
40
+
41
+ # 测试快照
42
+ __snapshots__/
43
+ *.snap
44
+
45
+ # 类型声明
46
+ *.d.ts
package/README.md CHANGED
@@ -19,10 +19,10 @@
19
19
  ## 🛠️ 技术架构
20
20
 
21
21
  ```
22
- git commit → Husky (prepare-commit-msg) → ai-review-hook → AI API (OpenAI/DeepSeek)
23
-
24
- ✅ 通过:自动填充 Commit Message
25
- ❌ 失败:拦截提交并输出建议
22
+ git commit → Husky (commit-msg) → ai-review-hook → AI API (OpenAI/DeepSeek)
23
+
24
+ ✅ 通过:自动填充 Commit Message
25
+ ❌ 失败:拦截提交并输出建议
26
26
  ```
27
27
 
28
28
  ---
@@ -93,11 +93,65 @@ git commit
93
93
  ```
94
94
  your-project/
95
95
  ├── .husky/
96
- │ └── prepare-commit-msg # Git Hook(自动创建)
97
- ├── .env # API Key(自己创建,不要提交!)
98
- ├── .env.example # 配置示例(自动创建)
99
- ├── .gitignore # 已包含 .env
100
- └── package.json # 包含 ai-code-review 依赖
96
+ │ └── commit-msg # Git Hook(自动创建)
97
+ ├── .env # API Key(自己创建,不要提交!)
98
+ ├── .env.example # 配置示例(自动创建)
99
+ ├── .reviewignore # AI 审查忽略文件(可选)
100
+ ├── .reviewignore.example # 忽略规则示例(自动创建)
101
+ ├── .gitignore # 已包含 .env
102
+ └── package.json # 包含 ai-code-review 依赖
103
+ ```
104
+
105
+ ---
106
+
107
+ ## 🚫 文件忽略配置 (.reviewignore)
108
+
109
+ 创建 `.reviewignore` 文件来跳过某些文件的 AI 审查,语法类似 `.gitignore`:
110
+
111
+ ```bash
112
+ # 复制示例文件
113
+ cp .reviewignore.example .reviewignore
114
+ ```
115
+
116
+ ### 支持的语法
117
+
118
+ ```gitignore
119
+ # 注释
120
+ # 这是一个注释
121
+
122
+ # 通配符
123
+ package-lock.json # 匹配特定文件
124
+ *.min.js # * 匹配任意字符(不包括 /)
125
+ dist/ # 匹配整个目录
126
+ **/*.snap # ** 匹配任意路径层级
127
+
128
+ # 否定模式
129
+ *.md # 忽略所有 markdown
130
+ !README.md # 但不忽略 README.md
131
+ ```
132
+
133
+ ### 常见配置示例
134
+
135
+ ```gitignore
136
+ # 锁文件
137
+ package-lock.json
138
+ pnpm-lock.yaml
139
+ yarn.lock
140
+
141
+ # 生成的文件
142
+ *.min.js
143
+ *.bundle.js
144
+ dist/
145
+ build/
146
+
147
+ # 文档和资源
148
+ *.md
149
+ *.svg
150
+ *.png
151
+
152
+ # 测试快照
153
+ __snapshots__/
154
+ *.snap
101
155
  ```
102
156
 
103
157
  ---
package/bin/cli.mjs CHANGED
@@ -25,10 +25,9 @@ const command = process.argv[2];
25
25
  const HOOK_CONTENT = `#!/bin/sh
26
26
 
27
27
  # AI Code Review Hook
28
- # Git 提供的参数传递给脚本
28
+ # 使用 commit-msg hook 以支持 --no-verify 跳过
29
29
  # $1: 提交消息文件路径
30
- # $2: 提交来源 (message, template, merge, squash, commit)
31
- npx ai-review-hook "$1" "$2"
30
+ npx ai-review-hook "$1"
32
31
  `;
33
32
 
34
33
  const ENV_EXAMPLE = `# AI 提供商选择 (openai / deepseek)
@@ -118,11 +117,18 @@ function setupHook() {
118
117
  fs.mkdirSync(huskyDir, { recursive: true });
119
118
  }
120
119
 
121
- // 创建 prepare-commit-msg hook
122
- const hookPath = path.join(huskyDir, "prepare-commit-msg");
120
+ // 创建 commit-msg hook(支持 --no-verify 跳过)
121
+ const hookPath = path.join(huskyDir, "commit-msg");
123
122
  fs.writeFileSync(hookPath, HOOK_CONTENT);
124
123
  fs.chmodSync(hookPath, "755");
125
- console.log("✅ 创建 Git Hook: .husky/prepare-commit-msg");
124
+ console.log("✅ 创建 Git Hook: .husky/commit-msg");
125
+
126
+ // 删除旧的 prepare-commit-msg hook(如果存在)
127
+ const oldHookPath = path.join(huskyDir, "prepare-commit-msg");
128
+ if (fs.existsSync(oldHookPath)) {
129
+ fs.unlinkSync(oldHookPath);
130
+ console.log("🗑️ 删除旧的 Hook: .husky/prepare-commit-msg");
131
+ }
126
132
 
127
133
  // 创建 .env.example
128
134
  const envExamplePath = path.join(projectRoot, ".env.example");
@@ -131,6 +137,14 @@ function setupHook() {
131
137
  console.log("✅ 创建配置示例: .env.example");
132
138
  }
133
139
 
140
+ // 创建 .reviewignore.example
141
+ const reviewIgnoreExampleSrc = path.join(__dirname, "..", ".reviewignore.example");
142
+ const reviewIgnoreExampleDest = path.join(projectRoot, ".reviewignore.example");
143
+ if (!fs.existsSync(reviewIgnoreExampleDest) && fs.existsSync(reviewIgnoreExampleSrc)) {
144
+ fs.copyFileSync(reviewIgnoreExampleSrc, reviewIgnoreExampleDest);
145
+ console.log("✅ 创建忽略规则示例: .reviewignore.example");
146
+ }
147
+
134
148
  // 更新 .gitignore
135
149
  const gitignorePath = path.join(projectRoot, ".gitignore");
136
150
  let gitignoreContent = "";
package/bin/hook.mjs CHANGED
@@ -146,12 +146,151 @@ async function callWithRetry(fn, retries = CONFIG.maxRetries) {
146
146
  throw lastError;
147
147
  }
148
148
 
149
+ // 加载 .reviewignore 文件并解析为正则表达式
150
+ function loadReviewIgnore() {
151
+ const ignorePatterns = [];
152
+ const ignoreFiles = [
153
+ path.join(projectRoot, ".reviewignore"),
154
+ path.join(process.cwd(), ".reviewignore"),
155
+ ];
156
+
157
+ for (const ignoreFile of ignoreFiles) {
158
+ if (fs.existsSync(ignoreFile)) {
159
+ logVerbose(`加载 .reviewignore: ${ignoreFile}`);
160
+ const content = fs.readFileSync(ignoreFile, "utf-8");
161
+ content.split("\n").forEach((line) => {
162
+ const trimmed = line.trim();
163
+ // 跳过空行和注释
164
+ if (trimmed && !trimmed.startsWith("#")) {
165
+ ignorePatterns.push(trimmed);
166
+ }
167
+ });
168
+ break; // 只使用找到的第一个 .reviewignore
169
+ }
170
+ }
171
+
172
+ return ignorePatterns;
173
+ }
174
+
175
+ // 将 gitignore 风格的 pattern 转换为正则表达式
176
+ function patternToRegex(pattern) {
177
+ // 处理否定模式
178
+ if (pattern.startsWith("!")) {
179
+ return { regex: patternToRegex(pattern.slice(1)).regex, negated: true };
180
+ }
181
+
182
+ let regexStr = pattern
183
+ // 转义正则特殊字符(除了 * 和 ?)
184
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
185
+ // ** 匹配任意路径(包括 /)
186
+ .replace(/\*\*/g, "{{DOUBLE_STAR}}")
187
+ // * 匹配任意字符(不包括 /)
188
+ .replace(/\*/g, "[^/]*")
189
+ // ? 匹配单个字符
190
+ .replace(/\?/g, "[^/]")
191
+ // 还原 **
192
+ .replace(/{{DOUBLE_STAR}}/g, ".*");
193
+
194
+ // 如果 pattern 以 / 开头,匹配从根目录开始
195
+ if (pattern.startsWith("/")) {
196
+ regexStr = "^" + regexStr.slice(2); // 移除开头的 \\/
197
+ } else {
198
+ // 否则匹配任意位置
199
+ regexStr = "(^|/)" + regexStr;
200
+ }
201
+
202
+ // 如果 pattern 以 / 结尾,只匹配目录
203
+ if (pattern.endsWith("/")) {
204
+ regexStr = regexStr.slice(0, -2) + "(/|$)";
205
+ } else {
206
+ regexStr += "($|/)";
207
+ }
208
+
209
+ return { regex: new RegExp(regexStr), negated: false };
210
+ }
211
+
212
+ // 检查文件是否应该被忽略
213
+ function shouldIgnoreFile(filePath, patterns) {
214
+ let ignored = false;
215
+
216
+ for (const pattern of patterns) {
217
+ const { regex, negated } = patternToRegex(pattern);
218
+ if (regex.test(filePath)) {
219
+ ignored = !negated;
220
+ }
221
+ }
222
+
223
+ return ignored;
224
+ }
225
+
226
+ // 过滤 diff,移除被忽略的文件
227
+ function filterDiff(diff, ignorePatterns) {
228
+ if (ignorePatterns.length === 0) {
229
+ return diff;
230
+ }
231
+
232
+ const lines = diff.split("\n");
233
+ const filteredLines = [];
234
+ let currentFile = null;
235
+ let skipCurrentFile = false;
236
+ let ignoredFiles = [];
237
+
238
+ for (const line of lines) {
239
+ // 检测 diff 文件头
240
+ const diffMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
241
+ if (diffMatch) {
242
+ currentFile = diffMatch[2];
243
+ skipCurrentFile = shouldIgnoreFile(currentFile, ignorePatterns);
244
+ if (skipCurrentFile) {
245
+ ignoredFiles.push(currentFile);
246
+ }
247
+ }
248
+
249
+ if (!skipCurrentFile) {
250
+ filteredLines.push(line);
251
+ }
252
+ }
253
+
254
+ if (ignoredFiles.length > 0) {
255
+ logVerbose(`跳过的文件 (${ignoredFiles.length}): ${ignoredFiles.join(", ")}`);
256
+ }
257
+
258
+ return filteredLines.join("\n");
259
+ }
260
+
149
261
  const commitMsgFile = process.argv[2];
150
- const commitSource = process.argv[3]; // message, template, merge, squash, commit
151
262
 
152
- // 如果是 merge/squash 或已有 message,跳过处理
153
- if (["merge", "squash", "commit"].includes(commitSource)) {
154
- console.log("ℹ️ 跳过 AI Review(merge/squash/amend 提交)");
263
+ // 检查是否是 merge 提交(通过检查 MERGE_HEAD 文件)
264
+ function isMergeCommit() {
265
+ try {
266
+ const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
267
+ return fs.existsSync(path.join(gitDir, "MERGE_HEAD"));
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ // 检查 commit message 是否已经有内容(非模板)
274
+ function hasExistingMessage() {
275
+ if (!commitMsgFile || !fs.existsSync(commitMsgFile)) {
276
+ return false;
277
+ }
278
+ const content = fs.readFileSync(commitMsgFile, "utf-8");
279
+ // 过滤掉注释行和空行
280
+ const meaningfulLines = content
281
+ .split("\n")
282
+ .filter((line) => !line.startsWith("#") && line.trim());
283
+ return meaningfulLines.length > 0;
284
+ }
285
+
286
+ // 如果是 merge 提交或已有 message,跳过处理
287
+ if (isMergeCommit()) {
288
+ console.log("ℹ️ 跳过 AI Review(merge 提交)");
289
+ process.exit(0);
290
+ }
291
+
292
+ if (hasExistingMessage()) {
293
+ logVerbose("检测到已有 commit message,跳过 AI 生成");
155
294
  process.exit(0);
156
295
  }
157
296
 
@@ -231,10 +370,16 @@ async function runAIReview() {
231
370
  }
232
371
 
233
372
  try {
373
+ // 0. 加载 .reviewignore 配置
374
+ const ignorePatterns = loadReviewIgnore();
375
+ if (ignorePatterns.length > 0) {
376
+ logVerbose(`已加载 ${ignorePatterns.length} 个忽略规则`);
377
+ }
378
+
234
379
  // 1. 获取暂存区 Diff
235
380
  logVerbose("正在获取暂存区 Diff...");
236
381
  const diffTimer = logTime("获取 Diff");
237
- const diff = execSync("git diff --cached", { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
382
+ let diff = execSync("git diff --cached", { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
238
383
  logTimeEnd(diffTimer);
239
384
 
240
385
  if (!diff.trim()) {
@@ -242,7 +387,17 @@ async function runAIReview() {
242
387
  process.exit(0);
243
388
  }
244
389
 
245
- logVerbose(`Diff 大小: ${(diff.length / 1000).toFixed(2)}KB`);
390
+ logVerbose(`原始 Diff 大小: ${(diff.length / 1000).toFixed(2)}KB`);
391
+
392
+ // 1.5 应用 .reviewignore 过滤
393
+ diff = filterDiff(diff, ignorePatterns);
394
+
395
+ if (!diff.trim()) {
396
+ console.log("ℹ️ 所有更改的文件都在 .reviewignore 中,跳过 AI Review");
397
+ process.exit(0);
398
+ }
399
+
400
+ logVerbose(`过滤后 Diff 大小: ${(diff.length / 1000).toFixed(2)}KB`);
246
401
 
247
402
  // 2. 运行构建检查
248
403
  if (!CONFIG.skipBuild) {
@@ -319,11 +474,21 @@ async function runAIReview() {
319
474
 
320
475
  // 5. 处理结果
321
476
  if (result.is_passed) {
322
- fs.writeFileSync(commitMsgFile, result.message);
477
+ // 构建 commit message,将 reason 作为注释附加
478
+ let commitMessage = result.message;
479
+ if (result.reason && result.reason.trim()) {
480
+ // 将 reason 转换为 Git 注释格式(每行以 # 开头)
481
+ const reasonLines = result.reason
482
+ .split("\n")
483
+ .map((line) => `# ${line}`)
484
+ .join("\n");
485
+ commitMessage += `\n\n# --- AI Review 建议 ---\n${reasonLines}`;
486
+ }
487
+ fs.writeFileSync(commitMsgFile, commitMessage);
323
488
  console.log("✅ AI Review 通过");
324
489
  console.log(`📝 生成的提交信息: ${result.message}`);
325
- if (result.suggestions) {
326
- console.log(`💡 建议: ${result.suggestions}`);
490
+ if (result.reason && result.reason.trim()) {
491
+ console.log(`💡 建议: ${result.reason}`);
327
492
  }
328
493
  logTimeEnd(totalTimer);
329
494
  process.exit(0); // 确保成功时返回退出码 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxb-clouds/ai-code-review",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "基于 OpenAI API 的 Git Hooks 集成方案,自动代码审查并生成 Conventional Commits 提交信息",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,10 @@
29
29
  "https-proxy-agent": "^7.0.6",
30
30
  "openai": "^4.0.0"
31
31
  },
32
+ "files": [
33
+ "bin/",
34
+ ".reviewignore.example"
35
+ ],
32
36
  "peerDependencies": {
33
37
  "husky": ">=9.0.0"
34
38
  },
package/test/test.mjs DELETED
@@ -1,534 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * 本地测试脚本
5
- * 用于测试 AI Code Review 的各项功能
6
- */
7
-
8
- import fs from "fs";
9
- import path from "path";
10
- import { execSync, spawn } from "child_process";
11
- import { fileURLToPath } from "url";
12
- import { HttpsProxyAgent } from "https-proxy-agent";
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
- const projectRoot = path.resolve(__dirname, "..");
16
-
17
- // 颜色输出
18
- const colors = {
19
- reset: "\x1b[0m",
20
- green: "\x1b[32m",
21
- red: "\x1b[31m",
22
- yellow: "\x1b[33m",
23
- blue: "\x1b[34m",
24
- cyan: "\x1b[36m",
25
- };
26
-
27
- function log(msg, color = "reset") {
28
- console.log(`${colors[color]}${msg}${colors.reset}`);
29
- }
30
-
31
- function logSection(title) {
32
- console.log("\n" + "=".repeat(50));
33
- log(`📋 ${title}`, "cyan");
34
- console.log("=".repeat(50));
35
- }
36
-
37
- function logVerbose(msg) {
38
- // 在测试中始终启用 verbose 日志
39
- console.log(`[DEBUG] ${msg}`);
40
- }
41
-
42
- // 从 OpenAI 错误提取诊断信息
43
- function analyzeOpenAIError(error) {
44
- const errorInfo = {
45
- code: error.code,
46
- status: error.status,
47
- message: error.message,
48
- type: error.type || error.constructor?.name,
49
- };
50
-
51
- // 尝试获取原始错误信息
52
- if (error.response?.data?.error) {
53
- const apiError = error.response.data.error;
54
- errorInfo.apiError = {
55
- type: apiError.type,
56
- message: apiError.message,
57
- param: apiError.param,
58
- code: apiError.code,
59
- };
60
- }
61
-
62
- // 检查底层错误
63
- if (error.cause) {
64
- errorInfo.cause = {
65
- code: error.cause.code,
66
- errno: error.cause.errno,
67
- syscall: error.cause.syscall,
68
- hostname: error.cause.hostname,
69
- port: error.cause.port,
70
- };
71
- }
72
-
73
- return errorInfo;
74
- }
75
-
76
- // 测试用例
77
- const tests = {
78
- // 测试环境变量加载
79
- async testEnvLoading() {
80
- logSection("测试环境变量加载");
81
-
82
- const envPath = path.join(projectRoot, ".env");
83
- if (fs.existsSync(envPath)) {
84
- log("✅ .env 文件存在", "green");
85
-
86
- const content = fs.readFileSync(envPath, "utf-8");
87
- const hasApiKey = content.includes("OPENAI_API_KEY");
88
-
89
- if (hasApiKey) {
90
- log("✅ OPENAI_API_KEY 已配置", "green");
91
- } else {
92
- log("⚠️ OPENAI_API_KEY 未配置", "yellow");
93
- }
94
- } else {
95
- log("⚠️ .env 文件不存在,请从 .env.example 复制", "yellow");
96
- }
97
- },
98
-
99
- // 测试 CLI 命令
100
- async testCLI() {
101
- logSection("测试 CLI 命令");
102
-
103
- try {
104
- const helpOutput = execSync("node bin/cli.mjs help", {
105
- cwd: projectRoot,
106
- encoding: "utf-8",
107
- });
108
- log("✅ ai-review help 命令正常", "green");
109
- console.log(helpOutput);
110
- } catch (error) {
111
- log("❌ CLI 命令失败: " + error.message, "red");
112
- }
113
- },
114
-
115
- // 测试 Git Diff 获取
116
- async testGitDiff() {
117
- logSection("测试 Git Diff 获取");
118
-
119
- try {
120
- const diff = execSync("git diff --cached", {
121
- cwd: projectRoot,
122
- encoding: "utf-8",
123
- });
124
-
125
- if (diff.trim()) {
126
- log(`✅ 检测到暂存区变更 (${(diff.length / 1000).toFixed(1)}KB)`, "green");
127
- console.log("前 500 字符预览:");
128
- console.log(diff.slice(0, 500) + (diff.length > 500 ? "\n..." : ""));
129
- } else {
130
- log("ℹ️ 暂存区没有变更", "blue");
131
- log(" 提示: 使用 git add <file> 添加文件后再测试", "yellow");
132
- }
133
- } catch (error) {
134
- log("❌ Git Diff 获取失败: " + error.message, "red");
135
- }
136
- },
137
-
138
- // 测试构建命令
139
- async testBuild() {
140
- logSection("测试构建命令");
141
-
142
- const packageJsonPath = path.join(projectRoot, "package.json");
143
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
144
-
145
- if (packageJson.scripts?.build) {
146
- log("ℹ️ 发现 build 脚本,尝试运行...", "blue");
147
- try {
148
- execSync("npm run build", { cwd: projectRoot, stdio: "inherit" });
149
- log("✅ 构建成功", "green");
150
- } catch (error) {
151
- log("❌ 构建失败", "red");
152
- }
153
- } else {
154
- log("ℹ️ 没有 build 脚本(这是正常的,此项目不需要构建)", "blue");
155
- }
156
- },
157
-
158
- // 模拟 Hook 调用(不实际调用 AI)
159
- async testHookDryRun() {
160
- logSection("测试 Hook 逻辑(模拟运行)");
161
-
162
- // 检查必要文件
163
- const hookPath = path.join(projectRoot, "bin/hook.mjs");
164
- if (fs.existsSync(hookPath)) {
165
- log("✅ hook.mjs 文件存在", "green");
166
- } else {
167
- log("❌ hook.mjs 文件不存在", "red");
168
- return;
169
- }
170
-
171
- // 检查依赖
172
- try {
173
- await import("openai");
174
- log("✅ openai 依赖已安装", "green");
175
- } catch {
176
- log("❌ openai 依赖未安装,请运行 npm install", "red");
177
- }
178
- },
179
-
180
- // AI 提供商预设配置
181
- AI_PROVIDERS: {
182
- openai: {
183
- baseURL: "https://api.openai.com/v1",
184
- defaultModel: "gpt-4o-mini",
185
- envKey: "OPENAI_API_KEY",
186
- },
187
- deepseek: {
188
- baseURL: "https://api.deepseek.com",
189
- defaultModel: "deepseek-chat",
190
- envKey: "DEEPSEEK_API_KEY",
191
- },
192
- },
193
-
194
- // 获取当前 AI 配置
195
- getAIConfig() {
196
- const provider = (process.env.AI_PROVIDER || "openai").toLowerCase();
197
- const preset = this.AI_PROVIDERS[provider] || this.AI_PROVIDERS.openai;
198
- const apiKey = process.env[preset.envKey] || process.env.OPENAI_API_KEY;
199
- const baseURL = process.env.OPENAI_BASE_URL || preset.baseURL;
200
- const model = process.env.OPENAI_MODEL || preset.defaultModel;
201
- return { provider, apiKey, baseURL, model };
202
- },
203
-
204
- // 测试 API 连接
205
- async testAPI() {
206
- logSection("测试 AI API 连接");
207
-
208
- // 加载环境变量
209
- const envPath = path.join(projectRoot, ".env");
210
- if (!fs.existsSync(envPath)) {
211
- log("⏭️ 跳过:未找到 .env 文件", "yellow");
212
- return;
213
- }
214
-
215
- const envContent = fs.readFileSync(envPath, "utf-8");
216
- envContent.split("\n").forEach((line) => {
217
- const trimmed = line.trim();
218
- if (trimmed && !trimmed.startsWith("#")) {
219
- const [key, ...vals] = trimmed.split("=");
220
- if (key && vals.length) {
221
- process.env[key.trim()] = vals.join("=").trim();
222
- }
223
- }
224
- });
225
-
226
- const aiConfig = this.getAIConfig();
227
-
228
- if (!aiConfig.apiKey) {
229
- log(`⏭️ 跳过:未配置 API Key`, "yellow");
230
- log(` 当前提供商: ${aiConfig.provider}`, "yellow");
231
- log(
232
- ` 需要设置: ${this.AI_PROVIDERS[aiConfig.provider]?.envKey || "OPENAI_API_KEY"}`,
233
- "yellow",
234
- );
235
- return;
236
- }
237
-
238
- log(`🔗 正在测试 ${aiConfig.provider.toUpperCase()} API 连接...`, "blue");
239
- const timeout = parseInt(process.env.AI_REVIEW_TIMEOUT) || 30000;
240
- const proxyUrl =
241
- process.env.HTTPS_PROXY ||
242
- process.env.HTTP_PROXY ||
243
- process.env.https_proxy ||
244
- process.env.http_proxy;
245
-
246
- log(` 提供商: ${aiConfig.provider}`, "blue");
247
- log(` Base URL: ${aiConfig.baseURL}`, "blue");
248
- log(` 模型: ${aiConfig.model}`, "blue");
249
- log(` 超时: ${timeout}ms`, "blue");
250
- if (proxyUrl) {
251
- log(` 代理: ${proxyUrl}`, "blue");
252
- } else {
253
- log(` 代理: 未配置 (设置 HTTPS_PROXY 环境变量启用)`, "yellow");
254
- }
255
-
256
- try {
257
- const OpenAI = (await import("openai")).default;
258
-
259
- // 创建代理 agent
260
- let httpAgent = undefined;
261
- if (proxyUrl) {
262
- httpAgent = new HttpsProxyAgent(proxyUrl);
263
- logVerbose(` 已创建代理 agent`);
264
- }
265
-
266
- // 创建 OpenAI 客户端实例
267
- log("\n 1️⃣ 初始化 AI 客户端...", "blue");
268
- const openai = new OpenAI({
269
- apiKey: aiConfig.apiKey,
270
- baseURL: aiConfig.baseURL,
271
- timeout: timeout,
272
- maxRetries: 0, // 禁用自动重试,由我们控制
273
- httpAgent: httpAgent,
274
- });
275
-
276
- logVerbose(` 客户端初始化成功`);
277
-
278
- // 测试 API 连接 - 发送轻量级请求
279
- log(" 2️⃣ 测试 API 连接...", "blue");
280
- const startTime = Date.now();
281
-
282
- try {
283
- const completion = await openai.chat.completions.create({
284
- model: aiConfig.model,
285
- messages: [
286
- {
287
- role: "user",
288
- content: "Say 'OK' only.",
289
- },
290
- ],
291
- max_tokens: 10,
292
- temperature: 0,
293
- });
294
-
295
- const elapsed = Date.now() - startTime;
296
-
297
- log(" 3️⃣ 解析响应...", "blue");
298
- const response = completion.choices[0]?.message?.content || "";
299
-
300
- log("\n✅ OpenAI API 连接成功!", "green");
301
- log(` 实际模型: ${completion.model}`, "blue");
302
- log(` 响应内容: ${response}`, "blue");
303
- log(` 响应时间: ${elapsed}ms`, "blue");
304
-
305
- if (completion.usage) {
306
- log(` 📊 Token 使用:`, "blue");
307
- log(` • Prompt tokens: ${completion.usage.prompt_tokens}`, "blue");
308
- log(` • Completion tokens: ${completion.usage.completion_tokens}`, "blue");
309
- log(` • Total tokens: ${completion.usage.total_tokens}`, "blue");
310
- }
311
-
312
- log(`\n✨ 所有检查通过,API 连接正常!`, "green");
313
- } catch (apiError) {
314
- // API 调用失败,但客户端创建成功说明基本连接没问题
315
- throw apiError;
316
- }
317
- } catch (error) {
318
- log("❌ API 测试失败", "red");
319
-
320
- // 获取错误详情
321
- const errorInfo = analyzeOpenAIError(error);
322
-
323
- logVerbose(`\n错误对象分析:`);
324
- logVerbose(` Code: ${errorInfo.code}`);
325
- logVerbose(` Status: ${errorInfo.status}`);
326
- logVerbose(` Type: ${errorInfo.type}`);
327
- logVerbose(` Message: ${errorInfo.message}`);
328
- if (errorInfo.apiError) {
329
- logVerbose(` API Error: ${JSON.stringify(errorInfo.apiError)}`);
330
- }
331
- if (errorInfo.cause) {
332
- logVerbose(` Cause: ${JSON.stringify(errorInfo.cause)}`);
333
- }
334
-
335
- log("\n🔍 诊断和修复建议:", "yellow");
336
-
337
- // 详细的错误诊断
338
- if (error.code === "ECONNREFUSED" || errorInfo.cause?.code === "ECONNREFUSED") {
339
- log("\n 错误:无法连接到服务器", "red");
340
- log(" 可能原因:", "blue");
341
- log(" • Base URL 错误或服务器无法访问", "blue");
342
- log(" • 网络连接问题", "blue");
343
- log(" • 防火墙或代理阻止了请求", "blue");
344
- log(
345
- `\n 当前 Base URL: ${process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"}`,
346
- "yellow",
347
- );
348
- log(" 修复步骤:", "blue");
349
- log(" 1. 测试网络: ping api.openai.com", "blue");
350
- log(" 2. 检查代理: 如需代理,设置 HTTP_PROXY 或 HTTPS_PROXY", "blue");
351
- log(" 3. 检查防火墙设置", "blue");
352
- } else if (error.code === "ETIMEDOUT" || errorInfo.cause?.code === "ETIMEDOUT") {
353
- log("\n 错误:请求超时", "red");
354
- log(" 可能原因:", "blue");
355
- log(" • 网络延迟过高", "blue");
356
- log(" • API 响应缓慢", "blue");
357
- log(" • 超时时间设置过短", "blue");
358
- log(`\n 当前超时: ${process.env.AI_REVIEW_TIMEOUT || 30000}ms`, "yellow");
359
- log(" 修复步骤:", "blue");
360
- log(" 1. 增加超时: 设置 AI_REVIEW_TIMEOUT=60000", "blue");
361
- log(" 2. 检查网络延迟: ping api.openai.com", "blue");
362
- log(" 3. 稍后重试", "blue");
363
- } else if (error.code === "ENOTFOUND" || errorInfo.cause?.code === "ENOTFOUND") {
364
- log("\n 错误:DNS 解析失败", "red");
365
- log(" 可能原因:", "blue");
366
- log(" • DNS 配置错误", "blue");
367
- log(" • 域名拼写错误", "blue");
368
- log(" • 网络无法访问 DNS 服务", "blue");
369
- log(
370
- `\n 当前 Base URL: ${process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"}`,
371
- "yellow",
372
- );
373
- log(" 修复步骤:", "blue");
374
- log(" 1. 检查 DNS 设置", "blue");
375
- log(" 2. 尝试使用公共 DNS 8.8.8.8", "blue");
376
- log(" 3. 检查 Base URL 拼写", "blue");
377
- } else if (error.status === 401 || errorInfo.apiError?.code === "invalid_api_key") {
378
- log("\n 错误:API Key 无效或已过期", "red");
379
- log(" 可能原因:", "blue");
380
- log(" • API Key 不正确或已删除", "blue");
381
- log(" • API Key 已过期", "blue");
382
- log(" • API Key 权限不足", "blue");
383
- log("\n 修复步骤:", "blue");
384
- log(" 1. 检查 .env 中的 OPENAI_API_KEY 是否正确", "blue");
385
- log(" 2. 访问 https://platform.openai.com/api-keys 重新生成 Key", "blue");
386
- log(" 3. 等待 60 秒后重试", "blue");
387
- } else if (error.status === 429 || errorInfo.apiError?.code === "rate_limit_exceeded") {
388
- log("\n 错误:请求频率超限或配额不足", "red");
389
- log(" 可能原因:", "blue");
390
- log(" • 请求过于频繁", "blue");
391
- log(" • API 配额已用尽", "blue");
392
- log(" • 账户余额不足", "blue");
393
- log("\n 修复步骤:", "blue");
394
- log(" 1. 等待几分钟后重试", "blue");
395
- log(" 2. 检查账户余额: https://platform.openai.com/account/billing/overview", "blue");
396
- log(" 3. 检查使用配额: https://platform.openai.com/account/rate-limits", "blue");
397
- } else if (error.status === 404 || errorInfo.apiError?.code === "model_not_found") {
398
- log("\n 错误:模型不存在或无权访问", "red");
399
- log(" 可能原因:", "blue");
400
- log(" • 模型名称错误或不存在", "blue");
401
- log(" • 模型已下线", "blue");
402
- log(" • 账户无权使用该模型", "blue");
403
- log(`\n 当前模型: ${process.env.OPENAI_MODEL || "gpt-4o-mini"}`, "yellow");
404
- log(" 修复步骤:", "blue");
405
- log(" 1. 检查模型名称: https://platform.openai.com/docs/models", "blue");
406
- log(" 2. 修改 OPENAI_MODEL 为有效的模型名", "blue");
407
- log(" 3. 检查账户权限", "blue");
408
- } else if (error.status === 500 || error.status === 502 || error.status === 503) {
409
- log(`\n 错误:OpenAI 服务器错误 (${error.status})`, "red");
410
- log(" 可能原因:", "blue");
411
- log(" • OpenAI 服务暂时不可用", "blue");
412
- log(" • 服务器故障", "blue");
413
- log(" • 服务器维护中", "blue");
414
- log("\n 修复步骤:", "blue");
415
- log(" 1. 稍后重试", "blue");
416
- log(" 2. 检查 OpenAI 状态: https://status.openai.com", "blue");
417
- log(" 3. 查看官方通告", "blue");
418
- } else {
419
- log(`\n 错误: ${error.message}`, "yellow");
420
- log(` 错误类型: ${errorInfo.type}`, "yellow");
421
- log(` 错误代码: ${errorInfo.code || "未知"}`, "yellow");
422
- if (error.status) {
423
- log(` HTTP 状态: ${error.status}`, "yellow");
424
- }
425
- log("\n 通用排查步骤:", "blue");
426
- log(" 1. 启用详细日志: AI_REVIEW_VERBOSE=true", "blue");
427
- log(" 2. 检查 .env 配置文件中的所有参数", "blue");
428
- log(" 3. 验证 API Key 和 Base URL", "blue");
429
- log(" 4. 测试网络连接: curl https://api.openai.com/v1/models", "blue");
430
- log(" 5. 查看完整错误信息(使用 AI_REVIEW_VERBOSE=true)", "blue");
431
- }
432
- }
433
- },
434
-
435
- // 测试完整流程(需要 API Key)
436
- async testFullFlow() {
437
- logSection("测试完整流程(需要暂存的更改和 API Key)");
438
-
439
- const envPath = path.join(projectRoot, ".env");
440
- if (!fs.existsSync(envPath)) {
441
- log("⏭️ 跳过:未配置 .env", "yellow");
442
- return;
443
- }
444
-
445
- try {
446
- const diff = execSync("git diff --cached", {
447
- cwd: projectRoot,
448
- encoding: "utf-8",
449
- });
450
-
451
- if (!diff.trim()) {
452
- log("⏭️ 跳过:暂存区没有变更", "yellow");
453
- log(" 要测试完整流程,请先 git add 一些文件", "blue");
454
- return;
455
- }
456
-
457
- log("🚀 运行完整 Hook 测试...", "blue");
458
- log(" 这将调用 OpenAI API(会产生费用)", "yellow");
459
- log(" 按 Ctrl+C 可取消\n", "yellow");
460
-
461
- // 创建临时 commit msg 文件
462
- const tmpMsgFile = path.join(projectRoot, ".git/COMMIT_EDITMSG_TEST");
463
- fs.writeFileSync(tmpMsgFile, "");
464
-
465
- try {
466
- execSync(`node bin/hook.mjs "${tmpMsgFile}" message`, {
467
- cwd: projectRoot,
468
- stdio: "inherit",
469
- env: { ...process.env },
470
- });
471
-
472
- const generatedMsg = fs.readFileSync(tmpMsgFile, "utf-8");
473
- if (generatedMsg) {
474
- log("\n✅ 完整流程测试成功!", "green");
475
- log(`📝 生成的提交信息: ${generatedMsg}`, "cyan");
476
- }
477
- } finally {
478
- // 清理临时文件
479
- if (fs.existsSync(tmpMsgFile)) {
480
- fs.unlinkSync(tmpMsgFile);
481
- }
482
- }
483
- } catch (error) {
484
- log("❌ 完整流程测试失败: " + error.message, "red");
485
- }
486
- },
487
- };
488
-
489
- // 运行所有测试
490
- async function runTests() {
491
- console.log("\n🧪 AI Code Review 本地测试\n");
492
-
493
- const testName = process.argv[2];
494
-
495
- if (testName && tests[testName]) {
496
- // 运行指定测试
497
- await tests[testName]();
498
- } else if (testName === "full") {
499
- // 运行完整流程测试
500
- await tests.testFullFlow();
501
- } else {
502
- // 运行所有基础测试
503
- await tests.testEnvLoading();
504
- await tests.testCLI();
505
- await tests.testGitDiff();
506
- await tests.testBuild();
507
- await tests.testHookDryRun();
508
-
509
- console.log("\n" + "=".repeat(50));
510
- log("💡 提示", "cyan");
511
- console.log("=".repeat(50));
512
- console.log(`
513
- 测试 API 连接(会调用 API,消耗少量 token):
514
- npm test testAPI
515
-
516
- 运行完整流程测试(会调用 API):
517
- npm test full
518
-
519
- 单独运行某个测试:
520
- npm test testEnvLoading
521
- npm test testCLI
522
- npm test testGitDiff
523
- npm test testAPI
524
- npm test testFullFlow
525
-
526
- 本地链接测试(在其他项目中使用):
527
- npm run test:link
528
- cd ../other-project
529
- npx ai-review init
530
- `);
531
- }
532
- }
533
-
534
- runTests().catch(console.error);