Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49867987ab | ||
|
|
35193909e8 | ||
|
|
5a2c95f72b | ||
|
|
b04d70b99a | ||
|
|
39c58316a5 | ||
|
|
16618303ed | ||
|
|
fe64579cab | ||
|
|
738bce4a03 | ||
|
|
4e8bcdd68e | ||
|
|
422bd581e0 | ||
|
|
a79d5106a0 | ||
|
|
01df74b01e | ||
|
|
b812ff62f8 | ||
|
|
7632652b7b | ||
|
|
10cbfc39a5 | ||
|
|
c7e8b449c9 | ||
|
|
6eda653db6 | ||
|
|
4d9674831b | ||
|
|
28c29e69df | ||
|
|
26b0342934 | ||
|
|
02e2e4edd3 | ||
|
|
9de40815b0 | ||
|
|
16fd83412d | ||
|
|
1493b0a392 | ||
|
|
0b4d02d232 | ||
|
|
4515e28efc |
No files matched your search
@@ -15,22 +15,73 @@
|
|||||||
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md"><strong>简体中文</strong></a> |
|
||||||
|
<a href="README_EN.md">English</a> |
|
||||||
|
<a href="README_JA.md">日本語</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌟 设计初衷与定位
|
## 🌟 为什么做 git-air?
|
||||||
|
|
||||||
市面上有许多优秀的云端与 CI/CD 级别的 AI Code Review 平台(如各类 PR 自动审查 Bot),它们在团队协作和远程代码门禁上表现出色。
|
### 痛点:AI 写代码一时爽,Review 起来火葬场
|
||||||
|
|
||||||
**而 `git-air` 的定位是:将审查能力极致前置到开发者的本地终端(Pre-Commit / Local CLI)。**
|
现在的开发工作流几乎离不开 AI 辅助(Cursor、Copilot、各种大模型插件)。几秒钟生成上百行代码,写起来确实爽快。
|
||||||
|
|
||||||
在代码正式 push 或发起 PR 之前,在本地敲一行命令,就能以零延迟、极简的方式先做一轮深度扫描,把潜在的并发隐患、逻辑缺陷与敏感信息拦截在本地,作为开发者最轻量顺手的日常命令行结对编程助手。
|
**但这也带来了一个全新的致命痛点:**
|
||||||
|
- **“看起来太完美”的视觉欺骗**:AI 生成的代码排版工整、注释漂亮、语法标准,极容易让人产生盲目信任;
|
||||||
|
- **生成 AI 的“自恋惯性”**:在同一个对话上下文里,你问写代码的那个 AI *“有没有 Bug”*,它往往带有思维惯性与自恋幻觉,大概率自信满满地回复 *“逻辑完全正确、非常健壮”*;
|
||||||
|
- **隐蔽暗坑极难肉眼捕捉**:比如在某个 `if err != nil` 提前返回时漏了解锁(Mutex Leak)、协程内部缺少 `recover`、循环内未释放的连接……这些隐患肉眼一扫极易漏掉。
|
||||||
|
|
||||||
|
这导致了一个尴尬的死循环:**AI 负责批量生产代码,人类程序员负责在半夜线上故障时擦屁股。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 解法:把审查能力极致前置到本地终端(Pre-Commit)
|
||||||
|
|
||||||
|
市面上有许多优秀的云端与 CI/CD 级别的代码审查平台,而 **`git-air` 的定位是:在代码正式 commit 或发起 PR 之前,作为你本地终端里完全独立的“第三方审查员”。**
|
||||||
|
|
||||||
|
它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺:
|
||||||
|
|
||||||
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
|
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
|
||||||
- 🧠 **全模型自由接入**:开箱即用支持 **Google Gemini、DeepSeek、本地离线 Ollama、OpenAI** 以及任何兼容 OpenAI 协议的自定义端点。
|
- 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。
|
||||||
|
- 🔒 **本地敏感信息自动脱敏**:在 Diff 发送给大模型前,自动在本地脱敏私钥证书、API Key、Bearer Token、数据库连接串等敏感数据,零泄露风险。
|
||||||
- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
|
- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
|
||||||
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
|
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
|
||||||
|
- 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
|
||||||
|
- ✨ **AI 智能 Commit Message**:一键根据代码变更生成规范的 Conventional Commits 提交说明,支持**中/英/日/韩**等多语言自适应。
|
||||||
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
|
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
|
||||||
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。
|
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截(支持原 Hook 备份与链式调用),在代码上库前把好第一道质量关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ 终端效果演示
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git air
|
||||||
|
|
||||||
|
[git-air] 代码评审中... (Engine: gemini / gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
#### 变更概述
|
||||||
|
在用户鉴权模块中引入了 Redis 缓存逻辑,并调整了上下文超时传递机制。
|
||||||
|
|
||||||
|
#### 详细审查意见
|
||||||
|
- [BLOCKER] internal/service/user.go:45 - 严重安全隐患: 循环体内直接拼接原生 SQL 字符串,存在 SQL 注入风险。
|
||||||
|
// 建议修复方案:
|
||||||
|
db.Where("username = ?", inputName).First(&user)
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:82 - 潜在风险: 静默丢弃了 Redis 连接异常,当缓存宕机时可能造成缓存击穿。
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:103 - 并发隐患: 提前 return 时漏释放互斥锁(Mutex Leak),高并发下极易引发死锁。
|
||||||
|
|
||||||
|
#### 评审结论
|
||||||
|
- 结论: [REJECT]
|
||||||
|
- 评分: 60 / 100
|
||||||
|
|
||||||
|
📊 Token: 输入 1,243 / 输出 412 | ≈ $0.0025
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,9 +144,21 @@ git air config set --provider ollama --model qwen2.5-coder
|
|||||||
|
|
||||||
# 12. OpenAI 官方 (GPT-4o / o3-mini)
|
# 12. OpenAI 官方 (GPT-4o / o3-mini)
|
||||||
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
|
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
|
||||||
|
|
||||||
|
# 13. 自定义 Token 计费费率 (可选:覆盖内置价格表,单位: 美元/1M Tokens)
|
||||||
|
git air config set --price-input 0.75 --price-output 3.75
|
||||||
|
|
||||||
|
# 14. 永久设置 Commit Message 默认语言 (可选: auto, zh, en, ja, ko)
|
||||||
|
git air config set --commit-lang en
|
||||||
|
|
||||||
|
# 15. 配置专属代码审查自定义提示词 (可选)
|
||||||
|
git air config set --reviewer-prompt "你是一位严谨的 Go 资深专家,重点关注并发与内存"
|
||||||
|
|
||||||
|
# 16. 配置专属 Commit Message 自定义提示词 (可选)
|
||||||
|
git air config set --commit-msg-prompt "生成提交信息时,严格使用 Angular 规范"
|
||||||
```
|
```
|
||||||
|
|
||||||
查看当前配置状态:
|
查看当前配置状态(含自定义费率与脱敏 Key):
|
||||||
```bash
|
```bash
|
||||||
git air config get
|
git air config get
|
||||||
```
|
```
|
||||||
@@ -119,30 +182,91 @@ git air internal/service/chat.go
|
|||||||
|
|
||||||
# 临时指定模型或 Key 进行评审
|
# 临时指定模型或 Key 进行评审
|
||||||
git air --provider deepseek --model deepseek-chat
|
git air --provider deepseek --model deepseek-chat
|
||||||
|
|
||||||
|
# 输出结构化 JSON 报告(便于 CI/CD 自动化流水线或脚本消费)
|
||||||
|
git air --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 一键安装 Git Pre-commit 拦截钩子
|
### 3. AI 智能生成规范 Commit Message (`git air msg`)
|
||||||
|
|
||||||
|
写代码一时爽,写 Commit 抓耳挠腮?`git-air` 会根据你的代码改动,自动生成符合国际 Conventional Commits 规范的精炼提交说明,并支持**中/英/日/韩多语言智能自适应**与**自动暂存**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 基础用法:分析暂存区代码并交互式确认提交
|
||||||
|
git air msg
|
||||||
|
|
||||||
|
# 2. 自动暂存所有改动并生成提交(无需手动 git add)
|
||||||
|
git air msg -a
|
||||||
|
|
||||||
|
# 3. 指定单次输出自然语言(支持 auto, zh, en, ja, ko)
|
||||||
|
git air msg -l en # 生成英文说明(适合 GitHub 开源项目)
|
||||||
|
git air msg -l ja # 生成日语说明
|
||||||
|
git air msg -l ko # 生成韩语说明
|
||||||
|
|
||||||
|
# 4. 极速一条龙:自动暂存 + 生成 + 免确认直接提交
|
||||||
|
git air msg -a -c
|
||||||
|
|
||||||
|
# 5. 永久修改全局默认提交语言(无需每次加 -l 参数)
|
||||||
|
git air config set --commit-lang en # 以后永久默认生成英文
|
||||||
|
git air config set --commit-lang ja # 以后永久默认生成日文
|
||||||
|
git air config set --commit-lang auto # 恢复自动跟随系统
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果演示:**
|
||||||
|
```text
|
||||||
|
$ git air msg -l zh
|
||||||
|
[git-air] 正在生成 Conventional Commit Message... (Language: 简体中文 / Engine: gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
feat(auth): 引入 Redis 缓存并修复 Mutex 死锁隐患
|
||||||
|
|
||||||
|
- 在用户认证模块新增 Redis Token 缓存机制
|
||||||
|
- 修复提前 return 时未释放互斥锁的并发隐患
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
? 是否直接以此信息执行 git commit?[Y/n]: y
|
||||||
|
[SUCCESS] 代码提交成功!🎉
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 一键安装 Git Pre-commit 拦截钩子
|
||||||
|
|
||||||
在你的 Git 项目根目录下执行:
|
在你的 Git 项目根目录下执行:
|
||||||
```bash
|
```bash
|
||||||
git air hook install
|
git air hook install
|
||||||
```
|
```
|
||||||
以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒!
|
- **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线!
|
||||||
|
- **原 Hook 链式执行**:若仓库原本已有 `pre-commit`,`git-air` 会自动安全备份并在审查放行后**链式调用原有 Hook**,绝不破坏现有工程配置!
|
||||||
|
- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`;
|
||||||
|
- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。
|
||||||
|
|
||||||
如需卸载钩子:
|
如需卸载钩子并完全恢复原 Hook:
|
||||||
```bash
|
```bash
|
||||||
git air hook uninstall
|
git air hook uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 5. 版本查看与一键自动升级 (`git air update`)
|
||||||
|
|
||||||
|
`git-air` 内置了**轻量级 24 小时静默更新检测**,当 GitHub 发布新版本时会在终端底部自动提醒;你也可以随时手动检查与一键升级:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看当前版本并在线检查更新
|
||||||
|
git air version
|
||||||
|
|
||||||
|
# 一键自动拉取并升级到最新版本
|
||||||
|
git air update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚙️ 配置文件说明
|
## ⚙️ 配置文件说明
|
||||||
|
|
||||||
`git-air` 支持多层配置覆盖,优先级从高到低为:
|
`git-air` 支持多层配置覆盖,优先级从高到低为:
|
||||||
1. **命令行 Flags**(如 `--key`, `--model`, `--provider`)
|
1. **命令行 Flags**(如 `--key`, `--model`, `--provider`, `--lang`, `--strict`, `--no-block`)
|
||||||
2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
|
2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`)
|
||||||
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
|
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
|
||||||
4. **全局用户配置**(`~/.git-air/config.yaml`)
|
4. **全局用户配置**(`~/.git-air/config.yaml`)
|
||||||
|
|
||||||
@@ -161,9 +285,45 @@ model: "gemini-3.7-flash"
|
|||||||
# base_url: "https://api.deepseek.com/v1"
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
|
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# 旧版兼容字段(v1.x 仍可使用)
|
||||||
# custom_prompt: ""
|
# custom_prompt: ""
|
||||||
|
|
||||||
|
# 自定义模型单价 (可选,单位: 美元/1M Tokens)
|
||||||
|
# price_input: 0.75
|
||||||
|
# price_output: 3.75
|
||||||
|
|
||||||
|
# AI Commit Message 生成语言 (可选: auto, zh, en, ja, ko)
|
||||||
|
# commit_lang: "auto"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 提示词字段迁移说明
|
||||||
|
|
||||||
|
旧版本只有一个通用的 `custom_prompt` 字段。随着 `git-air` 同时支持代码 Review 和 AI Commit Message,两个场景的目标不同,继续共用一个字段容易造成提示词混用和行为不明确。
|
||||||
|
|
||||||
|
因此从当前版本开始,建议分别使用:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
custom_reviewer_prompt: "用于代码 Review 的提示词"
|
||||||
|
custom_commit_msg_prompt: "用于生成 Commit Message 的提示词"
|
||||||
|
```
|
||||||
|
|
||||||
|
`custom_prompt` 暂时仍然兼容,并会作为 `custom_reviewer_prompt` 使用。迁移方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git air config set --reviewer-prompt "你的 Reviewer Prompt"
|
||||||
|
git air config set --commit-msg-prompt "你的 Commit Message Prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
生命周期安排:
|
||||||
|
|
||||||
|
- `v1.x`:继续支持 `custom_prompt`;
|
||||||
|
- `v1.1.0`:标记为 deprecated,并提示用户迁移;
|
||||||
|
- `v2.0.0`:移除 `custom_prompt` 字段。
|
||||||
|
|
||||||
|
这是为了避免破坏现有用户配置,同时让两个 Prompt 的职责更加清晰。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 团队规则定制(`.airules`)
|
## 📋 团队规则定制(`.airules`)
|
||||||
@@ -180,6 +340,26 @@ model: "gemini-3.7-flash"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🛡️ 自定义忽略审查(`.airignore`)
|
||||||
|
|
||||||
|
除了内置自动忽略的锁文件与二进制外,你还可以在仓库根目录创建 `.airignore` 文件,自定义排除不需要 AI 评审的文件或目录:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# 忽略所有文档与原型设计
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# 忽略测试 Mock 与假数据
|
||||||
|
tests/mock/
|
||||||
|
testdata/
|
||||||
|
|
||||||
|
# 忽略私密配置文件
|
||||||
|
*.env
|
||||||
|
secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📄 开源许可证
|
## 📄 开源许可证
|
||||||
|
|
||||||
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
|
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
|
||||||
+362
@@ -0,0 +1,362 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/logo.png" width="180" height="180" alt="git-air logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">git-air 🍃</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go" alt="Go Version" /></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||||
|
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">简体中文</a> |
|
||||||
|
<a href="README_EN.md"><strong>English</strong></a> |
|
||||||
|
<a href="README_JA.md">日本語</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Why git-air?
|
||||||
|
|
||||||
|
### The Pain Point: AI Writes Code Fast, But Reviewing It Is a Nightmare
|
||||||
|
|
||||||
|
Most developers today rely heavily on AI coding assistants (Cursor, Copilot, and LLM extensions). Generating hundreds of lines of code in seconds feels incredible.
|
||||||
|
|
||||||
|
**However, this introduces a critical new problem:**
|
||||||
|
- **The "Looks Perfect" Illusion**: AI-generated code features neat indentation, convincing naming, and flawless syntax, making it dangerously easy to trust blindly;
|
||||||
|
- **The Confirmation Bias of Generating Models**: In the same conversation thread, asking the very same AI *"Are there any bugs here?"* almost always yields a self-flattering response: *"The logic is completely solid and robust!"*;
|
||||||
|
- **Subtle Traps Missed by Human Eyes**: Overlooked unlocked mutexes on early `return` paths, missing `recover()` inside goroutines, unreleased database connections in loops... These critical traps easily slip past quick manual skimming.
|
||||||
|
|
||||||
|
This creates an absurd cycle: **AI mass-produces code, while human engineers spend late nights fixing production crashes.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Solution: Shift Code Review Directly to Your Local Terminal (Pre-Commit)
|
||||||
|
|
||||||
|
While there are great cloud/CI-based PR review bots, **`git-air` is designed to be your independent, cold-blooded "third-party auditor" right inside your local terminal before you ever type `git commit`.**
|
||||||
|
|
||||||
|
Completely isolated from your chat history, it acts like a **grumpy but sharp senior architect sitting beside your desk**, ruthlessly inspecting only raw code changes (`git diff`):
|
||||||
|
|
||||||
|
- ⚡ **Git-Native Simplicity**: No workflow changes needed. Just run `git air` in your terminal for instant feedback.
|
||||||
|
- 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini 3.x, Anthropic Claude, xAI Grok, DeepSeek, Qwen, Zhipu GLM, Moonshot Kimi, Local Ollama, OpenAI**, and any OpenAI-compatible endpoint.
|
||||||
|
- 🔒 **Local Secret Redaction**: Automatically masks private keys, API keys, Bearer tokens, and database connection strings before sending diffs to the LLM (zero leakage risk).
|
||||||
|
- 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops.
|
||||||
|
- 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches.
|
||||||
|
- 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free).
|
||||||
|
- ✨ **AI Commit Message Generator**: Automatically generates clean, standardized Conventional Commits messages with multi-language support (English, Japanese, Korean, Chinese).
|
||||||
|
- 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines.
|
||||||
|
- 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command (with automatic backup and chain execution of existing hooks) to block bugs before code is committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Terminal Preview
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git air
|
||||||
|
|
||||||
|
[git-air] Reviewing code... (Engine: gemini / gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
#### Change Summary
|
||||||
|
Added Redis caching layer to user authentication and refined context propagation.
|
||||||
|
|
||||||
|
#### Detailed Findings
|
||||||
|
- [BLOCKER] internal/service/user.go:45 - Critical Vulnerability: Direct SQL string concatenation inside loop. Potential SQL injection.
|
||||||
|
// Suggested Fix:
|
||||||
|
db.Where("username = ?", inputName).First(&user)
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:82 - Potential Risk: Silently discarding Redis error. May lead to cache stampede.
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:103 - Concurrency Issue: Mutex unlock skipped on early return branch. High risk of deadlock.
|
||||||
|
|
||||||
|
#### Verdict
|
||||||
|
- Status: [REJECT]
|
||||||
|
- Score: 60 / 100
|
||||||
|
|
||||||
|
📊 Token: Input 1,243 / Output 412 | ≈ $0.0025
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Option 1: Via Go Install (Recommended)
|
||||||
|
```bash
|
||||||
|
go install github.com/unclesam-ly/git-air@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Build from Source
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/unclesam-ly/git-air.git
|
||||||
|
cd git-air
|
||||||
|
go build -o git-air .
|
||||||
|
sudo mv git-air /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Configure Model & API Key
|
||||||
|
|
||||||
|
`git-air` comes with pre-configured endpoints and default models for all major AI providers. Simply pass `--provider`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Google Gemini (Recommended: fast & cost-effective)
|
||||||
|
git air config set --provider gemini --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet)
|
||||||
|
git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet
|
||||||
|
|
||||||
|
# 3. xAI Grok (Grok-2 / Grok-3)
|
||||||
|
git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest
|
||||||
|
|
||||||
|
# 4. DeepSeek (Deep reasoning)
|
||||||
|
git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat
|
||||||
|
|
||||||
|
# 5. SiliconFlow (DeepSeek-V3 hosted)
|
||||||
|
git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
|
||||||
|
|
||||||
|
# 6. Qwen / DashScope (Alibaba Cloud)
|
||||||
|
git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus
|
||||||
|
|
||||||
|
# 7. Zhipu AI (GLM-4)
|
||||||
|
git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus
|
||||||
|
|
||||||
|
# 8. Moonshot AI (Kimi)
|
||||||
|
git air config set --provider moonshot --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 9. Groq (Ultra-fast inference)
|
||||||
|
git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile
|
||||||
|
|
||||||
|
# 10. OpenRouter (Universal AI Gateway)
|
||||||
|
git air config set --provider openrouter --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 11. 100% Offline Local Privacy (Ollama)
|
||||||
|
git air config set --provider ollama --model qwen2.5-coder
|
||||||
|
|
||||||
|
# 12. OpenAI Official (GPT-4o / o3-mini)
|
||||||
|
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
|
||||||
|
|
||||||
|
# 13. Custom Token Pricing (Optional: override default rates, USD/1M tokens)
|
||||||
|
git air config set --price-input 0.75 --price-output 3.75
|
||||||
|
|
||||||
|
# 14. Permanently Set Default Commit Message Language (auto, zh, en, ja, ko)
|
||||||
|
git air config set --commit-lang en
|
||||||
|
|
||||||
|
# 15. Set Dedicated Custom Code Reviewer Prompt (Optional)
|
||||||
|
git air config set --reviewer-prompt "You are a principal Go architect focusing on concurrency and memory safety"
|
||||||
|
|
||||||
|
# 16. Set Dedicated Custom Commit Message Prompt (Optional)
|
||||||
|
git air config set --commit-msg-prompt "Always follow the strict Angular commit conventions"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check current configuration (including masked keys and custom rates):
|
||||||
|
```bash
|
||||||
|
git air config get
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Everyday Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Review currently staged changes (default)
|
||||||
|
git air
|
||||||
|
|
||||||
|
# Review the previous commit
|
||||||
|
git air HEAD~1
|
||||||
|
|
||||||
|
# Review differences between branches
|
||||||
|
git air main..feature-agent
|
||||||
|
|
||||||
|
# Review a specific file
|
||||||
|
git air internal/service/chat.go
|
||||||
|
|
||||||
|
# Temporarily override provider or model
|
||||||
|
git air --provider deepseek --model deepseek-chat
|
||||||
|
|
||||||
|
# Output structured JSON report (for CI/CD pipelines and scripts)
|
||||||
|
git air --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. AI-Powered Commit Message Generation (`git air msg`)
|
||||||
|
|
||||||
|
Struggling to write concise commit messages? `git-air` inspects your code changes and automatically crafts a clean Conventional Commits message with **auto-language adaptation** and **auto-staging**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Basic usage: analyze staged changes & interactively commit
|
||||||
|
git air msg
|
||||||
|
|
||||||
|
# 2. Auto-stage all modified files (no need for manual git add)
|
||||||
|
git air msg -a
|
||||||
|
|
||||||
|
# 3. Explicitly specify natural language for one-off run (auto, zh, en, ja, ko)
|
||||||
|
git air msg -l en # English (ideal for open-source repos)
|
||||||
|
git air msg -l ja # Japanese
|
||||||
|
git air msg -l ko # Korean
|
||||||
|
|
||||||
|
# 4. Instant one-liner: auto-stage + generate + direct commit
|
||||||
|
git air msg -a -c
|
||||||
|
|
||||||
|
# 5. Permanently set global default commit language (no need for -l flag)
|
||||||
|
git air config set --commit-lang en # Default to English forever
|
||||||
|
git air config set --commit-lang ja # Default to Japanese forever
|
||||||
|
git air config set --commit-lang auto # Reset to auto-detect system locale
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal Preview:**
|
||||||
|
```text
|
||||||
|
$ git air msg -l en
|
||||||
|
[git-air] Generating Conventional Commit Message... (Language: English / Engine: gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
feat(auth): add Redis token caching and fix mutex deadlock
|
||||||
|
|
||||||
|
- Introduce Redis caching layer for user authentication
|
||||||
|
- Fix concurrency bug where mutex was not unlocked on early return
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
? Execute git commit with this message? [Y/n]: y
|
||||||
|
[SUCCESS] Code committed successfully! 🎉
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. One-Click Git Pre-commit Hook
|
||||||
|
|
||||||
|
Install the hook inside any Git repository:
|
||||||
|
```bash
|
||||||
|
git air hook install
|
||||||
|
```
|
||||||
|
- **Automated Commit Gate**: When `git-air` flags any `[BLOCKER]` issue or outputs a `[REJECT]` verdict, it automatically exits with a non-zero status code, **directly intercepting and blocking `git commit`**!
|
||||||
|
- **Chain Execution of Existing Hooks**: If a `pre-commit` hook already exists, `git-air` automatically backs it up and **chains into your original hook** once review passes, without disrupting existing workflows.
|
||||||
|
- **Strict Mode (`--strict`)**: To block commits even on `[WARNING]` issues, use `git air --strict`;
|
||||||
|
- **Bypass Interception**: If you urgently need to bypass the check, use standard Git `git commit --no-verify` or pass `git air --no-block`.
|
||||||
|
|
||||||
|
To uninstall and fully restore your original hook:
|
||||||
|
```bash
|
||||||
|
git air hook uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Version Check & One-Click Upgrade (`git air update`)
|
||||||
|
|
||||||
|
`git-air` comes with **lightweight 24-hour background update detection** and alerts you in the terminal whenever a new release is available. You can also manually check or upgrade anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current version and check for updates
|
||||||
|
git air version
|
||||||
|
|
||||||
|
# One-click upgrade to the latest release
|
||||||
|
git air update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration Hierarchy
|
||||||
|
|
||||||
|
`git-air` searches for configurations in the following priority order:
|
||||||
|
1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
|
||||||
|
2. **Environment Variables** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`)
|
||||||
|
3. **Project Config** (`./config.yaml` or `./.git-air.yaml` in repo root)
|
||||||
|
4. **Global User Config** (`~/.git-air/config.yaml`)
|
||||||
|
|
||||||
|
### `config.yaml` Example Template:
|
||||||
|
```yaml
|
||||||
|
# Provider: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
|
||||||
|
provider: "gemini"
|
||||||
|
api_key: "YOUR_API_KEY_HERE"
|
||||||
|
model: "gemini-3.7-flash"
|
||||||
|
|
||||||
|
# Custom Base URL (Optional)
|
||||||
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
|
# Custom System Prompt (Optional)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# Legacy compatibility field (supported throughout v1.x)
|
||||||
|
# custom_prompt: ""
|
||||||
|
|
||||||
|
# Custom Token Pricing (Optional, in USD per 1M tokens)
|
||||||
|
# price_input: 0.75
|
||||||
|
# price_output: 3.75
|
||||||
|
|
||||||
|
# AI Commit Message Language (Optional: auto, zh, en, ja, ko)
|
||||||
|
# commit_lang: "auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prompt Field Migration
|
||||||
|
|
||||||
|
Older versions provided a single `custom_prompt` field. Since `git-air` supports both code review and AI commit-message generation, sharing one prompt can lead to mixed instructions and unclear behavior.
|
||||||
|
|
||||||
|
Use separate fields instead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
custom_reviewer_prompt: "Prompt for code review"
|
||||||
|
custom_commit_msg_prompt: "Prompt for commit message generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy `custom_prompt` field remains compatible for now and is treated as `custom_reviewer_prompt`.
|
||||||
|
|
||||||
|
Migration commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git air config set --reviewer-prompt "Your reviewer prompt"
|
||||||
|
git air config set --commit-msg-prompt "Your commit message prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deprecation timeline:
|
||||||
|
|
||||||
|
- `v1.x`: `custom_prompt` remains supported;
|
||||||
|
- `v1.1.0`: the field is marked deprecated and migration is recommended;
|
||||||
|
- `v2.0.0`: `custom_prompt` will be removed.
|
||||||
|
|
||||||
|
This keeps existing configurations working while making the two prompt responsibilities explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Custom Team Rules (`.airules`)
|
||||||
|
|
||||||
|
Create an `.airules` file in your repository root. `git-air` will automatically inject these rules into the model with highest priority:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Team Coding Guidelines
|
||||||
|
1. All SQL operations must reside in the DAO/repository layer. No raw SQL string concatenation;
|
||||||
|
2. Concurrent shared variable access must use sync.RWMutex or sync/atomic;
|
||||||
|
3. Opened resource handles (DB rows, HTTP resp.Body, files) must be immediately closed via defer;
|
||||||
|
4. Never discard errors with blank identifiers (_ = err).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Custom Ignore Rules (`.airignore`)
|
||||||
|
|
||||||
|
In addition to built-in ignored lockfiles and binaries, create an `.airignore` file in your repository root to exclude specific paths from AI review:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# Ignore documentation & design specs
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Ignore test mocks and fixtures
|
||||||
|
tests/mock/
|
||||||
|
testdata/
|
||||||
|
|
||||||
|
# Ignore sensitive config files
|
||||||
|
*.env
|
||||||
|
secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
|
||||||
+356
@@ -0,0 +1,356 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/logo.png" width="180" height="180" alt="git-air logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">git-air 🍃</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Go言語で書かれた、空気のように軽快で高速なGitネイティブAIコードレビューツール。</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go" alt="Go Version" /></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||||
|
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">简体中文</a> |
|
||||||
|
<a href="README_EN.md">English</a> |
|
||||||
|
<a href="README_JA.md"><strong>日本語</strong></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 なぜ git-air を開発したのか?
|
||||||
|
|
||||||
|
### 課題:AIが書いたコードは気持ちいいが、レビューは悪夢
|
||||||
|
|
||||||
|
現代の開発では、AI(Cursor、Copilot、各種LLMプラグイン)を活用することが当たり前になりました。数秒で数十行・数百行のコードが生成されるため、開発スピードは劇的に向上します。
|
||||||
|
|
||||||
|
**しかし、そこには見過ごせない重大な課題が存在します:**
|
||||||
|
- **「見た目が完璧すぎる」という錯覚**:インデントやコメント、命名が綺麗なため、バグがないと盲信しやすい;
|
||||||
|
- **生成AI自身の「自己肯定バイアス」**:同じチャット内で生成AIに「このコードにバグはある?」と聞いても、「問題なく堅牢に動作します!」と返ってくることが多い;
|
||||||
|
- **人間の目で見落としやすい潜在的な罠**:`if err != nil` の早期リターン時にロック解除(Mutex Leak)を忘れたり、Goroutine 内で `recover` がなかったり、ループ内でコネクションが解放されなかったりするバグは、目視では非常に見落としやすい。
|
||||||
|
|
||||||
|
その結果、**「AIがコードを大量生産し、深夜に人間が本番障害の対応に追われる」**という本末転倒な状況が生まれます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 解決策:レビューをローカルターミナル(Pre-Commit)に完全前置
|
||||||
|
|
||||||
|
GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air` はコードを `git commit` する前に、ローカル端末上で動作する完全に独立した「第三者監査役」として設計されています。**
|
||||||
|
|
||||||
|
チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします:
|
||||||
|
|
||||||
|
- ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。
|
||||||
|
- 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。
|
||||||
|
- 🔒 **ローカルでの機密情報自動マスキング**:LLM への送信前に秘密鍵、API Key、Bearer Token、DB接続文字列などを自動マスキング(情報漏洩リスクゼロ)。
|
||||||
|
- 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
|
||||||
|
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
|
||||||
|
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
|
||||||
|
- ✨ **AI コミットメッセージ自動生成**:変更差分から Conventional Commits 準拠の高品質なメッセージを生成(**日本語・英語・韓国語・中国語**に自動適応)。
|
||||||
|
- 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。
|
||||||
|
- 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録(既存フックの自動バックアップ&チェーン実行に対応)。危険なコードのコミットを未然にブロックします。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ ターミナル実行イメージ
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git air
|
||||||
|
|
||||||
|
[git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
#### 変更概要
|
||||||
|
ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。
|
||||||
|
|
||||||
|
#### 詳細レビュー結果
|
||||||
|
- [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。
|
||||||
|
// 推奨修正コード:
|
||||||
|
db.Where("username = ?", inputName).First(&user)
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。
|
||||||
|
|
||||||
|
- [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。
|
||||||
|
|
||||||
|
#### 結論
|
||||||
|
- 判定: [REJECT]
|
||||||
|
- スコア: 60 / 100
|
||||||
|
|
||||||
|
📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 インストール
|
||||||
|
|
||||||
|
### 方法 1: Go Install(推奨)
|
||||||
|
```bash
|
||||||
|
go install github.com/unclesam-ly/git-air@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2: ソースコードからビルド
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/unclesam-ly/git-air.git
|
||||||
|
cd git-air
|
||||||
|
go build -o git-air .
|
||||||
|
sudo mv git-air /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 クイックスタート
|
||||||
|
|
||||||
|
### 1. モデルと API Key の設定
|
||||||
|
|
||||||
|
`git-air` は国内外の主要LLMのエンドポイントと推奨モデルを標準プリセットしています。`--provider` を指定するだけで簡単に切り替えられます:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Google Gemini (推奨: 高速・低コスト)
|
||||||
|
git air config set --provider gemini --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet)
|
||||||
|
git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet
|
||||||
|
|
||||||
|
# 3. xAI Grok (Grok-2 / Grok-3)
|
||||||
|
git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest
|
||||||
|
|
||||||
|
# 4. DeepSeek (高度な推論・思考モデル)
|
||||||
|
git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat
|
||||||
|
|
||||||
|
# 5. SiliconFlow (DeepSeek-V3 高速ホスティング)
|
||||||
|
git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
|
||||||
|
|
||||||
|
# 6. 通義千問 (Qwen / Alibaba Cloud)
|
||||||
|
git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus
|
||||||
|
|
||||||
|
# 7. 智譜 AI (GLM-4)
|
||||||
|
git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus
|
||||||
|
|
||||||
|
# 8. Moonshot AI (Kimi)
|
||||||
|
git air config set --provider moonshot --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 9. Groq (超高速推論)
|
||||||
|
git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile
|
||||||
|
|
||||||
|
# 10. OpenRouter (マルチモデル統合ゲートウェイ)
|
||||||
|
git air config set --provider openrouter --key "YOUR_KEY"
|
||||||
|
|
||||||
|
# 11. ローカル完全オフライン・プライバシー重視 (Ollama)
|
||||||
|
git air config set --provider ollama --model qwen2.5-coder
|
||||||
|
|
||||||
|
# 12. OpenAI 公式 (GPT-4o / o3-mini)
|
||||||
|
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
|
||||||
|
|
||||||
|
# 13. カスタム Token 料金設定 (任意:組み込み料金表を上書き、単位: 米ドル/1M Tokens)
|
||||||
|
git air config set --price-input 0.75 --price-output 3.75
|
||||||
|
|
||||||
|
# 14. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko)
|
||||||
|
git air config set --commit-lang ja
|
||||||
|
|
||||||
|
# 15. コードレビュー専用のカスタムプロンプトを設定 (任意)
|
||||||
|
git air config set --reviewer-prompt "並行処理とメモリ安全性を最重要視するGoシニアアーキテクトとしてレビューしてください"
|
||||||
|
|
||||||
|
# 16. コミットメッセージ専用のカスタムプロンプトを設定 (任意)
|
||||||
|
git air config set --commit-msg-prompt "AngularのConventional Commits規範に厳格に従って生成してください"
|
||||||
|
```
|
||||||
|
|
||||||
|
現在の設定を確認(カスタム料金やマスク化Keyを含む):
|
||||||
|
```bash
|
||||||
|
git air config get
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. よく使うレビューコマンド
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 現在ステージングされた(git add済み)変更をレビュー(デフォルト)
|
||||||
|
git air
|
||||||
|
|
||||||
|
# 直前のコミット内容をレビュー
|
||||||
|
git air HEAD~1
|
||||||
|
|
||||||
|
# ブランチ間の差分をレビュー
|
||||||
|
git air main..feature-agent
|
||||||
|
|
||||||
|
# 特定のファイルのみをレビュー
|
||||||
|
git air internal/service/chat.go
|
||||||
|
|
||||||
|
# 一時的にモデルやProviderを指定して実行
|
||||||
|
git air --provider deepseek --model deepseek-chat
|
||||||
|
|
||||||
|
# 構造化 JSON レポートを出力(CI/CD パイプラインや自動化スクリプト向け)
|
||||||
|
git air --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. AI コミットメッセージ自動生成 (`git air msg`)
|
||||||
|
|
||||||
|
コミットメッセージの作成に悩む必要はもうありません。`git-air` がコードの変更差分を分析し、Conventional Commits 準拠のメッセージを**多言語自動適応**と**自動ステージング**で生成します:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 基本的な使い方:差分を分析して対話型コミット
|
||||||
|
git air msg
|
||||||
|
|
||||||
|
# 2. 変更ファイルを自動ステージング(git add 不要)
|
||||||
|
git air msg -a
|
||||||
|
|
||||||
|
# 3. 単発で出力言語を指定(auto, zh, en, ja, ko)
|
||||||
|
git air msg -l en # 英語(オープンソース・国際プロジェクト向け)
|
||||||
|
git air msg -l ja # 日本語
|
||||||
|
git air msg -l ko # 韓国語
|
||||||
|
|
||||||
|
# 4. 最速ワンライナー:自動ステージング + 生成 + 即座にコミット
|
||||||
|
git air msg -a -c
|
||||||
|
|
||||||
|
# 5. グローバルデフォルト言語を永続設定(毎回 -l を付ける必要がなくなります)
|
||||||
|
git air config set --commit-lang ja # 今後常に日本語で生成
|
||||||
|
git air config set --commit-lang en # 今後常に英語で生成
|
||||||
|
git air config set --commit-lang auto # システム言語への自動追従に戻す
|
||||||
|
```
|
||||||
|
|
||||||
|
**実行イメージ:**
|
||||||
|
```text
|
||||||
|
$ git air msg -l ja
|
||||||
|
[git-air] Conventional Commit Message を生成中... (Language: 日本語 / Engine: gemini-3.7-flash)
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
feat(auth): Redisキャッシュを追加し、Mutexのデッドロックを修正
|
||||||
|
|
||||||
|
- ユーザー認証ハンドラーに Redis トークンキャッシュ層を導入
|
||||||
|
- 早期リターン時に Mutex の Unlock が漏れる並行処理のバグを修正
|
||||||
|
─────────────────────────────────────────────────────────────────
|
||||||
|
? このメッセージで直接 git commit を実行しますか?[Y/n]: y
|
||||||
|
[SUCCESS] コードのコミットに成功しました!🎉
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. ワンタッチで Pre-commit フックを登録
|
||||||
|
|
||||||
|
Git リポジトリ直下で以下を実行:
|
||||||
|
```bash
|
||||||
|
git air hook install
|
||||||
|
```
|
||||||
|
- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します!
|
||||||
|
- **既存フックのチェーン実行**:リポジトリに既存の `pre-commit` がある場合、自動的にバックアップされ、レビュー通過後に**元のフックをチェーン実行**します。
|
||||||
|
- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用;
|
||||||
|
- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。
|
||||||
|
|
||||||
|
フックを解除し、元のフックを完全復元する場合:
|
||||||
|
```bash
|
||||||
|
git air hook uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. バージョン確認とワンクリック自動更新 (`git air update`)
|
||||||
|
|
||||||
|
`git-air` には**軽量な24時間サイレント更新検出機能**が組み込まれており、新しいリリースが公開されるとターミナル下部で自動通知されます。手動での確認やアップデートも可能です:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 現在のバージョンを確認し、更新をチェック
|
||||||
|
git air version
|
||||||
|
|
||||||
|
# 最新バージョンへ一括アップデート
|
||||||
|
git air update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 設定の優先順位
|
||||||
|
|
||||||
|
1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--format`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
|
||||||
|
2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`)
|
||||||
|
3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`)
|
||||||
|
4. **グローバル設定** (`~/.git-air/config.yaml`)
|
||||||
|
|
||||||
|
### `config.yaml` 設定ファイル例:
|
||||||
|
```yaml
|
||||||
|
# プロバイダー: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
|
||||||
|
provider: "gemini"
|
||||||
|
api_key: "YOUR_API_KEY_HERE"
|
||||||
|
model: "gemini-3.7-flash"
|
||||||
|
|
||||||
|
# カスタム API エンドポイント (任意)
|
||||||
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
|
# カスタムプロンプト設定 (任意)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# 旧版互換フィールド (v1.xで利用可能)
|
||||||
|
# custom_prompt: ""
|
||||||
|
|
||||||
|
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
|
||||||
|
# price_input: 0.75
|
||||||
|
# price_output: 3.75
|
||||||
|
|
||||||
|
# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko)
|
||||||
|
# commit_lang: "auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### プロンプト設定フィールドの移行について
|
||||||
|
|
||||||
|
旧バージョンでは単一の `custom_prompt` フィールドのみを提供していましたが、`git-air` がコードレビューと AI コミットメッセージ生成の両方に対応したため、2 つのシナリオでプロンプトの目的が異なります。
|
||||||
|
|
||||||
|
そのため、現在のバージョン以降はそれぞれ個別のフィールドを使用することを推奨します:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
custom_reviewer_prompt: "コードレビュー用のカスタムプロンプト"
|
||||||
|
custom_commit_msg_prompt: "コミットメッセージ生成用のカスタムプロンプト"
|
||||||
|
```
|
||||||
|
|
||||||
|
なお、旧版の `custom_prompt` は引き続き互換性が保たれ、`custom_reviewer_prompt` として自動的に扱われます。CLI からの設定方法:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git air config set --reviewer-prompt "あなたの Reviewer Prompt"
|
||||||
|
git air config set --commit-msg-prompt "あなたの Commit Message Prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
ライフサイクル方針:
|
||||||
|
- `v1.x`:`custom_prompt` を継続サポート;
|
||||||
|
- `v1.1.0`:非推奨(deprecated)として警告を表示;
|
||||||
|
- `v2.0.0`:`custom_prompt` フィールドを完全削除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 チーム規約のカスタマイズ(`.airules`)
|
||||||
|
|
||||||
|
リポジトリのルートディレクトリに `.airules` ファイルを作成すると、AI はそのルールを最優先事項として適用します:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# チーム共通開発ルール
|
||||||
|
1. すべての SQL 処理は DAO 層に集約し、文字列結合による生 SQL は禁止とする;
|
||||||
|
2. 並行処理で共有変数を扱う場合は、sync.RWMutex または sync/atomic を使用すること;
|
||||||
|
3. オープンしたリソース(DB rows、HTTP レスポンス、ファイル)は defer Close() で確実に解放すること;
|
||||||
|
4. error をアンダースコア(_ = err)で無条件に無視することを禁止する。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ カスタム除外設定(`.airignore`)
|
||||||
|
|
||||||
|
組み込みの自動除外(ロックファイルやバイナリ)に加え、リポジトリ直下に `.airignore` を配置することで、任意のファイルやディレクトリをAIレビューから除外できます:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# ドキュメントや設計書を除外
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# テストモックやダミーデータを除外
|
||||||
|
tests/mock/
|
||||||
|
testdata/
|
||||||
|
|
||||||
|
# 機密設定ファイルを除外
|
||||||
|
*.env
|
||||||
|
secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 ライセンス
|
||||||
|
|
||||||
|
本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です!
|
||||||
+74
-22
@@ -2,11 +2,11 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git-air/internal/ui"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,13 @@ type AppConfig struct {
|
|||||||
APIKey string `yaml:"api_key"` // API Key
|
APIKey string `yaml:"api_key"` // API Key
|
||||||
BaseURL string `yaml:"base_url"` // 自定义请求地址
|
BaseURL string `yaml:"base_url"` // 自定义请求地址
|
||||||
Model string `yaml:"model"` // 模型名称
|
Model string `yaml:"model"` // 模型名称
|
||||||
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
|
// CustomPrompt 为兼容旧配置保留,读取时会回退到 CustomReviewerPrompt。
|
||||||
|
CustomPrompt string `yaml:"custom_prompt,omitempty"`
|
||||||
|
CustomReviewerPrompt string `yaml:"custom_reviewer_prompt,omitempty"`
|
||||||
|
CustomCommitMsgPrompt string `yaml:"custom_commit_msg_prompt,omitempty"`
|
||||||
|
PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选)
|
||||||
|
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
|
||||||
|
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConfigPath() (string, error) {
|
func getConfigPath() (string, error) {
|
||||||
@@ -31,37 +37,37 @@ func getConfigPath() (string, error) {
|
|||||||
return filepath.Join(dir, "config.yaml"), nil
|
return filepath.Join(dir, "config.yaml"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig 读取全局配置(优先使用环境变量,其次读取 yaml 文件)
|
// LoadConfig 智能读取配置:
|
||||||
|
// 1. 先读取用户全局配置 ~/.git-air/config.yaml (保证 APIKey 等全局生效)
|
||||||
|
// 2. 若当前项目存在 .git-air.yaml 或 git-air.yaml,则进行项目级增量覆盖
|
||||||
|
// 3. 环境变量最高优先级覆盖
|
||||||
func LoadConfig() (*AppConfig, error) {
|
func LoadConfig() (*AppConfig, error) {
|
||||||
cfg := &AppConfig{
|
cfg := &AppConfig{
|
||||||
Provider: "gemini",
|
Provider: "gemini",
|
||||||
Model: "gemini-3.7-flash",
|
Model: "gemini-3.7-flash",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 优先探测当前项目根目录下的配置文件
|
// 1. 首先加载全局配置 ~/.git-air/config.yaml
|
||||||
localCandidates := []string{"config.yaml", ".git-air.yaml", ".git-air.yml", "configs/config.yaml"}
|
configPath, err := getConfigPath()
|
||||||
loaded := false
|
if err != nil {
|
||||||
|
// 隐式跳过时,可考虑记录日志或在 verbose 模式下输出
|
||||||
|
} else if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析全局配置文件 [%s] 失败: %w", configPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查当前项目是否有专属于 git-air 的配置文件 (.git-air.yaml / git-air.yaml) 进行增量覆盖
|
||||||
|
localCandidates := []string{".git-air.yaml", ".git-air.yml", "git-air.yaml"}
|
||||||
for _, name := range localCandidates {
|
for _, name := range localCandidates {
|
||||||
if data, err := os.ReadFile(name); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(name); err == nil && len(data) > 0 {
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
return nil, fmt.Errorf("解析项目配置文件 [%s] 失败: %w", name, err)
|
||||||
}
|
}
|
||||||
loaded = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 如果项目根目录下没有,则回退读取用户全局配置 ~/.git-air/config.yaml
|
|
||||||
if !loaded {
|
|
||||||
configPath, err := getConfigPath()
|
|
||||||
if err == nil {
|
|
||||||
if data, err := os.ReadFile(configPath); err == nil {
|
|
||||||
_ = yaml.Unmarshal(data, cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 环境变量最高优先级覆盖
|
// 3. 环境变量最高优先级覆盖
|
||||||
if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" {
|
if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" {
|
||||||
cfg.APIKey = envKey
|
cfg.APIKey = envKey
|
||||||
@@ -75,6 +81,14 @@ func LoadConfig() (*AppConfig, error) {
|
|||||||
if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" {
|
if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" {
|
||||||
cfg.BaseURL = envBaseURL
|
cfg.BaseURL = envBaseURL
|
||||||
}
|
}
|
||||||
|
if envLang := os.Getenv("GIT_AIR_COMMIT_LANG"); envLang != "" {
|
||||||
|
cfg.CommitLang = envLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧版本:custom_prompt 等价于 reviewer 提示词。
|
||||||
|
if cfg.CustomReviewerPrompt == "" {
|
||||||
|
cfg.CustomReviewerPrompt = cfg.CustomPrompt
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -102,6 +116,7 @@ var configSetCmd = &cobra.Command{
|
|||||||
Short: "设置全局配置项",
|
Short: "设置全局配置项",
|
||||||
Example: ` git air config set --provider gemini --key YOUR_KEY
|
Example: ` git air config set --provider gemini --key YOUR_KEY
|
||||||
git air config set --provider deepseek --key YOUR_KEY --model deepseek-chat
|
git air config set --provider deepseek --key YOUR_KEY --model deepseek-chat
|
||||||
|
git air config set --commit-lang ja
|
||||||
git air config set --provider ollama --model qwen2.5-coder`,
|
git air config set --provider ollama --model qwen2.5-coder`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cfg, err := LoadConfig()
|
cfg, err := LoadConfig()
|
||||||
@@ -124,7 +139,24 @@ var configSetCmd = &cobra.Command{
|
|||||||
cfg.BaseURL = u
|
cfg.BaseURL = u
|
||||||
}
|
}
|
||||||
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
|
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
|
||||||
|
// 旧版 --prompt 继续写入旧字段,同时同步新字段,保证 v1.x 行为兼容。
|
||||||
cfg.CustomPrompt = pr
|
cfg.CustomPrompt = pr
|
||||||
|
cfg.CustomReviewerPrompt = pr
|
||||||
|
}
|
||||||
|
if pr, _ := cmd.Flags().GetString("reviewer-prompt"); pr != "" {
|
||||||
|
cfg.CustomReviewerPrompt = pr
|
||||||
|
}
|
||||||
|
if pr, _ := cmd.Flags().GetString("commit-msg-prompt"); pr != "" {
|
||||||
|
cfg.CustomCommitMsgPrompt = pr
|
||||||
|
}
|
||||||
|
if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 {
|
||||||
|
cfg.PriceInput = pi
|
||||||
|
}
|
||||||
|
if po, _ := cmd.Flags().GetFloat64("price-output"); po > 0 {
|
||||||
|
cfg.PriceOutput = po
|
||||||
|
}
|
||||||
|
if cl, _ := cmd.Flags().GetString("commit-lang"); cl != "" {
|
||||||
|
cfg.CommitLang = cl
|
||||||
}
|
}
|
||||||
if err := SaveConfig(cfg); err != nil {
|
if err := SaveConfig(cfg); err != nil {
|
||||||
ui.PrintError("保存配置失败: %v", err)
|
ui.PrintError("保存配置失败: %v", err)
|
||||||
@@ -155,11 +187,26 @@ var configGetCmd = &cobra.Command{
|
|||||||
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
|
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
|
||||||
}
|
}
|
||||||
fmt.Printf("APIKey: %s\n", maskedKey)
|
fmt.Printf("APIKey: %s\n", maskedKey)
|
||||||
hasPrompt := "否 (使用默认内置架构师 Prompt)"
|
reviewerPrompt := "否 (使用默认内置架构师 Prompt)"
|
||||||
if cfg.CustomPrompt != "" {
|
if cfg.CustomReviewerPrompt != "" {
|
||||||
hasPrompt = "是 (已自定义)"
|
reviewerPrompt = "是 (已自定义)"
|
||||||
|
}
|
||||||
|
commitPrompt := "否 (使用默认 Commit Message Prompt)"
|
||||||
|
if cfg.CustomCommitMsgPrompt != "" {
|
||||||
|
commitPrompt = "是 (已自定义)"
|
||||||
|
}
|
||||||
|
fmt.Printf("ReviewerPrompt: %s\n", reviewerPrompt)
|
||||||
|
fmt.Printf("CommitMsgPrompt: %s\n", commitPrompt)
|
||||||
|
commitLang := "auto (自动识别系统语言)"
|
||||||
|
if cfg.CommitLang != "" {
|
||||||
|
commitLang = cfg.CommitLang
|
||||||
|
}
|
||||||
|
fmt.Printf("CommitLang: %s\n", commitLang)
|
||||||
|
if cfg.PriceInput > 0 || cfg.PriceOutput > 0 {
|
||||||
|
fmt.Printf("CustomPrice: 输入 $%.4f/1M, 输出 $%.4f/1M (已自定义覆盖)\n", cfg.PriceInput, cfg.PriceOutput)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("CustomPrice: 默认 (跟随官方最新价格表)\n")
|
||||||
}
|
}
|
||||||
fmt.Printf("CustomPrompt: %s\n", hasPrompt)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +216,11 @@ func init() {
|
|||||||
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
|
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
|
||||||
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
|
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
|
||||||
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
|
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
|
||||||
|
configSetCmd.Flags().String("reviewer-prompt", "", "自定义代码 Reviewer Prompt")
|
||||||
|
configSetCmd.Flags().String("commit-msg-prompt", "", "自定义 Commit Message Prompt")
|
||||||
|
configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格")
|
||||||
|
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
|
||||||
|
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
|
||||||
|
|
||||||
configCmd.AddCommand(configSetCmd)
|
configCmd.AddCommand(configSetCmd)
|
||||||
configCmd.AddCommand(configGetCmd)
|
configCmd.AddCommand(configGetCmd)
|
||||||
|
|||||||
+145
-15
@@ -1,20 +1,142 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git-air/internal/git"
|
"errors"
|
||||||
"git-air/internal/ui"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/git"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const preCommitScript = `#!/bin/sh
|
const (
|
||||||
# git-air pre-commit hook
|
preCommitMarker = "# git-air-managed-pre-commit-v1"
|
||||||
|
preCommitBackupSuffix = ".git-air-backup"
|
||||||
|
preCommitBackupFileName = "pre-commit.git-air-backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func preCommitScript(bin ...string) string {
|
||||||
|
binPath := "git-air"
|
||||||
|
if len(bin) > 0 && bin[0] != "" {
|
||||||
|
binPath = bin[0]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`#!/bin/sh
|
||||||
|
%s
|
||||||
echo "[git-air] 正在执行 Commit 前代码自动化评审..."
|
echo "[git-air] 正在执行 Commit 前代码自动化评审..."
|
||||||
git-air --cached
|
"%s" --staged
|
||||||
# 如果评审发现阻断性问题,可通过退出码阻断 commit
|
git_air_status=$?
|
||||||
`
|
if [ "$git_air_status" -ne 0 ]; then
|
||||||
|
exit "$git_air_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
old_hook="$hook_dir/%s"
|
||||||
|
if [ -x "$old_hook" ]; then
|
||||||
|
exec "$old_hook" "$@"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`, preCommitMarker, binPath, preCommitBackupFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGitAirHook(data []byte) bool {
|
||||||
|
content := string(data)
|
||||||
|
return strings.Contains(content, preCommitMarker) ||
|
||||||
|
strings.Contains(content, "# git-air pre-commit hook") ||
|
||||||
|
strings.Contains(content, "git-air --staged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPreCommitHook(hookPath string) error {
|
||||||
|
backupPath := hookPath + preCommitBackupSuffix
|
||||||
|
hookData, hookReadErr := os.ReadFile(hookPath)
|
||||||
|
hookExists := hookReadErr == nil
|
||||||
|
if hookReadErr != nil && !errors.Is(hookReadErr, os.ErrNotExist) {
|
||||||
|
return hookReadErr
|
||||||
|
}
|
||||||
|
// 幂等安装:已经是 git-air 包装 Hook 时直接返回,绝不把自己再次备份。
|
||||||
|
if hookExists && isGitAirHook(hookData) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(backupPath); err == nil {
|
||||||
|
return fmt.Errorf("检测到已有 git-air 备份文件 %s,请先执行 hook uninstall", backupPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hookExists {
|
||||||
|
// 保留原 Hook:先移动为备份,再安装包装 Hook。写入失败时尝试恢复。
|
||||||
|
if err := os.Rename(hookPath, backupPath); err != nil {
|
||||||
|
return fmt.Errorf("备份已有 pre-commit Hook 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath := "git-air"
|
||||||
|
if exe, err := os.Executable(); err == nil && exe != "" {
|
||||||
|
binPath = exe
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(hookPath, []byte(preCommitScript(binPath)), 0755); err != nil {
|
||||||
|
if hookExists {
|
||||||
|
_ = os.Remove(hookPath)
|
||||||
|
_ = os.Rename(backupPath, hookPath)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("写入 git-air pre-commit Hook 失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstallPreCommitHook(hookPath string) error {
|
||||||
|
data, err := os.ReadFile(hookPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("当前仓库没有 git-air pre-commit Hook")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isGitAirHook(data) {
|
||||||
|
return fmt.Errorf("当前 pre-commit Hook 不是由 git-air 管理,已保留原文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
backupPath := hookPath + preCommitBackupSuffix
|
||||||
|
if _, err := os.Stat(backupPath); err == nil {
|
||||||
|
return restoreOriginalHook(hookPath, backupPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(hookPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreOriginalHook 恢复备份 Hook。
|
||||||
|
// POSIX 系统优先使用 Rename 的原子替换;Windows 目标文件存在时 Rename 可能失败,
|
||||||
|
// 此时先把包装 Hook 移到临时路径,恢复失败则尝试回滚,避免先删除导致数据丢失。
|
||||||
|
func restoreOriginalHook(hookPath, backupPath string) error {
|
||||||
|
if err := os.Rename(backupPath, hookPath); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tempPath := hookPath + ".git-air-restore-temp"
|
||||||
|
if _, err := os.Stat(tempPath); err == nil {
|
||||||
|
return fmt.Errorf("恢复原 pre-commit Hook 失败:临时文件已存在 %s", tempPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(hookPath, tempPath); err != nil {
|
||||||
|
return fmt.Errorf("暂存 git-air Hook 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(backupPath, hookPath); err != nil {
|
||||||
|
_ = os.Rename(tempPath, hookPath)
|
||||||
|
return fmt.Errorf("恢复原 pre-commit Hook 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(tempPath); err != nil {
|
||||||
|
return fmt.Errorf("清理临时 Hook 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var hookCmd = &cobra.Command{
|
var hookCmd = &cobra.Command{
|
||||||
Use: "hook",
|
Use: "hook",
|
||||||
@@ -23,25 +145,30 @@ var hookCmd = &cobra.Command{
|
|||||||
|
|
||||||
var hookInstallCmd = &cobra.Command{
|
var hookInstallCmd = &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "在当前仓库一键安装 pre-commit 钩子",
|
Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("请在 Git 仓库根目录下执行此命令")
|
ui.PrintError("请在 Git 仓库根目录下执行此命令")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
hooksDir := filepath.Join(repoRoot, ".git", "hooks")
|
||||||
if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil {
|
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
||||||
ui.PrintError("写入 hook 文件失败: %v", err)
|
ui.PrintError("创建 hooks 目录失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。")
|
hookPath := filepath.Join(hooksDir, "pre-commit")
|
||||||
|
if err := installPreCommitHook(hookPath); err != nil {
|
||||||
|
ui.PrintError("安装 Hook 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.PrintSuccess("已安装 git-air Hook;已有 Hook 已保存在 pre-commit.git-air-backup,提交通过后会继续执行。")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var hookUninstallCmd = &cobra.Command{
|
var hookUninstallCmd = &cobra.Command{
|
||||||
Use: "uninstall",
|
Use: "uninstall",
|
||||||
Short: "移除当前仓库的 pre-commit 钩子",
|
Short: "移除 git-air Hook 并恢复已有 Hook",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,8 +176,11 @@ var hookUninstallCmd = &cobra.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
||||||
_ = os.Remove(hookPath)
|
if err := uninstallPreCommitHook(hookPath); err != nil {
|
||||||
ui.PrintSuccess("已成功卸载 pre-commit 钩子!")
|
ui.PrintError("卸载 Hook 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.PrintSuccess("已卸载 git-air Hook;如原来存在 Hook,已恢复原文件。")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstallPreCommitHookIsIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hookPath := filepath.Join(dir, "pre-commit")
|
||||||
|
original := "#!/bin/sh\necho original\n"
|
||||||
|
if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := installPreCommitHook(hookPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backupPath := hookPath + preCommitBackupSuffix
|
||||||
|
backup, err := os.ReadFile(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(backup) != original {
|
||||||
|
t.Fatalf("原 Hook 备份内容被改变: %q", backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := os.ReadFile(hookPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(first), preCommitMarker) {
|
||||||
|
t.Fatal("安装后的 Hook 缺少 git-air 标记")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := installPreCommitHook(hookPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := os.ReadFile(hookPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(second) != string(first) {
|
||||||
|
t.Fatal("重复安装不应改写 git-air Hook")
|
||||||
|
}
|
||||||
|
backupAfter, err := os.ReadFile(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(backupAfter) != original {
|
||||||
|
t.Fatal("重复安装不应改变原 Hook 备份")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreCommitScriptRunsOnlyExecutableBackup(t *testing.T) {
|
||||||
|
script := preCommitScript()
|
||||||
|
if !strings.Contains(script, "if [ -x \"$old_hook\" ]; then") {
|
||||||
|
t.Fatalf("Hook 没有检查备份文件的可执行权限:\n%s", script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstallPreCommitHookRestoresOriginal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hookPath := filepath.Join(dir, "pre-commit")
|
||||||
|
original := "#!/bin/sh\necho original\n"
|
||||||
|
if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := installPreCommitHook(hookPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := uninstallPreCommitHook(hookPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
restored, err := os.ReadFile(hookPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(restored) != original {
|
||||||
|
t.Fatalf("原 Hook 未正确恢复: %q", restored)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(hookPath + preCommitBackupSuffix); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("备份文件未清理: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/git"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/llm"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/reviewer"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var msgCmd = &cobra.Command{
|
||||||
|
Use: "msg [git diff 参数,如 HEAD~1, main..feature, 文件名等]",
|
||||||
|
Aliases: []string{"commit-msg", "commit"},
|
||||||
|
Short: "根据代码变更智能生成规范的 Conventional Commits 提交信息",
|
||||||
|
Example: ` git air msg # 根据当前暂存区代码自动生成
|
||||||
|
git air msg -l ja # 指定生成日语提交说明
|
||||||
|
git air msg -l en # 指定生成英文提交说明
|
||||||
|
git air msg -c # 生成后直接执行 git commit`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
|
||||||
|
// 1. 检查并获取 Diff (默认优先获取暂存区)
|
||||||
|
diff, err := git.GetDiff(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
ui.PrintError("%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 加载配置
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
ui.PrintError("加载配置失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagKey, _ := cmd.Flags().GetString("key"); flagKey != "" {
|
||||||
|
cfg.APIKey = flagKey
|
||||||
|
}
|
||||||
|
if flagModel, _ := cmd.Flags().GetString("model"); flagModel != "" {
|
||||||
|
cfg.Model = flagModel
|
||||||
|
}
|
||||||
|
if flagProvider, _ := cmd.Flags().GetString("provider"); flagProvider != "" {
|
||||||
|
cfg.Provider = flagProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 初始化大模型客户端
|
||||||
|
llmClient, err := llm.NewClient(llm.Config{
|
||||||
|
Provider: cfg.Provider,
|
||||||
|
APIKey: cfg.APIKey,
|
||||||
|
BaseURL: cfg.BaseURL,
|
||||||
|
Model: cfg.Model,
|
||||||
|
PriceInput: cfg.PriceInput,
|
||||||
|
PriceOutput: cfg.PriceOutput,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ui.PrintError("初始化模型失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 确定目标语言 (命令行 Flag > 配置文件 > 自动探测系统语言)
|
||||||
|
lang, _ := cmd.Flags().GetString("lang")
|
||||||
|
if lang == "" {
|
||||||
|
lang = cfg.CommitLang
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, _ := git.GetRepoRoot(ctx)
|
||||||
|
rev := reviewer.NewReviewer(llmClient, repoRoot, "")
|
||||||
|
|
||||||
|
targetLang := reviewer.DetectLanguage(lang)
|
||||||
|
langDesc := "English"
|
||||||
|
switch targetLang {
|
||||||
|
case "zh":
|
||||||
|
langDesc = "简体中文"
|
||||||
|
case "ja":
|
||||||
|
langDesc = "日本語"
|
||||||
|
case "ko":
|
||||||
|
langDesc = "한국어"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\033[1;36m[git-air]\033[0m 正在生成 Conventional Commit Message... \033[0;90m(Language: %s / Engine: %s)\033[0m\n", langDesc, cfg.Model)
|
||||||
|
fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m")
|
||||||
|
|
||||||
|
commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang, cfg.CustomCommitMsgPrompt)
|
||||||
|
if err != nil {
|
||||||
|
ui.PrintError("生成 Commit Message 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 打印生成的提交信息
|
||||||
|
fmt.Printf("\033[1;32m%s\033[0m\n", commitMsg)
|
||||||
|
fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m")
|
||||||
|
|
||||||
|
// 6. 是否自动暂存与提交
|
||||||
|
flagAll, _ := cmd.Flags().GetBool("all")
|
||||||
|
flagCommit, _ := cmd.Flags().GetBool("commit")
|
||||||
|
|
||||||
|
if flagCommit {
|
||||||
|
if flagAll {
|
||||||
|
if err := git.StageAll(ctx); err != nil {
|
||||||
|
ui.PrintError("自动暂存失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
executeGitCommit(ctx, commitMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查暂存区状态
|
||||||
|
hasStaged, _ := git.HasStagedChanges(ctx)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
if !hasStaged && !flagAll {
|
||||||
|
fmt.Print("? 检测到改动尚未暂存 (unstaged),是否自动暂存并提交 (git add -A)?[Y/n]: ")
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
|
input = strings.TrimSpace(strings.ToLower(input))
|
||||||
|
|
||||||
|
if input == "" || input == "y" || input == "yes" {
|
||||||
|
if err := git.StageAll(ctx); err != nil {
|
||||||
|
ui.PrintError("自动暂存失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
executeGitCommit(ctx, commitMsg)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\033[0;90m已跳过提交。请执行 'git add <文件>' 暂存修改后再提交。\033[0m")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交互式询问是否直接提交
|
||||||
|
fmt.Print("? 是否直接以此信息执行 git commit?[Y/n]: ")
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
|
input = strings.TrimSpace(strings.ToLower(input))
|
||||||
|
|
||||||
|
if input == "" || input == "y" || input == "yes" {
|
||||||
|
executeGitCommit(ctx, commitMsg)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\033[0;90m已跳过提交。你可以复制上方内容手动提交。\033[0m")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeGitCommit(ctx context.Context, msg string) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "commit", "-m", msg)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
ui.PrintError("执行 git commit 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.PrintSuccess("代码提交成功!🎉")
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
msgCmd.Flags().StringP("lang", "l", "", "生成语言 (auto, zh, en, ja, ko)")
|
||||||
|
msgCmd.Flags().BoolP("all", "a", false, "自动暂存所有已修改的文件 (git add -A)")
|
||||||
|
msgCmd.Flags().BoolP("commit", "c", false, "生成后无需确认直接执行 git commit")
|
||||||
|
msgCmd.Flags().StringP("key", "k", "", "临时指定 API Key")
|
||||||
|
msgCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
|
||||||
|
msgCmd.Flags().StringP("provider", "p", "", "临时指定 Provider")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(msgCmd)
|
||||||
|
}
|
||||||
+105
-7
@@ -2,13 +2,16 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"git-air/internal/git"
|
"encoding/json"
|
||||||
"git-air/internal/llm"
|
|
||||||
"git-air/internal/reviewer"
|
|
||||||
"git-air/internal/ui"
|
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/git"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/llm"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/reviewer"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
@@ -16,10 +19,25 @@ var rootCmd = &cobra.Command{
|
|||||||
Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具",
|
Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具",
|
||||||
Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。
|
Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。
|
||||||
支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`,
|
支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
|
outputFormat, _ := cmd.Flags().GetString("format")
|
||||||
|
if outputFormat != "text" && outputFormat != "json" {
|
||||||
|
ui.PrintError("不支持的输出格式 %q,可选: text, json", outputFormat)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台非阻塞异步检查新版本
|
||||||
|
updateCh := updater.CheckAsync()
|
||||||
|
|
||||||
// 1. 检查并获取 Diff
|
// 1. 检查并获取 Diff
|
||||||
|
if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached {
|
||||||
|
args = append([]string{"--cached"}, args...)
|
||||||
|
} else if flagStaged, _ := cmd.Flags().GetBool("staged"); flagStaged {
|
||||||
|
args = append([]string{"--cached"}, args...)
|
||||||
|
}
|
||||||
|
|
||||||
diff, err := git.GetDiff(ctx, args...)
|
diff, err := git.GetDiff(ctx, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("%v", err)
|
ui.PrintError("%v", err)
|
||||||
@@ -50,6 +68,8 @@ var rootCmd = &cobra.Command{
|
|||||||
APIKey: cfg.APIKey,
|
APIKey: cfg.APIKey,
|
||||||
BaseURL: cfg.BaseURL,
|
BaseURL: cfg.BaseURL,
|
||||||
Model: cfg.Model,
|
Model: cfg.Model,
|
||||||
|
PriceInput: cfg.PriceInput,
|
||||||
|
PriceOutput: cfg.PriceOutput,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
|
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
|
||||||
@@ -62,22 +82,95 @@ var rootCmd = &cobra.Command{
|
|||||||
// 5. 初始化评审引擎
|
// 5. 初始化评审引擎
|
||||||
customPrompt, _ := cmd.Flags().GetString("prompt")
|
customPrompt, _ := cmd.Flags().GetString("prompt")
|
||||||
if customPrompt == "" {
|
if customPrompt == "" {
|
||||||
customPrompt = cfg.CustomPrompt
|
customPrompt = cfg.CustomReviewerPrompt
|
||||||
}
|
}
|
||||||
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
|
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
|
||||||
|
|
||||||
// 6. 打印开场横幅并开始流式评审
|
// 6. 打印开场横幅并开始流式评审
|
||||||
|
if outputFormat == "text" {
|
||||||
ui.PrintBanner(cfg.Provider, cfg.Model)
|
ui.PrintBanner(cfg.Provider, cfg.Model)
|
||||||
|
}
|
||||||
printer := ui.NewStreamPrinter()
|
printer := ui.NewStreamPrinter()
|
||||||
_, err = rev.Execute(ctx, diff, true, func(chunk string) {
|
var reviewOutput strings.Builder
|
||||||
|
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
|
||||||
|
reviewOutput.WriteString(chunk)
|
||||||
|
if outputFormat == "text" {
|
||||||
printer.PrintChunk(chunk)
|
printer.PrintChunk(chunk)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("\n评审过程中发生错误: %v", err)
|
ui.PrintError("\n评审过程中发生错误: %v", err)
|
||||||
return
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. 打印 Token 消耗统计
|
||||||
|
if outputFormat == "text" && usage != nil {
|
||||||
|
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputFormat == "text" {
|
||||||
ui.PrintFooter()
|
ui.PrintFooter()
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
report := reviewer.ParseReviewReport(reviewOutput.String())
|
||||||
|
var reportUsage interface{}
|
||||||
|
if usage != nil {
|
||||||
|
reportUsage = struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
CostUSD float64 `json:"cost_usd"`
|
||||||
|
}{
|
||||||
|
InputTokens: usage.InputTokens,
|
||||||
|
OutputTokens: usage.OutputTokens,
|
||||||
|
Model: usage.Model,
|
||||||
|
Provider: cfg.Provider,
|
||||||
|
CostUSD: usage.EstimateCost(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalReport := struct {
|
||||||
|
ReviewReport reviewer.ReviewReport `json:"review"`
|
||||||
|
Usage interface{} `json:"usage"`
|
||||||
|
}{
|
||||||
|
ReviewReport: report,
|
||||||
|
Usage: reportUsage,
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(os.Stdout).Encode(finalReport); err != nil {
|
||||||
|
ui.PrintError("输出 JSON 失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 检查新版本提醒(非阻塞读取,仅在 text 模式下展示)
|
||||||
|
select {
|
||||||
|
case info := <-updateCh:
|
||||||
|
if info != nil && outputFormat == "text" {
|
||||||
|
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 门禁阻断检测 (Pre-commit 拦截逻辑)
|
||||||
|
outText := reviewOutput.String()
|
||||||
|
hasBlocker := strings.Contains(outText, "[BLOCKER]") || strings.Contains(outText, "[CRITICAL]")
|
||||||
|
hasReject := strings.Contains(outText, "[REJECT]")
|
||||||
|
hasWarning := strings.Contains(outText, "[WARNING]") || strings.Contains(outText, "[WARN]")
|
||||||
|
|
||||||
|
strict, _ := cmd.Flags().GetBool("strict")
|
||||||
|
noBlock, _ := cmd.Flags().GetBool("no-block")
|
||||||
|
|
||||||
|
if !noBlock {
|
||||||
|
if hasBlocker || hasReject {
|
||||||
|
ui.PrintError("[git-air] 审查未通过:检测到 [BLOCKER] 严重缺陷或 [REJECT] 结论,已拦截提交!")
|
||||||
|
ui.PrintError("修复上述问题后再提交;如需临时跳过可执行 'git commit --no-verify' 或加上 '--no-block'")
|
||||||
|
os.Exit(1)
|
||||||
|
} else if strict && hasWarning {
|
||||||
|
ui.PrintError("[git-air] 严格模式(--strict)生效:检测到 [WARNING] 警告,已拦截提交!")
|
||||||
|
ui.PrintError("请优化上述警告或去掉 --strict 标志。")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +191,9 @@ func init() {
|
|||||||
rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
|
rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
|
||||||
rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
|
rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
|
||||||
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
|
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
|
||||||
|
rootCmd.Flags().Bool("cached", false, "显式审查暂存区(git add)代码")
|
||||||
|
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
|
||||||
|
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
|
||||||
|
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
|
||||||
|
rootCmd.Flags().String("format", "text", "输出格式: text 或 json")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
var updateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Aliases: []string{"upgrade"},
|
||||||
|
Short: "一键升级 git-air 到最新版本",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf("🔍 正在检查并拉取最新版本 (当前: %s)...\n", updater.CurrentVersion)
|
||||||
|
|
||||||
|
updateCmd := exec.Command("go", "install", "github.com/unclesam-ly/git-air@latest")
|
||||||
|
updateCmd.Stdout = os.Stdout
|
||||||
|
updateCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := updateCmd.Run(); err != nil {
|
||||||
|
ui.PrintError("升级失败: %v (提示: 请确保已安装 Go 并配置环境变量)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.PrintSuccess("git-air 已成功升级至最新版本!🎉")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(updateCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
|
"github.com/unclesam-ly/git-air/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionCmd = &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "查看当前 git-air 版本信息及更新检测",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf("git-air %s (%s/%s, Go %s)\n",
|
||||||
|
updater.CurrentVersion,
|
||||||
|
runtime.GOOS,
|
||||||
|
runtime.GOARCH,
|
||||||
|
runtime.Version(),
|
||||||
|
)
|
||||||
|
|
||||||
|
checkFlag, _ := cmd.Flags().GetBool("check")
|
||||||
|
if checkFlag {
|
||||||
|
info := updater.CheckUpdate(true)
|
||||||
|
if info != nil {
|
||||||
|
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
|
||||||
|
} else {
|
||||||
|
ui.PrintSuccess("当前已是最新版本!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
versionCmd.Flags().BoolP("check", "c", true, "向 GitHub 检查是否有可用新版本")
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
}
|
||||||
+18
-2
@@ -29,5 +29,21 @@ model: "gemini-3.7-flash"
|
|||||||
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
|
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
|
||||||
# base_url: ""
|
# base_url: ""
|
||||||
|
|
||||||
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
|
# 自定义代码 Reviewer 提示词 (可选,留空则使用内置资深架构师准则)
|
||||||
custom_prompt: ""
|
# 旧字段 custom_prompt 仍兼容,但建议迁移为 custom_reviewer_prompt
|
||||||
|
custom_reviewer_prompt: ""
|
||||||
|
# 旧版兼容字段(v1.x 仍可使用)
|
||||||
|
# custom_prompt: ""
|
||||||
|
|
||||||
|
# 自定义 Commit Message 提示词 (可选,追加在内置 Conventional Commits 约束之前)
|
||||||
|
custom_commit_msg_prompt: ""
|
||||||
|
|
||||||
|
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
|
||||||
|
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
|
||||||
|
# 大模型厂商价格可能动态调整,请以实际价格为准
|
||||||
|
# 若你的中转平台价格有变或使用私有计费,可在此自定义覆盖:
|
||||||
|
# price_input: 0.75 # 每 1M 输入 Token 美元单价
|
||||||
|
# price_output: 3.75 # 每 1M 输出 Token 美元单价
|
||||||
|
|
||||||
|
# AI Commit Message 生成语言 (可选,默认 "auto" 自动跟随系统语言,可选: zh, en, ja, ko)
|
||||||
|
commit_lang: "auto"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module git-air
|
module github.com/unclesam-ly/git-air
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.6
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/unclesam-ly/git-air/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,6 +34,21 @@ func GetRepoRoot(ctx context.Context) (string, error) {
|
|||||||
return strings.TrimSpace(string(out)), nil
|
return strings.TrimSpace(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasStagedChanges 检查当前 Git 暂存区是否有待提交的文件改动
|
||||||
|
func HasStagedChanges(ctx context.Context) (bool, error) {
|
||||||
|
stagedDiff, err := execGitDiff(ctx, "--cached")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(stagedDiff) != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StageAll 执行 git add -A 暂存当前所有改动
|
||||||
|
func StageAll(ctx context.Context) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "add", "-A")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
|
const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
|
||||||
|
|
||||||
// GetDiff 提取并过滤 Git 代码差异
|
// GetDiff 提取并过滤 Git 代码差异
|
||||||
@@ -43,6 +60,9 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
|||||||
return "", ErrNotGitRepo
|
return "", ErrNotGitRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repoRoot, _ := GetRepoRoot(ctx)
|
||||||
|
customIgnores := LoadAirIgnore(repoRoot)
|
||||||
|
|
||||||
var cmdArgs []string
|
var cmdArgs []string
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
// 1. 默认先提取暂存区代码 (准备 commit 的代码)
|
// 1. 默认先提取暂存区代码 (准备 commit 的代码)
|
||||||
@@ -51,7 +71,8 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(stagedDiff) != "" {
|
if strings.TrimSpace(stagedDiff) != "" {
|
||||||
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil
|
truncated := truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...))
|
||||||
|
return security.RedactSecrets(truncated), nil
|
||||||
}
|
}
|
||||||
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
||||||
cmdArgs = []string{}
|
cmdArgs = []string{}
|
||||||
@@ -64,20 +85,22 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredDiff := FilterDiff(rawDiff)
|
truncatedDiff := truncateDiffIfNeeded(FilterDiff(rawDiff, customIgnores...))
|
||||||
|
filteredDiff := security.RedactSecrets(truncatedDiff)
|
||||||
if strings.TrimSpace(filteredDiff) == "" {
|
if strings.TrimSpace(filteredDiff) == "" {
|
||||||
return "", ErrEmptyDiff
|
return "", ErrEmptyDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
return truncateDiffIfNeeded(filteredDiff), nil
|
return filteredDiff, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateDiffIfNeeded(diff string) string {
|
func truncateDiffIfNeeded(diff string) string {
|
||||||
runes := []rune(diff)
|
runes := []rune(diff)
|
||||||
if len(runes) > maxDiffSize {
|
if len(runes) > maxDiffSize {
|
||||||
runes = runes[:maxDiffSize]
|
runes = runes[:maxDiffSize]
|
||||||
}
|
|
||||||
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
||||||
|
}
|
||||||
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
// execGitDiff 执行底层的 git diff 命令
|
// execGitDiff 执行底层的 git diff 命令
|
||||||
|
|||||||
+72
-14
@@ -1,13 +1,14 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 默认需要忽略的文件名与后缀列表
|
// 默认需要忽略的文件名与后缀列表
|
||||||
var ignoredPatterns = []string{
|
var defaultIgnoredPatterns = []string{
|
||||||
// 依赖与锁文件
|
// 依赖与锁文件
|
||||||
"go.sum",
|
"go.sum",
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
@@ -36,19 +37,45 @@ var ignoredPatterns = []string{
|
|||||||
"*.pdf",
|
"*.pdf",
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略
|
// LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则
|
||||||
func isIgnoredFile(filePath string) bool {
|
func LoadAirIgnore(repoRoot string) []string {
|
||||||
cleanPath := filepath.ToSlash(filePath)
|
if repoRoot == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ignoreFile := filepath.Join(repoRoot, ".airignore")
|
||||||
|
file, err := os.Open(ignoreFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var patterns []string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
trimmed := strings.TrimSpace(scanner.Text())
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patterns = append(patterns, trimmed)
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则)
|
||||||
|
func isIgnoredFile(filePath string, customPatterns []string) bool {
|
||||||
|
cleanPath := filepath.ToSlash(strings.TrimSpace(filePath))
|
||||||
baseName := filepath.Base(cleanPath)
|
baseName := filepath.Base(cleanPath)
|
||||||
|
|
||||||
for _, pattern := range ignoredPatterns {
|
// 1. 先匹配用户自定义规则 (.airignore)
|
||||||
// 精确匹配文件名(如 go.sum)
|
for _, pattern := range customPatterns {
|
||||||
if pattern == baseName {
|
if matchPattern(pattern, cleanPath, baseName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 通配符匹配(如 *.pb.go)
|
// 2. 再匹配内置默认规则
|
||||||
if matched, _ := filepath.Match(pattern, baseName); matched {
|
for _, pattern := range defaultIgnoredPatterns {
|
||||||
|
if matchPattern(pattern, cleanPath, baseName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`)
|
// matchPattern 单条规则智能匹配(支持目录/、通配符*、精确匹配)
|
||||||
|
func matchPattern(pattern, cleanPath, baseName string) bool {
|
||||||
|
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
||||||
|
if pattern == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块
|
// 目录规则:如 "docs/" 或 "vendor/"
|
||||||
func FilterDiff(rawDiff string) string {
|
if strings.HasSuffix(pattern, "/") {
|
||||||
|
dirPrefix := strings.TrimSuffix(pattern, "/")
|
||||||
|
if strings.HasPrefix(cleanPath, dirPrefix+"/") || cleanPath == dirPrefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确匹配文件名或相对路径
|
||||||
|
if pattern == baseName || pattern == cleanPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通配符匹配文件名(如 *.pb.go)
|
||||||
|
if matched, _ := filepath.Match(pattern, baseName); matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通配符匹配全路径(如 mock/*.go)
|
||||||
|
if matched, _ := filepath.Match(pattern, cleanPath); matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterDiff 过滤掉 diff 中命中忽略规则(如锁文件、生成的代码、.airignore 排除项)的文件区块
|
||||||
|
func FilterDiff(rawDiff string, customPatterns ...string) string {
|
||||||
if strings.TrimSpace(rawDiff) == "" {
|
if strings.TrimSpace(rawDiff) == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string {
|
|||||||
parts := strings.Fields(firstLine)
|
parts := strings.Fields(firstLine)
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
filePath := strings.TrimPrefix(parts[1], "b/")
|
filePath := strings.TrimPrefix(parts[1], "b/")
|
||||||
if isIgnoredFile(filePath) {
|
if isIgnoredFile(filePath, customPatterns) {
|
||||||
continue // 命中忽略规则,跳过这个文件的 diff
|
continue // 命中忽略规则,跳过这个文件的 diff
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsIgnoredFile(t *testing.T) {
|
||||||
|
customPatterns := []string{
|
||||||
|
"docs/",
|
||||||
|
"tests/mock/*.json",
|
||||||
|
"secrets.env",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
filePath string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// 默认内置忽略规则
|
||||||
|
{"go.sum", true},
|
||||||
|
{"package-lock.json", true},
|
||||||
|
{"api/user.pb.go", true},
|
||||||
|
{"web/dist/app.min.js", true},
|
||||||
|
{"assets/logo.png", true},
|
||||||
|
|
||||||
|
// 用户自定义 .airignore 规则
|
||||||
|
{"docs/readme.md", true},
|
||||||
|
{"docs/api/v1.md", true},
|
||||||
|
{"tests/mock/user.json", true},
|
||||||
|
{"secrets.env", true},
|
||||||
|
|
||||||
|
// 不应被忽略的正常业务代码
|
||||||
|
{"cmd/root.go", false},
|
||||||
|
{"internal/service/user.go", false},
|
||||||
|
{"pkg/utils/helper.go", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isIgnoredFile(tt.filePath, customPatterns)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("isIgnoredFile(%q) = %v, expected %v", tt.filePath, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAirIgnore(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "git-air-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建临时目录失败: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
content := `# 忽略文档
|
||||||
|
docs/
|
||||||
|
# 忽略临时文件
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
`
|
||||||
|
ignoreFile := filepath.Join(tempDir, ".airignore")
|
||||||
|
if err := os.WriteFile(ignoreFile, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("写入 .airignore 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := LoadAirIgnore(tempDir)
|
||||||
|
expected := []string{"docs/", "*.tmp", "*.log"}
|
||||||
|
|
||||||
|
if len(patterns) != len(expected) {
|
||||||
|
t.Fatalf("LoadAirIgnore 返回数量不符: got %v, expected %v", patterns, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range patterns {
|
||||||
|
if p != expected[i] {
|
||||||
|
t.Errorf("Pattern[%d] = %q, expected %q", i, p, expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterDiff(t *testing.T) {
|
||||||
|
rawDiff := `diff --git a/go.sum b/go.sum
|
||||||
|
index 111..222 100644
|
||||||
|
--- a/go.sum
|
||||||
|
+++ b/go.sum
|
||||||
|
@@ -1 +1 @@
|
||||||
|
+some-package v1.0.0 h1:...
|
||||||
|
diff --git a/internal/service/user.go b/internal/service/user.go
|
||||||
|
index 333..444 100644
|
||||||
|
--- a/internal/service/user.go
|
||||||
|
+++ b/internal/service/user.go
|
||||||
|
@@ -1 +1 @@
|
||||||
|
+func HandleUser() {}
|
||||||
|
diff --git a/docs/spec.md b/docs/spec.md
|
||||||
|
index 555..666 100644
|
||||||
|
--- a/docs/spec.md
|
||||||
|
+++ b/docs/spec.md
|
||||||
|
@@ -1 +1 @@
|
||||||
|
+# API Spec
|
||||||
|
`
|
||||||
|
customPatterns := []string{"docs/"}
|
||||||
|
filtered := FilterDiff(rawDiff, customPatterns...)
|
||||||
|
|
||||||
|
if strings.Contains(filtered, "go.sum") {
|
||||||
|
t.Errorf("FilterDiff 应当过滤掉 go.sum")
|
||||||
|
}
|
||||||
|
if strings.Contains(filtered, "docs/spec.md") {
|
||||||
|
t.Errorf("FilterDiff 应当过滤掉 docs/spec.md")
|
||||||
|
}
|
||||||
|
if !strings.Contains(filtered, "internal/service/user.go") {
|
||||||
|
t.Errorf("FilterDiff 应当保留正常的业务代码 internal/service/user.go")
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-12
@@ -16,12 +16,16 @@ type Config struct {
|
|||||||
APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
|
APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
|
||||||
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
|
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
|
||||||
Model string // 模型名称 (非必填,会自动设置默认主力模型)
|
Model string // 模型名称 (非必填,会自动设置默认主力模型)
|
||||||
|
PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表)
|
||||||
|
PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client 统一大模型客户端
|
// Client 统一大模型客户端
|
||||||
type Client struct {
|
type Client struct {
|
||||||
client *openai.Client
|
client *openai.Client
|
||||||
model string
|
model string
|
||||||
|
priceInput float64
|
||||||
|
priceOutput float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient 初始化客户端
|
// NewClient 初始化客户端
|
||||||
@@ -229,19 +233,55 @@ func NewClient(cfg Config) (*Client, error) {
|
|||||||
return &Client{
|
return &Client{
|
||||||
client: openai.NewClientWithConfig(clientConfig),
|
client: openai.NewClientWithConfig(clientConfig),
|
||||||
model: model,
|
model: model,
|
||||||
|
priceInput: cfg.PriceInput,
|
||||||
|
priceOutput: cfg.PriceOutput,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isReasoningModel 判断是否为推理或带有 Beta 限制的模型(这类模型要求 temperature 固定为 1)
|
||||||
|
func isReasoningModel(model string) bool {
|
||||||
|
m := strings.ToLower(model)
|
||||||
|
return strings.HasPrefix(m, "o1") ||
|
||||||
|
strings.HasPrefix(m, "o3") ||
|
||||||
|
strings.HasPrefix(m, "o4") ||
|
||||||
|
strings.Contains(m, "reasoner") ||
|
||||||
|
strings.Contains(m, "gpt-5") ||
|
||||||
|
strings.Contains(m, "luna") ||
|
||||||
|
strings.Contains(m, "terra") ||
|
||||||
|
strings.Contains(m, "sol")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTemperatureError 检测 API 报错是否由 temperature 参数限制触发
|
||||||
|
func isTemperatureError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "temperature") || strings.Contains(msg, "beta-limitations")
|
||||||
|
}
|
||||||
|
|
||||||
// Review 一次性完整返回代码评审结果
|
// Review 一次性完整返回代码评审结果
|
||||||
func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) {
|
func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) {
|
||||||
resp, err := c.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
temp := float32(0.2)
|
||||||
|
if isReasoningModel(c.model) {
|
||||||
|
temp = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
req := openai.ChatCompletionRequest{
|
||||||
Model: c.model,
|
Model: c.model,
|
||||||
Messages: []openai.ChatCompletionMessage{
|
Messages: []openai.ChatCompletionMessage{
|
||||||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||||||
{Role: openai.ChatMessageRoleUser, Content: diff},
|
{Role: openai.ChatMessageRoleUser, Content: diff},
|
||||||
},
|
},
|
||||||
Temperature: 0.2, // 代码评审低随机性
|
Temperature: temp,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.CreateChatCompletion(ctx, req)
|
||||||
|
// 针对限制了 temperature 的模型,且首次非 1.0 时,自动调整为 1.0 重试
|
||||||
|
if err != nil && req.Temperature != 1.0 && isTemperatureError(err) {
|
||||||
|
req.Temperature = 1.0
|
||||||
|
resp, err = c.client.CreateChatCompletion(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("代码评审调用失败: %w", err)
|
return "", fmt.Errorf("代码评审调用失败: %w", err)
|
||||||
@@ -254,28 +294,164 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string,
|
|||||||
return resp.Choices[0].Message.Content, nil
|
return resp.Choices[0].Message.Content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewStream 流式逐字返回评审结果(打字机效果)
|
// Usage 记录一次调用的 Token 消耗
|
||||||
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error {
|
type Usage struct {
|
||||||
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
InputTokens int
|
||||||
|
OutputTokens int
|
||||||
|
Model string
|
||||||
|
Provider string
|
||||||
|
CustomPriceInput float64 // 用户自定义输入单价(每 1M tokens 美元,>0 优先使用)
|
||||||
|
CustomPriceOutput float64 // 用户自定义输出单价(每 1M tokens 美元,>0 优先使用)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewPriceTable 各模型每 1M token 的美元单价(输入/输出)
|
||||||
|
// 数据来源:各厂商官方定价页面,更新于 2026-08
|
||||||
|
// 如实际价格有变动,可在 config.yaml 中通过 price_input/price_output 字段自定义覆盖
|
||||||
|
var reviewPriceTable = map[string][2]float64{
|
||||||
|
|
||||||
|
// ── Google Gemini ─────────────────────────────────────────────
|
||||||
|
// 3.x 系列(当前主力)
|
||||||
|
"gemini-3.7-flash": {0.75, 3.75}, // 推介价至 2026-12-31,之后 1.50/7.50
|
||||||
|
"gemini-3.6-flash": {1.50, 7.50},
|
||||||
|
"gemini-3.5-flash": {1.50, 9.00},
|
||||||
|
"gemini-3.5-flash-lite": {0.30, 2.50},
|
||||||
|
"gemini-3.1-flash-lite": {0.25, 1.50},
|
||||||
|
"gemini-3.1-pro": {2.00, 12.00},
|
||||||
|
// 2.5 系列(2026-10 退役)
|
||||||
|
"gemini-2.5-pro": {1.25, 10.00},
|
||||||
|
"gemini-2.5-flash": {0.30, 2.50},
|
||||||
|
"gemini-2.5-flash-lite": {0.10, 0.40},
|
||||||
|
// 旧款(兼容)
|
||||||
|
"gemini-2.0-flash": {0.10, 0.40},
|
||||||
|
|
||||||
|
// ── DeepSeek ─────────────────────────────────────────────────
|
||||||
|
// 注意:DeepSeek 已启用峰谷定价,以下为非高峰标准价
|
||||||
|
"deepseek-v4-flash": {0.22, 0.66},
|
||||||
|
"deepseek-chat": {0.22, 0.66}, // V4-Flash 别名
|
||||||
|
"deepseek-reasoner": {0.55, 2.19},
|
||||||
|
|
||||||
|
// ── Anthropic Claude ──────────────────────────────────────────
|
||||||
|
"claude-opus-5": {5.00, 25.00},
|
||||||
|
"claude-sonnet-5": {2.00, 10.00}, // 推介价至 2026-08-31
|
||||||
|
"anthropic/claude-sonnet-5": {2.00, 10.00},
|
||||||
|
"claude-haiku-4-5": {1.00, 5.00},
|
||||||
|
"anthropic/claude-haiku-4-5": {1.00, 5.00},
|
||||||
|
// 旧款(兼容)
|
||||||
|
"anthropic/claude-3.7-sonnet": {3.00, 15.00},
|
||||||
|
"anthropic/claude-3.5-sonnet": {3.00, 15.00},
|
||||||
|
"anthropic/claude-3.5-haiku": {0.80, 4.00},
|
||||||
|
|
||||||
|
// ── xAI Grok ─────────────────────────────────────────────────
|
||||||
|
"grok-4.6": {2.00, 6.00}, // >200k tokens 翻倍
|
||||||
|
"grok-4.3": {1.25, 2.50},
|
||||||
|
"grok-2-latest": {2.00, 6.00}, // 旧款映射
|
||||||
|
|
||||||
|
// ── OpenAI ───────────────────────────────────────────────────
|
||||||
|
"gpt-5.6-sol": {5.00, 30.00},
|
||||||
|
"gpt-5.6-terra": {2.00, 12.00},
|
||||||
|
"gpt-5.6-luna": {0.20, 1.20},
|
||||||
|
"gpt-4o": {2.50, 10.00}, // 旧款保留
|
||||||
|
"gpt-4o-mini": {0.15, 0.60},
|
||||||
|
"o3-mini": {1.10, 4.40},
|
||||||
|
|
||||||
|
// ── SiliconFlow(聚合平台,按 DeepSeek V4 主力估算)────────────
|
||||||
|
"deepseek-ai/DeepSeek-V3": {0.22, 0.66},
|
||||||
|
"deepseek-ai/DeepSeek-R1": {0.55, 2.19},
|
||||||
|
|
||||||
|
// ── Qwen / 阿里通义千问 ───────────────────────────────────────
|
||||||
|
"qwen-3.8-max": {2.00, 6.00},
|
||||||
|
"qwen-max": {2.00, 6.00},
|
||||||
|
"qwen-plus": {0.40, 1.20},
|
||||||
|
"qwen-3.7-flash": {0.03, 0.13},
|
||||||
|
|
||||||
|
// ── Kimi / Moonshot AI ────────────────────────────────────────
|
||||||
|
"kimi-k3": {3.00, 15.00},
|
||||||
|
"moonshot-v1-8k": {0.95, 4.00},
|
||||||
|
"moonshot-v1-32k": {1.90, 8.00},
|
||||||
|
"moonshot-v1-128k": {3.50, 15.00},
|
||||||
|
|
||||||
|
// ── 智谱 GLM ─────────────────────────────────────────────────
|
||||||
|
"glm-5.3": {1.40, 4.40},
|
||||||
|
"glm-5.2": {1.40, 4.40},
|
||||||
|
"glm-4-plus": {1.40, 4.40},
|
||||||
|
|
||||||
|
// ── Doubao / 字节跳动 ─────────────────────────────────────────
|
||||||
|
"doubao-seed-2.1-pro": {0.88, 4.42},
|
||||||
|
"doubao-pro-32k": {0.88, 4.42},
|
||||||
|
|
||||||
|
// ── Groq(超快推理,按量计费)────────────────────────────────
|
||||||
|
"llama-3.3-70b-versatile": {0.59, 0.79},
|
||||||
|
"llama-3.1-8b-instant": {0.05, 0.08},
|
||||||
|
|
||||||
|
// ── 本地模型(完全免费)──────────────────────────────────────
|
||||||
|
"ollama": {0, 0},
|
||||||
|
"local-model": {0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateCost 估算费用(美元),返回 -1 表示无定价数据
|
||||||
|
func (u *Usage) EstimateCost() float64 {
|
||||||
|
// 1. 优先使用用户在 config.yaml 中配置的自定义单价(每 1M tokens 美元)
|
||||||
|
if u.CustomPriceInput > 0 || u.CustomPriceOutput > 0 {
|
||||||
|
inputCost := float64(u.InputTokens) / 1_000_000 * u.CustomPriceInput
|
||||||
|
outputCost := float64(u.OutputTokens) / 1_000_000 * u.CustomPriceOutput
|
||||||
|
return inputCost + outputCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退使用内置官方模型价格表
|
||||||
|
prices, ok := reviewPriceTable[u.Model]
|
||||||
|
if !ok {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
// Ollama / 本地模型 price = 0,直接返回 0
|
||||||
|
inputCost := float64(u.InputTokens) / 1_000_000 * prices[0]
|
||||||
|
outputCost := float64(u.OutputTokens) / 1_000_000 * prices[1]
|
||||||
|
return inputCost + outputCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewStream 流式逐字返回评审结果(打字机效果),同时返回 Token 使用量
|
||||||
|
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) (*Usage, error) {
|
||||||
|
temp := float32(0.2)
|
||||||
|
if isReasoningModel(c.model) {
|
||||||
|
temp = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
req := openai.ChatCompletionRequest{
|
||||||
Model: c.model,
|
Model: c.model,
|
||||||
Messages: []openai.ChatCompletionMessage{
|
Messages: []openai.ChatCompletionMessage{
|
||||||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||||||
{Role: openai.ChatMessageRoleUser, Content: diff},
|
{Role: openai.ChatMessageRoleUser, Content: diff},
|
||||||
},
|
},
|
||||||
Temperature: 0.2,
|
Temperature: temp,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
})
|
StreamOptions: &openai.StreamOptions{
|
||||||
|
IncludeUsage: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.client.CreateChatCompletionStream(ctx, req)
|
||||||
|
// 针对限制了 temperature 的模型,且首次非 1.0 时,自动调整为 1.0 重试
|
||||||
|
if err != nil && req.Temperature != 1.0 && isTemperatureError(err) {
|
||||||
|
req.Temperature = 1.0
|
||||||
|
stream, err = c.client.CreateChatCompletionStream(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建流式请求失败: %w", err)
|
return nil, fmt.Errorf("创建流式请求失败: %w", err)
|
||||||
}
|
}
|
||||||
if stream != nil {
|
if stream != nil {
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage := &Usage{
|
||||||
|
Model: c.model,
|
||||||
|
CustomPriceInput: c.priceInput,
|
||||||
|
CustomPriceOutput: c.priceOutput,
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return nil, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,8 +460,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("流式读取异常: %w", err)
|
return nil, fmt.Errorf("流式读取异常: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 捕获最后一个 chunk 中携带的 Usage 信息
|
||||||
|
if response.Usage != nil && response.Usage.TotalTokens > 0 {
|
||||||
|
usage.InputTokens = response.Usage.PromptTokens
|
||||||
|
usage.OutputTokens = response.Usage.CompletionTokens
|
||||||
|
}
|
||||||
|
|
||||||
if len(response.Choices) > 0 {
|
if len(response.Choices) > 0 {
|
||||||
chunk := response.Choices[0].Delta.Content
|
chunk := response.Choices[0].Delta.Content
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
@@ -294,5 +477,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsReasoningModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"o1", true},
|
||||||
|
{"o1-mini", true},
|
||||||
|
{"o1-preview", true},
|
||||||
|
{"o3-mini", true},
|
||||||
|
{"gpt-5.6-luna", true},
|
||||||
|
{"gpt-5.6-sol", true},
|
||||||
|
{"gpt-5.6-terra", true},
|
||||||
|
{"deepseek-reasoner", true},
|
||||||
|
{"gemini-3.7-flash", false},
|
||||||
|
{"claude-4.6-sonnet", false},
|
||||||
|
{"gpt-4o", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isReasoningModel(tt.model)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("isReasoningModel(%q) = %v, expected %v", tt.model, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTemperatureError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
errStr string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"this model has beta-limitations, temperature, top_p and n are fixed at 1", true},
|
||||||
|
{"temperature must be 1.0", true},
|
||||||
|
{"invalid api key", false},
|
||||||
|
{"rate limit exceeded", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isTemperatureError(fmt.Errorf("%s", tt.errStr))
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("isTemperatureError(%q) = %v, expected %v", tt.errStr, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package reviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectLanguage 智能解析目标自然语言
|
||||||
|
// 优先级:传入指定语言 -> 系统 LANG/LC_ALL 环境变量 -> 默认 "en"
|
||||||
|
func DetectLanguage(specifiedLang string) string {
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(specifiedLang))
|
||||||
|
if lang != "" && lang != "auto" {
|
||||||
|
return NormalizeLang(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从系统环境变量中探测
|
||||||
|
envLang := os.Getenv("LC_ALL")
|
||||||
|
if envLang == "" {
|
||||||
|
envLang = os.Getenv("LANG")
|
||||||
|
}
|
||||||
|
if envLang == "" {
|
||||||
|
envLang = os.Getenv("LC_MESSAGES")
|
||||||
|
}
|
||||||
|
|
||||||
|
envLang = strings.ToLower(envLang)
|
||||||
|
if strings.HasPrefix(envLang, "zh") {
|
||||||
|
return "zh"
|
||||||
|
} else if strings.HasPrefix(envLang, "ja") {
|
||||||
|
return "ja"
|
||||||
|
} else if strings.HasPrefix(envLang, "ko") {
|
||||||
|
return "ko"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeLang 规范化语言缩写
|
||||||
|
func NormalizeLang(lang string) string {
|
||||||
|
switch strings.ToLower(lang) {
|
||||||
|
case "zh", "cn", "chinese", "zh-cn", "zh-tw", "中文":
|
||||||
|
return "zh"
|
||||||
|
case "ja", "jp", "japanese", "ja-jp", "日本語", "日文":
|
||||||
|
return "ja"
|
||||||
|
case "ko", "kr", "korean", "ko-kr", "한국어", "韩文":
|
||||||
|
return "ko"
|
||||||
|
default:
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCommitMsgPrompt 构建用于生成 Conventional Commits 的专业提示词
|
||||||
|
func BuildCommitMsgPrompt(lang string) string {
|
||||||
|
langName := "English"
|
||||||
|
exampleSummary := "feat(auth): add Redis token caching and fix mutex deadlock"
|
||||||
|
exampleBullet1 := "- Introduce Redis caching layer for user authentication"
|
||||||
|
exampleBullet2 := "- Fix concurrency bug where mutex was not unlocked on early return"
|
||||||
|
|
||||||
|
switch lang {
|
||||||
|
case "zh":
|
||||||
|
langName = "简体中文 (Simplified Chinese)"
|
||||||
|
exampleSummary = "feat(auth): 引入 Redis 缓存并修复 Mutex 死锁隐患"
|
||||||
|
exampleBullet1 = "- 在用户认证模块新增 Redis Token 缓存机制"
|
||||||
|
exampleBullet2 = "- 修复提前 return 时未释放互斥锁的并发隐患"
|
||||||
|
case "ja":
|
||||||
|
langName = "日本語 (Japanese)"
|
||||||
|
exampleSummary = "feat(auth): Redisキャッシュを追加し、Mutexのデッドロックを修正"
|
||||||
|
exampleBullet1 = "- ユーザー認証ハンドラーに Redis トークンキャッシュ層を導入"
|
||||||
|
exampleBullet2 = "- 早期リターン時に Mutex の Unlock が漏れる並行処理のバグを修正"
|
||||||
|
case "ko":
|
||||||
|
langName = "한국어 (Korean)"
|
||||||
|
exampleSummary = "feat(auth): Redis 캐시 도입 및 Mutex 데드락 버그 수정"
|
||||||
|
exampleBullet1 = "- 사용자 인증 핸들러에 Redis 토큰 캐시 계층 추가"
|
||||||
|
exampleBullet2 = "- 조기 반환 시 Mutex가 해제되지 않는 동시성 문제 해결"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`你是一位精通 Git 版本控制与 Conventional Commits 规范的资深架构师。
|
||||||
|
你的任务是根据开发者提供的 Git Diff 增量代码,生成一条极其规范、精炼、清晰的 Git Commit Message。
|
||||||
|
|
||||||
|
### 规则要求:
|
||||||
|
1. 必须使用 Conventional Commits 规范:<type>(<scope>): <subject>
|
||||||
|
- type 可选:feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||||
|
- scope 为本次变动的核心模块或文件名(英文小写,如 auth, db, ui, config 等)
|
||||||
|
2. 【语言要求】:除 type(scope) 以外的主题与说明必须使用【%s】撰写!
|
||||||
|
3. 输出结构:
|
||||||
|
- 第一行:精炼的主题行(不超过 60 个字符,不带句号)
|
||||||
|
- 第二行:留空行
|
||||||
|
- 第三行及之后(可选):使用 1~3 个无序列表项 (-) 简述核心改动要点
|
||||||
|
4. 严禁任何 Markdown 代码块包裹,严禁任何客套话或多余解释,直接输出纯文本 Commit Message!
|
||||||
|
|
||||||
|
### 示例参考:
|
||||||
|
%s
|
||||||
|
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
`, langName, exampleSummary, exampleBullet1, exampleBullet2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCommitMsgPromptWithCustom 在内置 Conventional Commits 约束基础上追加用户规则。
|
||||||
|
// 保留内置约束,避免自定义提示词意外要求模型输出非法提交格式。
|
||||||
|
func BuildCommitMsgPromptWithCustom(lang, customPrompt string) string {
|
||||||
|
basePrompt := BuildCommitMsgPrompt(lang)
|
||||||
|
customPrompt = strings.TrimSpace(customPrompt)
|
||||||
|
if customPrompt == "" {
|
||||||
|
return basePrompt
|
||||||
|
}
|
||||||
|
return customPrompt + "\n\n### git-air 内置提交信息约束(必须遵守):\n" + basePrompt
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package reviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"zh", "zh"},
|
||||||
|
{"ZH", "zh"},
|
||||||
|
{"zh-CN", "zh"},
|
||||||
|
{"中文", "zh"},
|
||||||
|
{"ja", "ja"},
|
||||||
|
{"ja-JP", "ja"},
|
||||||
|
{"Japanese", "ja"},
|
||||||
|
{"日本語", "ja"},
|
||||||
|
{"ko", "ko"},
|
||||||
|
{"ko-KR", "ko"},
|
||||||
|
{"한국어", "ko"},
|
||||||
|
{"en", "en"},
|
||||||
|
{"en-US", "en"},
|
||||||
|
{"English", "en"},
|
||||||
|
{"fr", "en"}, // 未预置语言回退到 en
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := DetectLanguage(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("DetectLanguage(%q) = %q, expected %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommitMsgPrompt(t *testing.T) {
|
||||||
|
langs := []struct {
|
||||||
|
lang string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"zh", "简体中文"},
|
||||||
|
{"ja", "日本語"},
|
||||||
|
{"ko", "한국어"},
|
||||||
|
{"en", "English"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range langs {
|
||||||
|
prompt := BuildCommitMsgPrompt(tt.lang)
|
||||||
|
if !strings.Contains(prompt, tt.expected) {
|
||||||
|
t.Errorf("BuildCommitMsgPrompt(%q) 应包含语言名称 %q", tt.lang, tt.expected)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "Conventional Commits") {
|
||||||
|
t.Errorf("BuildCommitMsgPrompt(%q) 应包含 Conventional Commits 说明", tt.lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommitMsgPromptWithCustom(t *testing.T) {
|
||||||
|
prompt := BuildCommitMsgPromptWithCustom("en", "Use a short imperative subject and mention breaking changes explicitly.")
|
||||||
|
if !strings.Contains(prompt, "Use a short imperative subject") {
|
||||||
|
t.Fatal("自定义 Commit Message Prompt 未被合并")
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "Conventional Commits") {
|
||||||
|
t.Fatal("内置 Conventional Commits 约束不应丢失")
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-19
@@ -5,29 +5,43 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
|
// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
|
||||||
const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。
|
const DefaultBaseSystemPrompt = `你是一位拥有 15 年以上高可用架构、高并发底层设计与代码安全审计经验的顶级首席架构师(Principal Engineer)。
|
||||||
你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。
|
你的核心使命是对开发者提交的 Git Diff 增量代码进行工业级、严苛、实证化、高信噪比的代码审查(Code Review)。
|
||||||
### 评审原则:
|
### 一、 核心审查原则(铁律)
|
||||||
1. 严禁任何客套话与无意义吹捧,直奔技术实质。
|
1. 【零客套、纯硬核】:严禁任何寒暄、恭维或废话,直接输出技术审查实质。
|
||||||
2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。
|
2. 【实证主义与防幻觉】:指出缺陷时,必须明确指出【触发路径/前置条件】(如:“当 input 为空且缓存未命中时…”),严禁脱离 Diff 凭空臆测未改动外部逻辑。
|
||||||
3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。
|
3. 【最小侵入修复】:给出的修复方案必须语法正确、开箱即用,优先给出最小改动补丁,严禁使用包含大量省略号的伪代码。
|
||||||
4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。
|
4. 【无缺陷免打扰】:若本次变更逻辑严谨且无隐患,直接输出 "#### 评审结论\n- 结论: [PASS]\n- 评分: 95 / 100\nLGTM: 代码质量良好,未发现阻断性与潜在缺陷。"。
|
||||||
### 重点审查维度:
|
### 二、 缺陷严重级别定义(严格执行,不得随意跨级)
|
||||||
- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。
|
- [BLOCKER] (阻断级/必须拦截):
|
||||||
- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。
|
- 必然或高概率导致:线上 Panic/Crash、死锁/活锁、Goroutine/内存泄露、并发竞态读写破坏、SQL 注入/越权/密钥硬编码、事务脏读/数据丢失、未释放资源句柄。
|
||||||
- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。
|
- 【结论强制为 [REJECT],评分 < 60】
|
||||||
- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。
|
- [WARNING] (告警级/潜在隐患):
|
||||||
### 输出格式规范(使用清晰严谨的纯文本标签):
|
- 潜在风险或性能退化:缺乏超时控制/重试风暴、大循环内频繁堆分配/N+1 查询、静默吞掉底层 Error、边界条件未覆盖、缺少 defer 兜底。
|
||||||
|
- 【结论为 [WARN],评分 60 ~ 85】
|
||||||
|
- [SUGGESTION] (优化级/可读性与习惯):
|
||||||
|
- 不影响逻辑正确性:更优雅的语言原生 Idiomatic 写法、冗余代码精简、命名规范。
|
||||||
|
- 【结论为 [PASS],评分 85 ~ 100】
|
||||||
|
### 三、 重点技术维度专项排查清单
|
||||||
|
1. 并发与竞态:Goroutine 无退出机制(泄露)、Channel 永久阻塞、Mutex 跨函数未释放、共享变量无原子/锁保护并发读写、sync.WaitGroup 传值而非指针。
|
||||||
|
2. 资源与内存:文件/连接/DB rows 未立即 defer Close()、time.After 在循环/select 中堆积内存、大切片底层数组无法 GC。
|
||||||
|
3. 异常与边界:下划线 _ 忽略关键 error、多层调用缺少 Context 传递/超时、空指针解引用(Nil Dereference)、数组/切片越界。
|
||||||
|
4. 安全与权限:动态拼接 SQL 语句、未转义外部输入、硬编码敏感凭证、缺少幂等性保障。
|
||||||
|
### 四、 输出格式规范(必须严格遵循 Markdown 格式)
|
||||||
#### 变更概述
|
#### 变更概述
|
||||||
(用 1-2 句话总结本次代码改动核心内容)
|
(用 1-2 句话客观概括本次变更的业务逻辑与技术影响)
|
||||||
#### 详细审查意见
|
#### 详细审查意见
|
||||||
(如果有问题,按严重程度列出;若无问题则省略此段)
|
(若存在问题,按严重程度递减排列;若无缺陷则此段直接省略)
|
||||||
- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块)
|
- [BLOCKER] 文件路径:行号 - 缺陷描述:说明触发场景、事故危害及调用路径
|
||||||
- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点
|
// 建议修复代码:
|
||||||
- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法
|
` + "```go" + `
|
||||||
|
// 修复后的代码块
|
||||||
|
` + "```" + `
|
||||||
|
- [WARNING] 文件路径:行号 - 潜在隐患:说明风险点及性能优化建议
|
||||||
|
- [SUGGESTION] 文件路径:行号 - 优化建议:更地道的代码组织方式
|
||||||
#### 评审结论
|
#### 评审结论
|
||||||
- 结论: [PASS / WARN / REJECT]
|
- 结论: [PASS / WARN / REJECT]
|
||||||
- 评分: X / 100
|
- 评分: X / 100 (0-100分)
|
||||||
`
|
`
|
||||||
|
|
||||||
// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
|
// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package reviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Finding struct {
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewReport struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Verdict string `json:"verdict,omitempty"`
|
||||||
|
Score int `json:"score,omitempty"`
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
verdictPattern = regexp.MustCompile(`(?mi)(?:结论|verdict)\s*[::]\s*\[?(PASS|WARN|WARNING|REJECT|BLOCKER|CRITICAL)\]?`)
|
||||||
|
scorePattern = regexp.MustCompile(`(?mi)(?:评分|score)\s*[::]\s*(\d{1,3})`)
|
||||||
|
findingPattern = regexp.MustCompile(`(?m)^\s*-\s*\[(BLOCKER|CRITICAL|WARNING|WARN|SUGGESTION|INFO)\]\s+(.+?)\s*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseReviewReport 将模型的 Markdown Review 转成稳定的 JSON 结构。
|
||||||
|
// Raw 始终保留完整原文,解析失败时下游仍可自行处理。
|
||||||
|
func ParseReviewReport(raw string) ReviewReport {
|
||||||
|
report := ReviewReport{
|
||||||
|
Format: "git-air-review/v1",
|
||||||
|
Findings: []Finding{},
|
||||||
|
Raw: strings.TrimSpace(raw),
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := verdictPattern.FindStringSubmatch(raw); len(match) > 1 {
|
||||||
|
report.Verdict = strings.ToUpper(match[1])
|
||||||
|
}
|
||||||
|
if match := scorePattern.FindStringSubmatch(raw); len(match) > 1 {
|
||||||
|
report.Score, _ = strconv.Atoi(match[1])
|
||||||
|
if report.Score > 100 {
|
||||||
|
report.Score = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range findingPattern.FindAllStringSubmatch(raw, -1) {
|
||||||
|
location, message := splitFindingContent(match[2])
|
||||||
|
report.Findings = append(report.Findings, Finding{
|
||||||
|
Severity: strings.ToUpper(match[1]),
|
||||||
|
Location: location,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFindingContent(content string) (string, string) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
parts := strings.SplitN(content, " - ", 2)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return "", content
|
||||||
|
}
|
||||||
|
|
||||||
|
location := strings.TrimSpace(parts[0])
|
||||||
|
message := strings.TrimSpace(parts[1])
|
||||||
|
// 没有行号/路径特征时,不要误把普通句子当成 location。
|
||||||
|
if !strings.Contains(location, ":") {
|
||||||
|
return "", content
|
||||||
|
}
|
||||||
|
return location, message
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package reviewer
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseReviewReport(t *testing.T) {
|
||||||
|
raw := `#### 详细审查意见
|
||||||
|
- [BLOCKER] internal/user.go:45 - 存在 SQL 注入风险
|
||||||
|
- [WARNING] internal/user.go:82 - Redis 错误被忽略
|
||||||
|
- [WARNING] 缺少超时控制
|
||||||
|
#### 评审结论
|
||||||
|
- 结论: [REJECT]
|
||||||
|
- 评分: 58 / 100
|
||||||
|
`
|
||||||
|
|
||||||
|
report := ParseReviewReport(raw)
|
||||||
|
if report.Verdict != "REJECT" || report.Score != 58 {
|
||||||
|
t.Fatalf("结论解析错误: %+v", report)
|
||||||
|
}
|
||||||
|
if len(report.Findings) != 3 || report.Findings[0].Location != "internal/user.go:45" {
|
||||||
|
t.Fatalf("问题解析错误: %+v", report.Findings)
|
||||||
|
}
|
||||||
|
if report.Findings[2].Location != "" || report.Findings[2].Message != "缺少超时控制" {
|
||||||
|
t.Fatalf("无位置信息的问题解析错误: %+v", report.Findings[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ package reviewer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git-air/internal/llm"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/unclesam-ly/git-air/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Reviewer struct {
|
type Reviewer struct {
|
||||||
@@ -23,18 +24,41 @@ func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Revi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute 执行代码评审
|
// Execute 执行代码评审,返回 (result, usage, error)
|
||||||
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) {
|
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, *llm.Usage, error) {
|
||||||
if strings.TrimSpace(diff) == "" {
|
if strings.TrimSpace(diff) == "" {
|
||||||
return "", fmt.Errorf("diff 内容为空,无需评审")
|
return "", nil, fmt.Errorf("diff 内容为空,无需评审")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态组装最终 System Prompt
|
// 动态组装最终 System Prompt
|
||||||
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
|
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
|
||||||
if stream && onChunk != nil {
|
if stream && onChunk != nil {
|
||||||
err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
|
usage, err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
|
||||||
|
return "", usage, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.client.Review(ctx, systemPrompt, diff)
|
||||||
|
return result, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCommitMsg 根据 Diff 智能生成符合 Conventional Commits 规范的提交信息
|
||||||
|
func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string, customPrompt string) (string, error) {
|
||||||
|
if strings.TrimSpace(diff) == "" {
|
||||||
|
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetLang := DetectLanguage(lang)
|
||||||
|
systemPrompt := BuildCommitMsgPromptWithCustom(targetLang, customPrompt)
|
||||||
|
|
||||||
|
msg, err := r.client.Review(ctx, systemPrompt, diff)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.client.Review(ctx, systemPrompt, diff)
|
// 清理多余的代码块标记或前后空白
|
||||||
|
cleanMsg := strings.TrimSpace(msg)
|
||||||
|
cleanMsg = strings.TrimPrefix(cleanMsg, "```gitcommit")
|
||||||
|
cleanMsg = strings.TrimPrefix(cleanMsg, "```")
|
||||||
|
cleanMsg = strings.TrimSuffix(cleanMsg, "```")
|
||||||
|
return strings.TrimSpace(cleanMsg), nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
type redactRule struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
var redactRules = []redactRule{
|
||||||
|
{
|
||||||
|
// PEM 私钥必须优先处理,避免后续规则只替换其中一部分。
|
||||||
|
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`),
|
||||||
|
replacement: "[REDACTED_SECRET]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 常见 Token 前缀:OpenAI/GitHub/AWS/Slack 等。
|
||||||
|
pattern: regexp.MustCompile(`(?i)\b(?:sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b`),
|
||||||
|
replacement: "[REDACTED_SECRET]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Authorization: Bearer ...
|
||||||
|
pattern: regexp.MustCompile(`(?i)(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+`),
|
||||||
|
replacement: "${1}[REDACTED_BEARER_TOKEN]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// JWT。
|
||||||
|
pattern: regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`),
|
||||||
|
replacement: "[REDACTED_SECRET]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 数据库连接串。
|
||||||
|
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s"'<>]+`),
|
||||||
|
replacement: "[REDACTED_SECRET]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 常见密钥字段赋值。值至少 8 个字符,减少普通变量误报。
|
||||||
|
pattern: regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|password|passwd|token)\b\s*[:=]\s*["']?)[^\s"' ,;]{8,}`),
|
||||||
|
replacement: "${1}[REDACTED_SECRET]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactSecrets 在 Diff 发送给模型前替换常见敏感信息。
|
||||||
|
// 脱敏只作用于发送给模型的字符串,不修改工作区文件或 Git 内容。
|
||||||
|
func RedactSecrets(input string) string {
|
||||||
|
output := input
|
||||||
|
for _, rule := range redactRules {
|
||||||
|
output = rule.pattern.ReplaceAllString(output, rule.replacement)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactSecrets(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
api_key = "sk-12345678901234567890"
|
||||||
|
Authorization: Bearer my-secret-bearer-token-123
|
||||||
|
password: "this-is-a-secret-password"
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
secret private key content
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`
|
||||||
|
if strings.Contains(input, "[REDACTED_") {
|
||||||
|
t.Fatal("测试输入不应预先包含脱敏占位符")
|
||||||
|
}
|
||||||
|
|
||||||
|
output := RedactSecrets(input)
|
||||||
|
if output == input {
|
||||||
|
t.Fatal("RedactSecrets 未改变包含敏感信息的输入")
|
||||||
|
}
|
||||||
|
for _, secret := range []string{
|
||||||
|
"sk-12345678901234567890",
|
||||||
|
"my-secret-bearer-token-123",
|
||||||
|
"this-is-a-secret-password",
|
||||||
|
"secret private key content",
|
||||||
|
} {
|
||||||
|
if strings.Contains(output, secret) {
|
||||||
|
t.Fatalf("敏感信息未被脱敏: %q\noutput: %s", secret, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "[REDACTED_SECRET]") || !strings.Contains(output, "[REDACTED_BEARER_TOKEN]") {
|
||||||
|
t.Fatalf("脱敏占位符不完整: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-1
@@ -30,10 +30,45 @@ func PrintFooter() {
|
|||||||
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
|
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrintUsage 打印 Token 消耗与费用估算
|
||||||
|
func PrintUsage(inputTokens, outputTokens int, costUSD float64) {
|
||||||
|
if inputTokens == 0 && outputTokens == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
costStr := ""
|
||||||
|
if costUSD < 0 {
|
||||||
|
costStr = "费用未知"
|
||||||
|
} else if costUSD == 0 {
|
||||||
|
costStr = "本地免费"
|
||||||
|
} else {
|
||||||
|
costStr = fmt.Sprintf("≈ $%.4f", costUSD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n- %sToken: 输入 %s%d%s / 输出 %s%d%s | %s%s%s\n",
|
||||||
|
colorBold,
|
||||||
|
colorCyan, inputTokens, colorGray,
|
||||||
|
colorCyan, outputTokens, colorGray,
|
||||||
|
colorCyan, costStr, colorReset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintUpdateBanner 打印新版本升级提醒卡片
|
||||||
|
func PrintUpdateBanner(currentVer, latestVer, releaseURL string) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(colorYellow + "┌─────────────────────────────────────────────────────────────┐" + colorReset)
|
||||||
|
fmt.Printf(colorYellow+"│"+colorReset+" 🚀 %sgit-air 新版本发布: %s%s%s → %s%s%s\n",
|
||||||
|
colorBold, colorGray, currentVer, colorReset,
|
||||||
|
colorGreen, latestVer, colorReset,
|
||||||
|
)
|
||||||
|
fmt.Println(colorYellow + "│" + colorReset + " 运行以下命令一键升级: " + colorYellow + "│" + colorReset)
|
||||||
|
fmt.Printf(colorYellow+"│"+colorReset+" %sgo install github.com/unclesam-ly/git-air@latest%s "+colorYellow+"│"+colorReset+"\n", colorCyan, colorReset)
|
||||||
|
fmt.Println(colorYellow + "└─────────────────────────────────────────────────────────────┘" + colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
// PrintError 打印错误信息
|
// PrintError 打印错误信息
|
||||||
func PrintError(format string, a ...interface{}) {
|
func PrintError(format string, a ...interface{}) {
|
||||||
msg := fmt.Sprintf(format, a...)
|
msg := fmt.Sprintf(format, a...)
|
||||||
fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
|
_, _ = fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrintSuccess 打印成功信息
|
// PrintSuccess 打印成功信息
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurrentVersion 当前编译版本号
|
||||||
|
const CurrentVersion = "v1.0.7"
|
||||||
|
|
||||||
|
const (
|
||||||
|
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"
|
||||||
|
checkInterval = 24 * time.Hour
|
||||||
|
httpTimeout = 1500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateState struct {
|
||||||
|
LastChecked int64 `json:"last_checked"`
|
||||||
|
LatestVer string `json:"latest_ver"`
|
||||||
|
ReleaseURL string `json:"release_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInfo struct {
|
||||||
|
CurrentVersion string
|
||||||
|
LatestVersion string
|
||||||
|
ReleaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachePath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".git-air")
|
||||||
|
_ = os.MkdirAll(dir, 0755)
|
||||||
|
return filepath.Join(dir, "update_check.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCache() (*UpdateState, error) {
|
||||||
|
path, err := getCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var state UpdateState
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCache(state *UpdateState) {
|
||||||
|
path, err := getCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(state)
|
||||||
|
if err == nil {
|
||||||
|
_ = os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdate 检查是否有新版本(带 24 小时本地缓存与极短超时,静默处理所有异常)
|
||||||
|
func CheckUpdate(force bool) *UpdateInfo {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// 1. 如果不是强制检查,优先读取 24 小时内的本地缓存
|
||||||
|
if !force {
|
||||||
|
if state, err := readCache(); err == nil && state != nil {
|
||||||
|
if now-state.LastChecked < int64(checkInterval.Seconds()) {
|
||||||
|
if IsNewerVersion(state.LatestVer, CurrentVersion) {
|
||||||
|
return &UpdateInfo{
|
||||||
|
CurrentVersion: CurrentVersion,
|
||||||
|
LatestVersion: state.LatestVer,
|
||||||
|
ReleaseURL: state.ReleaseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 发起轻量级 GitHub API 请求
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "git-air-cli")
|
||||||
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: httpTimeout}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil || resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 写入本地缓存
|
||||||
|
writeCache(&UpdateState{
|
||||||
|
LastChecked: now,
|
||||||
|
LatestVer: release.TagName,
|
||||||
|
ReleaseURL: release.HTMLURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 比对版本
|
||||||
|
if IsNewerVersion(release.TagName, CurrentVersion) {
|
||||||
|
return &UpdateInfo{
|
||||||
|
CurrentVersion: CurrentVersion,
|
||||||
|
LatestVersion: release.TagName,
|
||||||
|
ReleaseURL: release.HTMLURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAsync 后台并发静默检查,返回 channel 供主流程结束前快速获取
|
||||||
|
func CheckAsync() <-chan *UpdateInfo {
|
||||||
|
ch := make(chan *UpdateInfo, 1)
|
||||||
|
go func() {
|
||||||
|
info := CheckUpdate(false)
|
||||||
|
ch <- info
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNewerVersion 语义化版本号比对 (如 "v1.0.5" > "v1.0.4")
|
||||||
|
func IsNewerVersion(latest, current string) bool {
|
||||||
|
latestClean := strings.TrimPrefix(strings.TrimSpace(latest), "v")
|
||||||
|
currentClean := strings.TrimPrefix(strings.TrimSpace(current), "v")
|
||||||
|
|
||||||
|
if latestClean == "" || currentClean == "" || latestClean == currentClean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
latestParts := strings.Split(latestClean, ".")
|
||||||
|
currentParts := strings.Split(currentClean, ".")
|
||||||
|
|
||||||
|
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
|
||||||
|
lNum, err1 := strconv.Atoi(latestParts[i])
|
||||||
|
cNum, err2 := strconv.Atoi(currentParts[i])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return latestClean > currentClean
|
||||||
|
}
|
||||||
|
if lNum > cNum {
|
||||||
|
return true
|
||||||
|
} else if lNum < cNum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(latestParts) > len(currentParts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsNewerVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
latest string
|
||||||
|
current string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"v1.0.5", "v1.0.4", true},
|
||||||
|
{"v1.1.0", "v1.0.4", true},
|
||||||
|
{"v2.0.0", "v1.9.9", true},
|
||||||
|
{"v1.0.4", "v1.0.4", false},
|
||||||
|
{"v1.0.3", "v1.0.4", false},
|
||||||
|
{"v1.0.4.1", "v1.0.4", true},
|
||||||
|
{"1.0.5", "1.0.4", true},
|
||||||
|
{"", "v1.0.4", false},
|
||||||
|
{"v1.0.4", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := IsNewerVersion(tt.latest, tt.current)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("IsNewerVersion(%q, %q) = %v, expected %v", tt.latest, tt.current, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user