@zdthfhxh/ai-code-review-cli 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/ai-review +35 -0
- package/package.json +37 -0
- package/src/analyzer.js +166 -0
- package/src/config.js +85 -0
- package/src/filter.js +89 -0
- package/src/git.js +100 -0
- package/src/index.js +81 -0
- package/src/pipeline.js +26 -0
- package/src/provider/deepseek.js +66 -0
- package/src/provider/index.js +60 -0
- package/src/provider/ollama.js +70 -0
- package/src/provider/openai.js +66 -0
- package/src/reporter/index.js +40 -0
- package/src/reporter/json.js +15 -0
- package/src/reporter/sarif.js +73 -0
- package/src/reporter/terminal.js +80 -0
- package/src/runner.js +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# AI Code Review
|
|
2
|
+
|
|
3
|
+
> AI 代码审查 CLI 工具 — 在终端里审查代码改动,支持 DeepSeek、OpenAI、Ollama
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
8
|
+
## 为什么做这个?
|
|
9
|
+
|
|
10
|
+
代码审查很重要,但找人 review 很慢,自己 review 容易漏掉问题。AI Code Review 从你的 git diff 自动分析代码,找出潜在的问题,就像有一个资深的代码审查专家随时待命。
|
|
11
|
+
|
|
12
|
+
**参考框架:** Open Core 模式(开源核心 + 付费高级功能)
|
|
13
|
+
**对标项目:** reviewdog(8.4k stars)+ pr-agent(5k stars)+ code-review-gpt(1.8k stars)
|
|
14
|
+
|
|
15
|
+
## 特性
|
|
16
|
+
|
|
17
|
+
| 功能 | 免费版 | Pro 版 |
|
|
18
|
+
|:---|:---:|:---:|
|
|
19
|
+
| 代码审查(AI 分析) | ✅ | ✅ |
|
|
20
|
+
| 多 Provider 支持(DeepSeek/OpenAI/Ollama) | ✅ | ✅ |
|
|
21
|
+
| 终端输出 | ✅ | ✅ |
|
|
22
|
+
| JSON/SARIF 输出 | ✅ | ✅ |
|
|
23
|
+
| 指定文件审查 | ✅ | ✅ |
|
|
24
|
+
| 指定 commit 审查 | ✅ | ✅ |
|
|
25
|
+
| 严重级别过滤 | ❌ | ✅ |
|
|
26
|
+
| 团队规范检查 | ❌ | ✅ |
|
|
27
|
+
| CI/CD 集成(GitHub Action) | ❌ | ✅ |
|
|
28
|
+
| 自动修复建议 | ❌ | ✅ |
|
|
29
|
+
|
|
30
|
+
## 快速开始
|
|
31
|
+
|
|
32
|
+
### 安装
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 全局安装
|
|
36
|
+
npm install -g ai-code-review
|
|
37
|
+
|
|
38
|
+
# 或者直接运行
|
|
39
|
+
npx ai-code-review
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 配置
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# 设置 API 密钥(DeepSeek 默认)
|
|
46
|
+
ai-review config set AI_API_KEY sk-your-key
|
|
47
|
+
|
|
48
|
+
# 或者使用 OpenAI
|
|
49
|
+
ai-review config set AI_API_KEY sk-your-openai-key
|
|
50
|
+
ai-review config set AI_PROVIDER openai
|
|
51
|
+
ai-review config set AI_API_BASE https://api.openai.com/v1
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 使用
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# 审查当前分支的改动
|
|
58
|
+
ai-review
|
|
59
|
+
|
|
60
|
+
# 审查指定文件
|
|
61
|
+
ai-review src/index.js
|
|
62
|
+
|
|
63
|
+
# 审查已暂存的改动
|
|
64
|
+
ai-review --staged
|
|
65
|
+
|
|
66
|
+
# 审查指定 commit
|
|
67
|
+
ai-review --commit abc123
|
|
68
|
+
|
|
69
|
+
# 使用 OpenAI
|
|
70
|
+
ai-review --provider openai --model gpt-4
|
|
71
|
+
|
|
72
|
+
# 输出 JSON 格式
|
|
73
|
+
ai-review --output json
|
|
74
|
+
|
|
75
|
+
# 查看 AI 会收到什么(不实际调用)
|
|
76
|
+
ai-review --dry-run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 环境变量
|
|
80
|
+
|
|
81
|
+
所有配置项也可以通过环境变量设置:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
AI_REVIEW_AI_API_KEY=sk-your-key
|
|
85
|
+
AI_REVIEW_AI_PROVIDER=deepseek
|
|
86
|
+
AI_REVIEW_AI_MODEL=deepseek-chat
|
|
87
|
+
AI_REVIEW_REVIEW_LANGUAGE=zh
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 输出示例
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
📋 代码审查报告
|
|
94
|
+
──────────────────────────────────────────────────
|
|
95
|
+
发现 5 个问题,其中 1 个严重,2 个高优先级
|
|
96
|
+
──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
🔴 [严重] SQL 注入风险:用户输入未经过滤直接拼接
|
|
99
|
+
📁 src/user.js:42
|
|
100
|
+
用户输入直接用于数据库查询,存在 SQL 注入风险
|
|
101
|
+
💡 使用参数化查询代替字符串拼接
|
|
102
|
+
|
|
103
|
+
🟠 [高] 未处理的 Promise 错误
|
|
104
|
+
📁 src/api.js:15
|
|
105
|
+
async 函数中没有 try-catch,错误会导致进程崩溃
|
|
106
|
+
💡 添加 try-catch 或使用 .catch()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 对比
|
|
110
|
+
|
|
111
|
+
| 工具 | 类型 | AI | Price | CLI |
|
|
112
|
+
|:----|:----|:--:|:-----:|:---:|
|
|
113
|
+
| **ai-code-review** | CLI | ✅ | 免费 + Pro $9/月 | ✅ |
|
|
114
|
+
| CodeRabbit | GitHub App | ✅ | $12/月 | ❌ |
|
|
115
|
+
| pr-agent | GitHub App | ✅ | 免费 + $49/月 | ❌ |
|
|
116
|
+
| reviewdog | CLI | ❌ | 免费 | ✅ |
|
|
117
|
+
| code-review-gpt | CLI | ✅ | 免费 | ✅ |
|
|
118
|
+
|
|
119
|
+
## 许可证
|
|
120
|
+
|
|
121
|
+
MIT
|
package/bin/ai-review
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ai-review CLI 入口
|
|
4
|
+
*
|
|
5
|
+
* 参考框架:commander(最流行的 Node.js CLI 框架)
|
|
6
|
+
* 参考项目:ai-commit-message(复用 CLI 架构)
|
|
7
|
+
* 新增:reviewdog 风格的管道架构支持
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { program } = require('commander');
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('ai-review')
|
|
15
|
+
.description('AI 代码审查工具 — 在终端里审查代码改动')
|
|
16
|
+
.version(pkg.version)
|
|
17
|
+
.argument('[file]', '指定要审查的文件路径(可选,默认审查所有改动)')
|
|
18
|
+
.option('-s, --staged', '只审查已暂存(staged)的改动')
|
|
19
|
+
.option('-c, --commit <hash>', '审查指定 commit 的改动')
|
|
20
|
+
.option('--dry-run', '只查看 AI 会收到什么内容,不实际调用 AI')
|
|
21
|
+
.option('--model <model>', '指定 AI 模型(默认从配置读取)')
|
|
22
|
+
.option('--provider <provider>', '指定 AI 提供商(deepseek/openai/ollama)')
|
|
23
|
+
.option('--output <format>', '输出格式(terminal/json/sarif)', 'terminal')
|
|
24
|
+
.option('--severity <level>', '最低显示严重级别(low/medium/high/critical),默认显示全部')
|
|
25
|
+
.action(async (file, options) => {
|
|
26
|
+
const { run } = require('../src/index.js');
|
|
27
|
+
try {
|
|
28
|
+
await run(file, options);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('❌ 错误:', err.message);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zdthfhxh/ai-code-review-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered code review CLI — 在终端里审查代码,支持 DeepSeek、OpenAI、Ollama",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-review": "./bin/ai-review"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"vercel-build": "echo 'Static site - no build needed'",
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"dev": "node src/index.js --dry-run"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"code-review",
|
|
16
|
+
"ai",
|
|
17
|
+
"cli",
|
|
18
|
+
"git",
|
|
19
|
+
"deepseek",
|
|
20
|
+
"openai",
|
|
21
|
+
"ollama"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^12.0.0",
|
|
26
|
+
"conf": "^12.0.0"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"src/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/analyzer.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyzer 阶段 — AI 代码审查引擎
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:pr-agent 的审查提示词设计(结构化多维度分析)
|
|
5
|
+
* OpenAI 的 function calling 模式(结构化输出)
|
|
6
|
+
*
|
|
7
|
+
* 参考:pr-agent (6.5k ⭐) — 多维审查提示词、few-shot 示例、结构化输出
|
|
8
|
+
* OpenAI API — chat completions 标准格式
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const provider = require('./provider/index.js');
|
|
12
|
+
const config = require('./config.js');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 构建审查提示词
|
|
16
|
+
* @param {string} diff - git diff 内容
|
|
17
|
+
* @param {Object} ctx - 上下文(branch, files, stats 等)
|
|
18
|
+
* @param {string} language - 输出语言(zh/en)
|
|
19
|
+
* @returns {Array} 消息数组
|
|
20
|
+
*/
|
|
21
|
+
function buildReviewPrompt(diff, ctx, language = 'zh') {
|
|
22
|
+
const langInstruction = language === 'zh'
|
|
23
|
+
? '请用中文回复。'
|
|
24
|
+
: 'Please respond in English.';
|
|
25
|
+
|
|
26
|
+
const systemPrompt = `你是一个资深的代码审查专家。你的任务是审查代码 diff,找出潜在的问题。
|
|
27
|
+
|
|
28
|
+
${langInstruction}
|
|
29
|
+
|
|
30
|
+
## 审查维度
|
|
31
|
+
|
|
32
|
+
请从以下 5 个维度审查代码:
|
|
33
|
+
|
|
34
|
+
1. **正确性 (correctness)** — 是否有逻辑错误?边界条件处理不当?竞态条件?并发问题?
|
|
35
|
+
2. **安全性 (security)** — 是否有安全漏洞?SQL注入?XSS?CSRF?敏感信息泄露?权限校验缺失?
|
|
36
|
+
3. **性能 (performance)** — 是否有性能问题?不必要的循环?内存泄漏?N+1查询?未使用缓存?
|
|
37
|
+
4. **可维护性 (maintainability)** — 代码是否清晰?命名是否合理?是否有重复代码?复杂度是否过高?
|
|
38
|
+
5. **最佳实践 (best-practice)** — 是否遵循了当前语言/框架的最佳实践?是否有过时的 API 使用?
|
|
39
|
+
|
|
40
|
+
## 严重级别定义
|
|
41
|
+
|
|
42
|
+
- **critical**:可能导致生产事故或安全漏洞,必须修复
|
|
43
|
+
- **high**:明显的问题,强烈建议修复
|
|
44
|
+
- **medium**:需要注意的问题,建议修复
|
|
45
|
+
- **low**:小问题或改进建议
|
|
46
|
+
- **note**:仅供参考,非必须修复
|
|
47
|
+
|
|
48
|
+
## 审查规则
|
|
49
|
+
|
|
50
|
+
1. 只审查 diff 中改动的代码,不要审查未改动的代码
|
|
51
|
+
2. 如果某个问题只出现在新增代码中,标明行号
|
|
52
|
+
3. 对于每个问题,必须给出具体的修改建议
|
|
53
|
+
4. 如果确实没有发现问题,issues 数组可以为空
|
|
54
|
+
5. 不要过度报告:只报告真正的问题,不要为了凑数而报告
|
|
55
|
+
|
|
56
|
+
## 输出格式
|
|
57
|
+
|
|
58
|
+
请严格按照以下 JSON 格式输出,不要添加其他内容:
|
|
59
|
+
|
|
60
|
+
\`\`\`json
|
|
61
|
+
{
|
|
62
|
+
"summary": "审查总结(一句话概括)",
|
|
63
|
+
"issues": [
|
|
64
|
+
{
|
|
65
|
+
"severity": "critical | high | medium | low | note",
|
|
66
|
+
"category": "correctness | security | performance | maintainability | best-practice",
|
|
67
|
+
"file": "文件名",
|
|
68
|
+
"line": 行号(不确定就填 null),
|
|
69
|
+
"title": "问题标题(简短,不超过 20 字)",
|
|
70
|
+
"description": "问题描述(说明为什么这是个问题)",
|
|
71
|
+
"suggestion": "修改建议(具体可操作的建议)"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"positive": ["值得肯定的地方(可选,1-3条)"],
|
|
75
|
+
"stats": {
|
|
76
|
+
"totalIssues": 总问题数,
|
|
77
|
+
"critical": 严重问题数,
|
|
78
|
+
"high": 高优先级数,
|
|
79
|
+
"medium": 中优先级数,
|
|
80
|
+
"low": 低优先级数
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
\`\`\`
|
|
84
|
+
|
|
85
|
+
如果没有发现问题,请返回:
|
|
86
|
+
\`\`\`json
|
|
87
|
+
{
|
|
88
|
+
"summary": "代码审查通过,未发现明显问题",
|
|
89
|
+
"issues": [],
|
|
90
|
+
"positive": ["代码质量良好"],
|
|
91
|
+
"stats": { "totalIssues": 0, "critical": 0, "high": 0, "medium": 0, "low": 0 }
|
|
92
|
+
}
|
|
93
|
+
\`\`\``;
|
|
94
|
+
|
|
95
|
+
// 构建上下文信息
|
|
96
|
+
const contextInfo = [];
|
|
97
|
+
if (ctx.branch) contextInfo.push(`分支: ${ctx.branch}`);
|
|
98
|
+
if (ctx.files && ctx.files.length > 0) {
|
|
99
|
+
contextInfo.push(`改动文件: ${ctx.files.map(f => `${f.file} (${f.status})`).join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
const contextStr = contextInfo.length > 0 ? `\n## 上下文信息\n\n${contextInfo.join('\n')}\n` : '';
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
{ role: 'system', content: systemPrompt },
|
|
105
|
+
{ role: 'user', content: `请审查以下代码 diff:\n\n${contextStr}\`\`\`diff\n${diff}\n\`\`\`` }
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 解析 AI 回复为结构化审查结果
|
|
111
|
+
* @param {string} content - AI 回复内容
|
|
112
|
+
* @returns {Object} 审查结果对象
|
|
113
|
+
*/
|
|
114
|
+
function parseReviewResult(content) {
|
|
115
|
+
try {
|
|
116
|
+
// 尝试从 ```json ... ``` 中提取
|
|
117
|
+
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
118
|
+
if (jsonMatch) {
|
|
119
|
+
return JSON.parse(jsonMatch[1]);
|
|
120
|
+
}
|
|
121
|
+
// 尝试直接解析
|
|
122
|
+
return JSON.parse(content);
|
|
123
|
+
} catch {
|
|
124
|
+
// AI 回复不是 JSON 格式,包装成简单结构
|
|
125
|
+
return {
|
|
126
|
+
summary: 'AI 回复格式异常',
|
|
127
|
+
issues: [{
|
|
128
|
+
severity: 'note',
|
|
129
|
+
category: 'maintainability',
|
|
130
|
+
file: '',
|
|
131
|
+
line: null,
|
|
132
|
+
title: 'AI 回复格式异常',
|
|
133
|
+
description: 'AI 没有按预期格式返回 JSON,以下是原始回复:\n' + content,
|
|
134
|
+
suggestion: '请检查 AI 模型是否支持 JSON 格式输出,或尝试更换模型'
|
|
135
|
+
}],
|
|
136
|
+
positives: [],
|
|
137
|
+
stats: { totalIssues: 1, critical: 0, high: 0, medium: 0, low: 0 }
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Analyzer 阶段:调用 AI 分析代码 diff
|
|
144
|
+
* @param {Object} context - 管道上下文
|
|
145
|
+
* @returns {Object} 更新后的上下文,包含审查结果
|
|
146
|
+
*/
|
|
147
|
+
async function analyzer(context) {
|
|
148
|
+
const { diff, options } = context;
|
|
149
|
+
|
|
150
|
+
if (!diff || diff.length === 0) {
|
|
151
|
+
return { reviewResult: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const language = options.language || config.get('REVIEW_LANGUAGE');
|
|
155
|
+
|
|
156
|
+
const messages = buildReviewPrompt(diff, context, language);
|
|
157
|
+
const content = await provider.callAI(messages, {
|
|
158
|
+
provider: options.provider,
|
|
159
|
+
model: options.model
|
|
160
|
+
});
|
|
161
|
+
const reviewResult = parseReviewResult(content);
|
|
162
|
+
|
|
163
|
+
return { reviewResult };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { analyzer, buildReviewPrompt, parseReviewResult };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置管理
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:conf(最流行的 Node.js 配置管理库)
|
|
5
|
+
* 参考项目:ai-commit-message(复用配置模式)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const Conf = require('conf').default;
|
|
9
|
+
|
|
10
|
+
const schema = {
|
|
11
|
+
AI_API_KEY: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
default: '',
|
|
14
|
+
description: 'AI API 密钥'
|
|
15
|
+
},
|
|
16
|
+
AI_API_BASE: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
default: 'https://api.deepseek.com',
|
|
19
|
+
description: 'AI API 地址'
|
|
20
|
+
},
|
|
21
|
+
AI_MODEL: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
default: 'deepseek-chat',
|
|
24
|
+
description: 'AI 模型名'
|
|
25
|
+
},
|
|
26
|
+
AI_PROVIDER: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
default: 'deepseek',
|
|
29
|
+
description: 'AI 提供商(deepseek/openai/ollama)'
|
|
30
|
+
},
|
|
31
|
+
REVIEW_LANGUAGE: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
default: 'zh',
|
|
34
|
+
description: '审查输出语言(zh/en)'
|
|
35
|
+
},
|
|
36
|
+
SEVERITY_THRESHOLD: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
default: 'low',
|
|
39
|
+
description: '最低显示严重级别(low/medium/high/critical)'
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const config = new Conf({ schema, projectName: 'ai-code-review' });
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 获取配置值
|
|
47
|
+
* 优先级:环境变量 > 配置文件 > 默认值
|
|
48
|
+
*/
|
|
49
|
+
function get(key) {
|
|
50
|
+
const envKey = `AI_REVIEW_${key}`;
|
|
51
|
+
if (process.env[envKey]) return process.env[envKey];
|
|
52
|
+
return config.get(key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 设置配置值
|
|
57
|
+
*/
|
|
58
|
+
function set(key, value) {
|
|
59
|
+
config.set(key, value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 获取所有配置
|
|
64
|
+
*/
|
|
65
|
+
function getAll() {
|
|
66
|
+
const all = {};
|
|
67
|
+
for (const key of Object.keys(schema)) {
|
|
68
|
+
all[key] = get(key);
|
|
69
|
+
}
|
|
70
|
+
return all;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 获取提供商对应的默认 API 地址
|
|
75
|
+
*/
|
|
76
|
+
function getDefaultBaseUrl(provider) {
|
|
77
|
+
const urls = {
|
|
78
|
+
deepseek: 'https://api.deepseek.com',
|
|
79
|
+
openai: 'https://api.openai.com/v1',
|
|
80
|
+
ollama: 'http://localhost:11434'
|
|
81
|
+
};
|
|
82
|
+
return urls[provider] || urls.deepseek;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { get, set, getAll, getDefaultBaseUrl };
|
package/src/filter.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter 阶段 — 过滤、排序、去重审查结果
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:reviewdog 的 Filter 阶段
|
|
5
|
+
* reviewdog 的 Filter 阶段负责按 diff 过滤结果(只显示改动行的结果)
|
|
6
|
+
* 我们的 Filter 负责按严重级别过滤、去重、排序
|
|
7
|
+
*
|
|
8
|
+
* 参考:reviewdog (8.2k ⭐) 的 Filter 设计
|
|
9
|
+
* eslint 的输出过滤逻辑(按严重级别)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 严重级别排序权重
|
|
14
|
+
*/
|
|
15
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, note: 4 };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 去重 key 生成
|
|
19
|
+
*/
|
|
20
|
+
function issueKey(issue) {
|
|
21
|
+
return `${issue.file || ''}:${issue.line || ''}:${issue.title}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 是否通过严重级别阈值
|
|
26
|
+
* @param {string} severity - 问题严重级别
|
|
27
|
+
* @param {string} threshold - 阈值(low/medium/high/critical)
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function passesThreshold(severity, threshold) {
|
|
31
|
+
const sev = SEVERITY_ORDER[severity] ?? 999;
|
|
32
|
+
const thr = SEVERITY_ORDER[threshold] ?? 0;
|
|
33
|
+
return sev >= thr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filter 阶段:过滤、排序、去重
|
|
38
|
+
* @param {Object} context - 管道上下文
|
|
39
|
+
* @returns {Object} 更新后的上下文,包含过滤后的审查结果
|
|
40
|
+
*/
|
|
41
|
+
async function filter(context) {
|
|
42
|
+
const { reviewResult, options } = context;
|
|
43
|
+
|
|
44
|
+
if (!reviewResult || !reviewResult.issues) {
|
|
45
|
+
return { filteredResult: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const threshold = options.severity || 'low';
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
|
|
51
|
+
// 过滤:按严重级别 + 去重
|
|
52
|
+
const filteredIssues = reviewResult.issues
|
|
53
|
+
.filter(issue => {
|
|
54
|
+
// 按严重级别阈值过滤
|
|
55
|
+
if (!passesThreshold(issue.severity, threshold)) return false;
|
|
56
|
+
|
|
57
|
+
// 去重
|
|
58
|
+
const key = issueKey(issue);
|
|
59
|
+
if (seen.has(key)) return false;
|
|
60
|
+
seen.add(key);
|
|
61
|
+
return true;
|
|
62
|
+
})
|
|
63
|
+
// 排序:严重级别从高到低
|
|
64
|
+
.sort((a, b) => {
|
|
65
|
+
const orderA = SEVERITY_ORDER[a.severity] ?? 999;
|
|
66
|
+
const orderB = SEVERITY_ORDER[b.severity] ?? 999;
|
|
67
|
+
return orderA - orderB;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 重新计算统计
|
|
71
|
+
const stats = {
|
|
72
|
+
totalIssues: filteredIssues.length,
|
|
73
|
+
critical: filteredIssues.filter(i => i.severity === 'critical').length,
|
|
74
|
+
high: filteredIssues.filter(i => i.severity === 'high').length,
|
|
75
|
+
medium: filteredIssues.filter(i => i.severity === 'medium').length,
|
|
76
|
+
low: filteredIssues.filter(i => i.severity === 'low').length
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
filteredResult: {
|
|
81
|
+
summary: reviewResult.summary,
|
|
82
|
+
issues: filteredIssues,
|
|
83
|
+
positives: reviewResult.positive || reviewResult.positives || [],
|
|
84
|
+
stats
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { filter };
|
package/src/git.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git 操作模块
|
|
3
|
+
*
|
|
4
|
+
* 读取 git diff 和文件内容,用于 AI 审查
|
|
5
|
+
*
|
|
6
|
+
* 参考框架:child_process(Node.js 标准库)+ git 命令
|
|
7
|
+
* 参考项目:ai-commit-message(复用 git diff 读取逻辑)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 检查当前目录是否是 git 仓库
|
|
16
|
+
*/
|
|
17
|
+
function isGitRepo() {
|
|
18
|
+
try {
|
|
19
|
+
execSync('git rev-parse --git-dir', { stdio: 'ignore', encoding: 'utf-8' });
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 获取 git diff
|
|
28
|
+
* @param {Object} options - { staged: boolean, commit: string, file: string }
|
|
29
|
+
* @returns {string} diff 内容
|
|
30
|
+
*/
|
|
31
|
+
function getDiff(options = {}) {
|
|
32
|
+
const { staged, commit, file } = options;
|
|
33
|
+
|
|
34
|
+
let args = ['diff'];
|
|
35
|
+
if (staged) args.push('--staged');
|
|
36
|
+
if (commit) args = ['diff', `${commit}^..${commit}`];
|
|
37
|
+
if (file) args.push(file);
|
|
38
|
+
args.push('--unified=10'); // 上下文行数
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
return execSync(`git ${args.join(' ')}`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`获取 git diff 失败: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 获取改动文件的列表
|
|
49
|
+
* @param {Object} options - { staged: boolean, commit: string }
|
|
50
|
+
* @returns {Array<{file: string, status: string}>}
|
|
51
|
+
*/
|
|
52
|
+
function getChangedFiles(options = {}) {
|
|
53
|
+
const { staged, commit } = options;
|
|
54
|
+
|
|
55
|
+
let args = ['diff', '--name-status'];
|
|
56
|
+
if (staged) args.push('--staged');
|
|
57
|
+
if (commit) args = ['diff', '--name-status', `${commit}^..${commit}`];
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const output = execSync(`git ${args.join(' ')}`, { encoding: 'utf-8' });
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const line of output.trim().split('\n')) {
|
|
63
|
+
if (!line) continue;
|
|
64
|
+
const [status, file] = line.split('\t');
|
|
65
|
+
files.push({ file, status });
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(`获取文件列表失败: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 获取当前分支名
|
|
75
|
+
*/
|
|
76
|
+
function getCurrentBranch() {
|
|
77
|
+
try {
|
|
78
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return 'unknown';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 获取文件内容的上下文(用于 AI 理解代码结构)
|
|
86
|
+
* @param {string} filePath - 文件路径
|
|
87
|
+
* @param {number} contextLines - 上下文行数
|
|
88
|
+
*/
|
|
89
|
+
function getFileContext(filePath, contextLines = 5) {
|
|
90
|
+
try {
|
|
91
|
+
const fullPath = path.resolve(filePath);
|
|
92
|
+
if (!fs.existsSync(fullPath)) return '';
|
|
93
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
94
|
+
return content;
|
|
95
|
+
} catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { isGitRepo, getDiff, getChangedFiles, getCurrentBranch, getFileContext };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主入口 — 管道编排器
|
|
3
|
+
*
|
|
4
|
+
* 组合 Runner → Analyzer → Filter → Reporter 四个阶段
|
|
5
|
+
*
|
|
6
|
+
* 参考框架:reviewdog 的 3 阶段管道架构(Runner → Filter → Report)
|
|
7
|
+
* pr-agent 的 AI 集成模式
|
|
8
|
+
*
|
|
9
|
+
* 参考:reviewdog (8.2k ⭐) — 管道架构
|
|
10
|
+
* pr-agent (6.5k ⭐) — AI 集成
|
|
11
|
+
* Pieter Levels — 快速 MVP 模式
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { runPipeline } = require('./pipeline.js');
|
|
15
|
+
const { runner } = require('./runner.js');
|
|
16
|
+
const { analyzer } = require('./analyzer.js');
|
|
17
|
+
const { filter } = require('./filter.js');
|
|
18
|
+
const reporter = require('./reporter/index.js');
|
|
19
|
+
const config = require('./config.js');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 运行代码审查
|
|
23
|
+
* @param {string} file - 指定文件(可选)
|
|
24
|
+
* @param {Object} options - 命令行选项
|
|
25
|
+
*/
|
|
26
|
+
async function run(file, options) {
|
|
27
|
+
// 组合管道阶段(不含 analyzer 和 filter,dry-run 不需要)
|
|
28
|
+
const phases = [runner];
|
|
29
|
+
|
|
30
|
+
if (!options.dryRun) {
|
|
31
|
+
phases.push(analyzer, filter);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 初始上下文
|
|
35
|
+
const initialContext = {
|
|
36
|
+
options: {
|
|
37
|
+
file,
|
|
38
|
+
staged: options.staged,
|
|
39
|
+
commit: options.commit,
|
|
40
|
+
provider: options.provider || config.get('AI_PROVIDER'),
|
|
41
|
+
model: options.model || config.get('AI_MODEL'),
|
|
42
|
+
language: config.get('REVIEW_LANGUAGE'),
|
|
43
|
+
severity: options.severity || config.get('SEVERITY_THRESHOLD'),
|
|
44
|
+
output: options.output || 'terminal'
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// 执行管道
|
|
49
|
+
const context = await runPipeline(phases, initialContext);
|
|
50
|
+
|
|
51
|
+
// 没有 diff 的情况
|
|
52
|
+
if (!context.diff || context.diff.length === 0) {
|
|
53
|
+
console.log('✅ 没有检测到代码改动,无需审查。');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Dry-run 模式:只显示 diff 内容,不调用 AI
|
|
58
|
+
if (options.dryRun) {
|
|
59
|
+
console.log('\n📄 [Dry Run] 以下内容将发送给 AI:');
|
|
60
|
+
console.log('─'.repeat(50));
|
|
61
|
+
console.log(context.diff.slice(0, 2000));
|
|
62
|
+
console.log('─'.repeat(50));
|
|
63
|
+
console.log(`(共 ${context.diff.length} 字符,已显示前 2000 字符)`);
|
|
64
|
+
console.log(`\n📊 统计: ${context.stats.fileCount} 个文件, +${context.stats.additions}/-${context.stats.deletions} 行\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 显示审查进度信息
|
|
69
|
+
console.log(`\n🔍 正在审查代码...`);
|
|
70
|
+
console.log(` 分支: ${context.branch}`);
|
|
71
|
+
console.log(` 文件: ${context.stats.fileCount} 个文件改动 (+${context.stats.additions}/-${context.stats.deletions} 行)`);
|
|
72
|
+
console.log(` Provider: ${initialContext.options.provider}`);
|
|
73
|
+
console.log(` 模型: ${initialContext.options.model}`);
|
|
74
|
+
console.log(`\n⏳ AI 正在分析,请稍候...\n`);
|
|
75
|
+
|
|
76
|
+
// 输出结果
|
|
77
|
+
const format = options.output || 'terminal';
|
|
78
|
+
reporter.report(context.filteredResult, format);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { run };
|
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管道执行引擎
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:reviewdog 的 3 阶段管道架构(Runner → Filter → Report)
|
|
5
|
+
* 每个阶段是一个纯异步函数,接收上下文并返回更新后的上下文
|
|
6
|
+
* 管道按顺序执行,一个阶段的输出是下一个阶段的输入
|
|
7
|
+
*
|
|
8
|
+
* 参考:reviewdog (8.2k ⭐) 的 ARCHITECTURE.md — 3 阶段设计
|
|
9
|
+
* Unix 管道哲学 — 每个阶段做一件事,做好它
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 执行管道
|
|
14
|
+
* @param {Array<Function>} phases - 阶段函数数组,每个函数接收 context 返回 context
|
|
15
|
+
* @param {Object} initialContext - 初始上下文
|
|
16
|
+
* @returns {Object} 最终上下文
|
|
17
|
+
*/
|
|
18
|
+
async function runPipeline(phases, initialContext = {}) {
|
|
19
|
+
let context = { ...initialContext };
|
|
20
|
+
for (const phase of phases) {
|
|
21
|
+
context = { ...context, ...(await phase(context)) };
|
|
22
|
+
}
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { runPipeline };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Provider
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:OpenAI API 格式(DeepSeek 完全兼容)
|
|
5
|
+
* DeepSeek 使用与 OpenAI 相同的 API 格式,只需修改 base URL
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 调用 DeepSeek API
|
|
12
|
+
* @param {Array} messages - 消息数组
|
|
13
|
+
* @param {Object} options - { model, apiKey, apiBase }
|
|
14
|
+
* @returns {Promise<string>} AI 回复内容
|
|
15
|
+
*/
|
|
16
|
+
function callAI(messages, options) {
|
|
17
|
+
const { model, apiKey, apiBase } = options;
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const body = JSON.stringify({
|
|
21
|
+
model: model || 'deepseek-chat',
|
|
22
|
+
messages: messages,
|
|
23
|
+
temperature: 0.3,
|
|
24
|
+
max_tokens: 4096
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const url = new URL((apiBase || 'https://api.deepseek.com') + '/chat/completions');
|
|
28
|
+
const req = https.request({
|
|
29
|
+
hostname: url.hostname,
|
|
30
|
+
port: url.port || 443,
|
|
31
|
+
path: url.pathname,
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
36
|
+
'Content-Length': Buffer.byteLength(body)
|
|
37
|
+
}
|
|
38
|
+
}, (res) => {
|
|
39
|
+
let data = '';
|
|
40
|
+
res.on('data', chunk => data += chunk);
|
|
41
|
+
res.on('end', () => {
|
|
42
|
+
if (res.statusCode !== 200) {
|
|
43
|
+
try {
|
|
44
|
+
const err = JSON.parse(data);
|
|
45
|
+
reject(new Error(err.error?.message || `API 返回 ${res.statusCode}`));
|
|
46
|
+
} catch {
|
|
47
|
+
reject(new Error(`API 返回 ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = JSON.parse(data);
|
|
53
|
+
resolve(result.choices[0].message.content);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
reject(new Error(`解析 DeepSeek 回复失败: ${err.message}`));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
req.write(body);
|
|
62
|
+
req.end();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { callAI };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider 工厂 — 选择并创建 AI 提供商实例
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:pr-agent 的 Provider 抽象层
|
|
5
|
+
* pr-agent 支持多个 AI 提供商(OpenAI、Anthropic、Cohere 等),
|
|
6
|
+
* 通过统一的接口抽象,让上层代码不关心具体提供商
|
|
7
|
+
*
|
|
8
|
+
* 参考:pr-agent (6.5k ⭐) 的 provider 抽象设计
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const config = require('../config.js');
|
|
12
|
+
|
|
13
|
+
const providers = {
|
|
14
|
+
deepseek: require('./deepseek.js'),
|
|
15
|
+
openai: require('./openai.js'),
|
|
16
|
+
ollama: require('./ollama.js')
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 获取提供商实例
|
|
21
|
+
* @param {string} name - 提供商名称
|
|
22
|
+
* @returns {Object} 提供商对象,包含 callAI 方法
|
|
23
|
+
*/
|
|
24
|
+
function getProvider(name) {
|
|
25
|
+
const provider = providers[name];
|
|
26
|
+
if (!provider) {
|
|
27
|
+
throw new Error(`不支持的 AI 提供商: ${name}。支持的提供商: ${Object.keys(providers).join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
return provider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 获取所有支持的提供商列表
|
|
34
|
+
* @returns {Array<string>}
|
|
35
|
+
*/
|
|
36
|
+
function getSupportedProviders() {
|
|
37
|
+
return Object.keys(providers);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 调用 AI 的统一入口
|
|
42
|
+
* @param {Array} messages - 消息数组
|
|
43
|
+
* @param {Object} options - { provider, model, apiKey, apiBase }
|
|
44
|
+
* @returns {Promise<string>} AI 回复内容
|
|
45
|
+
*/
|
|
46
|
+
async function callAI(messages, options = {}) {
|
|
47
|
+
const providerName = options.provider || config.get('AI_PROVIDER');
|
|
48
|
+
const model = options.model || config.get('AI_MODEL');
|
|
49
|
+
const apiKey = options.apiKey || config.get('AI_API_KEY');
|
|
50
|
+
const apiBase = options.apiBase || config.get('AI_API_BASE') || config.getDefaultBaseUrl(providerName);
|
|
51
|
+
|
|
52
|
+
if (!apiKey && providerName !== 'ollama') {
|
|
53
|
+
throw new Error(`未配置 API 密钥。请设置环境变量 AI_REVIEW_AI_API_KEY 或运行: ai-review config set AI_API_KEY <你的密钥>`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const provider = getProvider(providerName);
|
|
57
|
+
return provider.callAI(messages, { model, apiKey, apiBase });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { callAI, getProvider, getSupportedProviders };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Provider
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:Ollama API 格式(本地模型)
|
|
5
|
+
* Ollama 也兼容 OpenAI API 格式,但默认地址是 localhost:11434
|
|
6
|
+
* 不需要 API 密钥
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const http = require('http');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 调用 Ollama API
|
|
13
|
+
* @param {Array} messages - 消息数组
|
|
14
|
+
* @param {Object} options - { model, apiBase }
|
|
15
|
+
* @returns {Promise<string>} AI 回复内容
|
|
16
|
+
*/
|
|
17
|
+
function callAI(messages, options) {
|
|
18
|
+
const { model, apiBase } = options;
|
|
19
|
+
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const body = JSON.stringify({
|
|
22
|
+
model: model || 'codellama',
|
|
23
|
+
messages: messages,
|
|
24
|
+
temperature: 0.3,
|
|
25
|
+
stream: false
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const url = new URL((apiBase || 'http://localhost:11434') + '/api/chat');
|
|
29
|
+
const transport = url.protocol === 'https:' ? require('https') : http;
|
|
30
|
+
const defaultPort = url.protocol === 'https:' ? 443 : 80;
|
|
31
|
+
|
|
32
|
+
const req = transport.request({
|
|
33
|
+
hostname: url.hostname,
|
|
34
|
+
port: url.port || defaultPort,
|
|
35
|
+
path: url.pathname,
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'Content-Length': Buffer.byteLength(body)
|
|
40
|
+
}
|
|
41
|
+
}, (res) => {
|
|
42
|
+
let data = '';
|
|
43
|
+
res.on('data', chunk => data += chunk);
|
|
44
|
+
res.on('end', () => {
|
|
45
|
+
if (res.statusCode !== 200) {
|
|
46
|
+
reject(new Error(`Ollama 返回 ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const result = JSON.parse(data);
|
|
51
|
+
resolve(result.message?.content || '');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
reject(new Error(`解析 Ollama 回复失败: ${err.message}`));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
req.on('error', (err) => {
|
|
59
|
+
if (err.code === 'ECONNREFUSED') {
|
|
60
|
+
reject(new Error('无法连接到 Ollama,请确保 Ollama 正在运行 (http://localhost:11434)'));
|
|
61
|
+
} else {
|
|
62
|
+
reject(err);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
req.write(body);
|
|
66
|
+
req.end();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { callAI };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Provider
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:OpenAI API 格式(标准实现)
|
|
5
|
+
* 使用 OpenAI 的 chat completions API
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 调用 OpenAI API
|
|
12
|
+
* @param {Array} messages - 消息数组
|
|
13
|
+
* @param {Object} options - { model, apiKey, apiBase }
|
|
14
|
+
* @returns {Promise<string>} AI 回复内容
|
|
15
|
+
*/
|
|
16
|
+
function callAI(messages, options) {
|
|
17
|
+
const { model, apiKey, apiBase } = options;
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const body = JSON.stringify({
|
|
21
|
+
model: model || 'gpt-4o-mini',
|
|
22
|
+
messages: messages,
|
|
23
|
+
temperature: 0.3,
|
|
24
|
+
max_tokens: 4096
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const url = new URL((apiBase || 'https://api.openai.com/v1') + '/chat/completions');
|
|
28
|
+
const req = https.request({
|
|
29
|
+
hostname: url.hostname,
|
|
30
|
+
port: url.port || 443,
|
|
31
|
+
path: url.pathname,
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
36
|
+
'Content-Length': Buffer.byteLength(body)
|
|
37
|
+
}
|
|
38
|
+
}, (res) => {
|
|
39
|
+
let data = '';
|
|
40
|
+
res.on('data', chunk => data += chunk);
|
|
41
|
+
res.on('end', () => {
|
|
42
|
+
if (res.statusCode !== 200) {
|
|
43
|
+
try {
|
|
44
|
+
const err = JSON.parse(data);
|
|
45
|
+
reject(new Error(err.error?.message || `API 返回 ${res.statusCode}`));
|
|
46
|
+
} catch {
|
|
47
|
+
reject(new Error(`API 返回 ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = JSON.parse(data);
|
|
53
|
+
resolve(result.choices[0].message.content);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
reject(new Error(`解析 OpenAI 回复失败: ${err.message}`));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
req.write(body);
|
|
62
|
+
req.end();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { callAI };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporter 工厂 — 选择并创建输出格式化器
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:reviewdog 的 Reporter 设计
|
|
5
|
+
* reviewdog 的 Reporter 支持多种输出目标(GitHub Checks、GitLab MR、本地文件等),
|
|
6
|
+
* 通过统一的 reporter 接口实现可插拔
|
|
7
|
+
*
|
|
8
|
+
* 参考:reviewdog (8.2k ⭐) 的 Reporter 设计
|
|
9
|
+
* eslint 的 formatter 模式
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const reporters = {
|
|
13
|
+
terminal: require('./terminal.js'),
|
|
14
|
+
json: require('./json.js'),
|
|
15
|
+
sarif: require('./sarif.js')
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 输出审查结果
|
|
20
|
+
* @param {Object} result - 审查结果(filtered 后的)
|
|
21
|
+
* @param {string} format - 输出格式(terminal/json/sarif)
|
|
22
|
+
*/
|
|
23
|
+
function report(result, format = 'terminal') {
|
|
24
|
+
const reporter = reporters[format];
|
|
25
|
+
if (!reporter) {
|
|
26
|
+
console.error(`不支持的输出格式: ${format},使用 terminal`);
|
|
27
|
+
return reporters.terminal.print(result);
|
|
28
|
+
}
|
|
29
|
+
return reporter.print(result);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 获取所有支持的输出格式
|
|
34
|
+
* @returns {Array<string>}
|
|
35
|
+
*/
|
|
36
|
+
function getSupportedFormats() {
|
|
37
|
+
return Object.keys(reporters);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { report, getSupportedFormats };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Reporter — 以 JSON 格式输出审查结果
|
|
3
|
+
*
|
|
4
|
+
* 适用于程序化处理,如 CI/CD 管道中的后续处理
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 输出 JSON 格式
|
|
9
|
+
* @param {Object} result - 审查结果
|
|
10
|
+
*/
|
|
11
|
+
function print(result) {
|
|
12
|
+
console.log(JSON.stringify(result, null, 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { print };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SARIF Reporter — 以 SARIF 格式输出审查结果
|
|
3
|
+
*
|
|
4
|
+
* SARIF (Static Analysis Results Interchange Format) 是 OASIS 标准,
|
|
5
|
+
* 兼容 GitHub Code Scanning、Visual Studio 等工具
|
|
6
|
+
*
|
|
7
|
+
* 参考:OASIS SARIF 规范 v2.1.0
|
|
8
|
+
* https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
9
|
+
* reviewdog 的 SARIF 输出实现
|
|
10
|
+
* GitHub Code Scanning 的 SARIF 格式要求
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SARIF 级别映射
|
|
15
|
+
*/
|
|
16
|
+
const severityToLevel = {
|
|
17
|
+
critical: 'error',
|
|
18
|
+
high: 'warning',
|
|
19
|
+
medium: 'warning',
|
|
20
|
+
low: 'note',
|
|
21
|
+
note: 'note'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 输出 SARIF 格式
|
|
26
|
+
* @param {Object} result - 审查结果
|
|
27
|
+
*/
|
|
28
|
+
function print(result) {
|
|
29
|
+
if (!result || !result.issues) {
|
|
30
|
+
console.log(JSON.stringify({ version: '2.1.0', runs: [] }, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sarif = {
|
|
35
|
+
version: '2.1.0',
|
|
36
|
+
runs: [{
|
|
37
|
+
tool: {
|
|
38
|
+
driver: {
|
|
39
|
+
name: 'ai-review',
|
|
40
|
+
informationUri: 'https://github.com/2446573/ai-code-review',
|
|
41
|
+
version: require('../../package.json').version
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
results: result.issues.map(issue => {
|
|
45
|
+
const sarifResult = {
|
|
46
|
+
ruleId: `${issue.category}/${issue.severity}`,
|
|
47
|
+
level: severityToLevel[issue.severity] || 'note',
|
|
48
|
+
message: {
|
|
49
|
+
text: `${issue.title}: ${issue.description}${issue.suggestion ? `\n建议: ${issue.suggestion}` : ''}`
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (issue.file) {
|
|
54
|
+
sarifResult.locations = [{
|
|
55
|
+
physicalLocation: {
|
|
56
|
+
artifactLocation: { uri: issue.file },
|
|
57
|
+
region: {}
|
|
58
|
+
}
|
|
59
|
+
}];
|
|
60
|
+
if (issue.line) {
|
|
61
|
+
sarifResult.locations[0].physicalLocation.region.startLine = issue.line;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return sarifResult;
|
|
66
|
+
})
|
|
67
|
+
}]
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
console.log(JSON.stringify(sarif, null, 2));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { print };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Reporter — 在终端中以彩色方式输出审查结果
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:eslint 的终端输出风格(颜色编码 + 严重级别图标)
|
|
5
|
+
* reviewdog 的格式化输出
|
|
6
|
+
*
|
|
7
|
+
* 参考:eslint 的 stylish formatter(颜色编码风格)
|
|
8
|
+
* reviewdog 的 CheckStyle 输出格式
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 严重级别对应的样式
|
|
13
|
+
*/
|
|
14
|
+
const SEVERITY_STYLES = {
|
|
15
|
+
critical: { icon: '🔴', color: '\x1b[31m', label: '严重' },
|
|
16
|
+
high: { icon: '🟠', color: '\x1b[33m', label: '高' },
|
|
17
|
+
medium: { icon: '🟡', color: '\x1b[33m', label: '中' },
|
|
18
|
+
low: { icon: '🔵', color: '\x1b[36m', label: '低' },
|
|
19
|
+
note: { icon: '⚪', color: '\x1b[90m', label: '建议' }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const RESET = '\x1b[0m';
|
|
23
|
+
const BOLD = '\x1b[1m';
|
|
24
|
+
const GRAY = '\x1b[90m';
|
|
25
|
+
const GREEN = '\x1b[32m';
|
|
26
|
+
const SEPARATOR = '─';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 在终端输出审查结果
|
|
30
|
+
* @param {Object} result - 审查结果
|
|
31
|
+
*/
|
|
32
|
+
function print(result) {
|
|
33
|
+
if (!result || !result.issues || result.issues.length === 0) {
|
|
34
|
+
console.log(`\n${GREEN}✅ 审查完成,未发现问题。${RESET}\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 标题
|
|
39
|
+
console.log(`\n${BOLD}📋 代码审查报告${RESET}`);
|
|
40
|
+
console.log(`${GRAY}${SEPARATOR.repeat(50)}${RESET}`);
|
|
41
|
+
console.log(` ${result.summary || '审查完成'}`);
|
|
42
|
+
console.log(`${GRAY}${SEPARATOR.repeat(50)}${RESET}`);
|
|
43
|
+
|
|
44
|
+
// 统计信息
|
|
45
|
+
const stats = result.stats || {};
|
|
46
|
+
const statParts = [];
|
|
47
|
+
if (stats.critical) statParts.push(`\x1b[31m${stats.critical} 严重${RESET}`);
|
|
48
|
+
if (stats.high) statParts.push(`\x1b[33m${stats.high} 高${RESET}`);
|
|
49
|
+
if (stats.medium) statParts.push(`\x1b[33m${stats.medium} 中${RESET}`);
|
|
50
|
+
if (stats.low) statParts.push(`\x1b[36m${stats.low} 低${RESET}`);
|
|
51
|
+
const total = stats.totalIssues || result.issues.length;
|
|
52
|
+
console.log(` 共 ${total} 个问题: ${statParts.join(', ')}\n`);
|
|
53
|
+
|
|
54
|
+
// 逐条输出问题
|
|
55
|
+
for (const issue of result.issues) {
|
|
56
|
+
const style = SEVERITY_STYLES[issue.severity] || SEVERITY_STYLES.note;
|
|
57
|
+
const location = issue.file
|
|
58
|
+
? `${issue.file}${issue.line ? `:${issue.line}` : ''}`
|
|
59
|
+
: '';
|
|
60
|
+
|
|
61
|
+
console.log(`${style.icon} ${BOLD}[${style.label}]${RESET} ${issue.title}`);
|
|
62
|
+
if (location) console.log(` ${GRAY}📁 ${location}${RESET}`);
|
|
63
|
+
console.log(` ${issue.description}`);
|
|
64
|
+
if (issue.suggestion) {
|
|
65
|
+
console.log(` ${GREEN}💡 ${issue.suggestion}${RESET}`);
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 值得肯定的地方
|
|
71
|
+
if (result.positives && result.positives.length > 0) {
|
|
72
|
+
console.log(`${GREEN}✅ 值得肯定的地方:${RESET}`);
|
|
73
|
+
for (const p of result.positives) {
|
|
74
|
+
console.log(` • ${p}`);
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { print };
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner 阶段 — 读取 git diff 和文件上下文
|
|
3
|
+
*
|
|
4
|
+
* 参考框架:reviewdog 的 Runner 阶段
|
|
5
|
+
* reviewdog 的 Runner 负责运行 linter 并读取结果
|
|
6
|
+
* 我们的 Runner 负责读取 git diff 和文件内容
|
|
7
|
+
*
|
|
8
|
+
* 参考:
|
|
9
|
+
* reviewdog 的 Runner 设计:运行工具 → 读取结果 → 传递给下一阶段
|
|
10
|
+
* ai-commit-message 的 git diff 读取逻辑(复用)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const git = require('./git.js');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runner 阶段:读取 git diff 和文件上下文
|
|
17
|
+
* @param {Object} context - 管道上下文
|
|
18
|
+
* @param {Object} context.options - 命令行选项
|
|
19
|
+
* @returns {Object} 更新后的上下文,包含 diff、files、branch、stats
|
|
20
|
+
*/
|
|
21
|
+
async function runner(context) {
|
|
22
|
+
const options = context.options || {};
|
|
23
|
+
|
|
24
|
+
if (!git.isGitRepo()) {
|
|
25
|
+
throw new Error('当前目录不是 git 仓库,请先在 git 仓库中运行 ai-review');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const diff = git.getDiff({
|
|
29
|
+
staged: options.staged,
|
|
30
|
+
commit: options.commit,
|
|
31
|
+
file: options.file
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!diff || diff.trim().length === 0) {
|
|
35
|
+
return { diff: '', files: [], branch: 'unknown', stats: { fileCount: 0, additions: 0, deletions: 0, diffLength: 0 } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const files = git.getChangedFiles({
|
|
39
|
+
staged: options.staged,
|
|
40
|
+
commit: options.commit
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const branch = git.getCurrentBranch();
|
|
44
|
+
|
|
45
|
+
// 统计增删行数
|
|
46
|
+
let additions = 0;
|
|
47
|
+
let deletions = 0;
|
|
48
|
+
for (const line of diff.split('\n')) {
|
|
49
|
+
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
|
50
|
+
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
diff,
|
|
55
|
+
files,
|
|
56
|
+
branch,
|
|
57
|
+
stats: { fileCount: files.length, additions, deletions, diffLength: diff.length }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { runner };
|