mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
[feat] 初始化gitea代码AI审核程序
This commit is contained in:
44
.dockerignore
Normal file
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 版本控制
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# 依赖目录
|
||||||
|
node_modules
|
||||||
|
.yarn
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# 构建输出
|
||||||
|
dist
|
||||||
|
lib
|
||||||
|
build
|
||||||
|
|
||||||
|
# 开发/测试文件
|
||||||
|
test*
|
||||||
|
*.test.ts
|
||||||
|
jest.config.js
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# 环境文件
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 其他文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.gz
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,json,yml}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Gitea配置
|
||||||
|
GITEA_API_URL=http://your-gitea-instance.com/api/v1
|
||||||
|
GITEA_ACCESS_TOKEN=your_gitea_access_token
|
||||||
|
|
||||||
|
# OpenAI配置
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
PORT=3000
|
||||||
|
WEBHOOK_SECRET=your_webhook_secret
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 仅复制与构建相关的文件
|
||||||
|
COPY package.json bun.lock tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# 安装依赖并构建
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# 运行阶段
|
||||||
|
FROM oven/bun:1-slim AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 创建非root用户
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 bunjs
|
||||||
|
|
||||||
|
# 仅复制生产所需文件
|
||||||
|
COPY --from=builder --chown=bunjs:nodejs /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=bunjs:nodejs /app/package.json ./
|
||||||
|
COPY --from=builder --chown=bunjs:nodejs /app/bun.lock ./
|
||||||
|
|
||||||
|
# 只安装生产依赖
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER bunjs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 设置健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
|
# 设置默认命令
|
||||||
|
CMD ["bun", "run", "dist/index.js"]
|
||||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# AI Code Review for Gitea
|
||||||
|
|
||||||
|
基于Bun和TypeScript的Gitea代码审查助手,自动为Pull Request提供AI驱动的代码审查。
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
|
||||||
|
- ✅ 自动对Gitea Pull Request进行代码审查
|
||||||
|
- ✅ 使用OpenAI API进行代码分析
|
||||||
|
- ✅ 提供总体代码审查评论
|
||||||
|
- ✅ 支持代码行级别评论
|
||||||
|
- ✅ 安全的Webhook验证
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Bun
|
||||||
|
- TypeScript
|
||||||
|
- Hono (轻量级Web框架)
|
||||||
|
- OpenAI API
|
||||||
|
- Gitea API
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. 克隆仓库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ai-review
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 配置环境变量
|
||||||
|
|
||||||
|
复制.env.example文件为.env并填写必要配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑.env文件,填写Gitea和OpenAI相关配置。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
- `GITEA_API_URL`: Gitea API URL (例如: `http://your-gitea-instance.com/api/v1`)
|
||||||
|
- `GITEA_ACCESS_TOKEN`: Gitea 访问令牌
|
||||||
|
- `OPENAI_BASE_URL`: OpenAI 请求地址
|
||||||
|
- `OPENAI_API_KEY`: OpenAI API密钥
|
||||||
|
- `OPENAI_MODEL`:OpenAI 使用模型
|
||||||
|
- `PORT`: 应用监听端口 (默认: 3000)
|
||||||
|
- `WEBHOOK_SECRET`: Webhook秘钥,用于验证请求来源
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
1. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # 开发模式
|
||||||
|
# 或
|
||||||
|
bun run start # 生产模式
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在Gitea仓库中配置Webhook
|
||||||
|
|
||||||
|
在Gitea仓库设置中添加Webhook:
|
||||||
|
|
||||||
|
- URL: `http://your-server:3000/webhook/gitea`
|
||||||
|
- 内容类型: `application/json`
|
||||||
|
- 秘钥: 设置为与WEBHOOK_SECRET相同的值
|
||||||
|
- 触发事件: 选择"Pull Request"
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
- `bun run dev`: 开发模式运行
|
||||||
|
- `bun run build`: 构建项目
|
||||||
|
- `bun run start`: 生产模式运行
|
||||||
|
- `bun run lint`: 运行代码风格检查
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
198
bun.lock
Normal file
198
bun.lock
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "ai-review",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.4.3",
|
||||||
|
"axios": "^1.8.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"hono": "^4.7.4",
|
||||||
|
"openai": "^4.87.3",
|
||||||
|
"tslint": "^6.1.3",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
|
||||||
|
|
||||||
|
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||||
|
|
||||||
|
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||||
|
|
||||||
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
|
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"axios": ["axios@1.8.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||||
|
|
||||||
|
"builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
|
|
||||||
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
|
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
|
||||||
|
|
||||||
|
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
|
|
||||||
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
|
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
|
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
|
||||||
|
|
||||||
|
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||||
|
|
||||||
|
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||||
|
|
||||||
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
|
||||||
|
|
||||||
|
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||||
|
|
||||||
|
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"openai": ["openai@4.87.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ=="],
|
||||||
|
|
||||||
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|
||||||
|
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||||
|
|
||||||
|
"semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||||
|
|
||||||
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||||
|
|
||||||
|
"tslint": ["tslint@6.1.3", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", "chalk": "^2.3.0", "commander": "^2.12.1", "diff": "^4.0.1", "glob": "^7.1.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", "mkdirp": "^0.5.3", "resolve": "^1.3.2", "semver": "^5.3.0", "tslib": "^1.13.0", "tsutils": "^2.29.0" }, "peerDependencies": { "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" }, "bin": { "tslint": "bin/tslint" } }, "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg=="],
|
||||||
|
|
||||||
|
"tsutils": ["tsutils@2.29.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" } }, "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||||
|
|
||||||
|
"@types/node-fetch/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||||
|
|
||||||
|
"openai/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||||
|
|
||||||
|
"@types/node-fetch/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
|
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
ai-code-review:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: registry.kuiper.com/ai-code-review:latest
|
||||||
|
container_name: ai-code-review
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-review",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI-driven code review for Gitea",
|
||||||
|
"packageManager": "yarn@4.6.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.4.3",
|
||||||
|
"axios": "^1.8.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"hono": "^4.7.4",
|
||||||
|
"openai": "^4.87.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tslint": "^6.1.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"./dist/*"
|
||||||
|
],
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"typings": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"build": "rm -rf dist && tsc",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"start:prod": "bun run dist/index.js",
|
||||||
|
"lint": "tslint -c tslint.json src/**/*.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"code-review",
|
||||||
|
"gitea",
|
||||||
|
"ai",
|
||||||
|
"bun",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
55
src/config/index.ts
Normal file
55
src/config/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 加载环境变量
|
||||||
|
config();
|
||||||
|
|
||||||
|
// 判断是否为开发环境
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||||||
|
|
||||||
|
// 环境变量验证模式
|
||||||
|
const envSchema = z.object({
|
||||||
|
// Gitea配置
|
||||||
|
GITEA_API_URL: z.string().url().default('http://localhost:3000/api/v1'),
|
||||||
|
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
||||||
|
|
||||||
|
// OpenAI配置
|
||||||
|
OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'),
|
||||||
|
OPENAI_API_KEY: z.string().default('test_openai_key'),
|
||||||
|
OPENAI_MODEL: z.string().default('gpt-4o-mini'),
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
PORT: z.string().transform(Number).default('3000'),
|
||||||
|
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理验证结果
|
||||||
|
const envParseResult = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!envParseResult.success) {
|
||||||
|
console.error('❌ 环境变量验证失败:');
|
||||||
|
console.error(envParseResult.error.format());
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
console.warn('⚠️ 使用开发环境默认值');
|
||||||
|
} else {
|
||||||
|
throw new Error('环境变量配置错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出配置
|
||||||
|
export default {
|
||||||
|
gitea: {
|
||||||
|
apiUrl: envParseResult.success ? envParseResult.data.GITEA_API_URL : 'http://localhost:3000/api/v1',
|
||||||
|
accessToken: envParseResult.success ? envParseResult.data.GITEA_ACCESS_TOKEN : 'test_token',
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
baseUrl: envParseResult.success ? envParseResult.data.OPENAI_BASE_URL : 'https://api.openai.com/v1',
|
||||||
|
apiKey: envParseResult.success ? envParseResult.data.OPENAI_API_KEY : 'test_openai_key',
|
||||||
|
model: envParseResult.success ? envParseResult.data.OPENAI_MODEL : 'gpt-4o-mini',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
port: envParseResult.success ? envParseResult.data.PORT : 3000,
|
||||||
|
webhookSecret: envParseResult.success ? envParseResult.data.WEBHOOK_SECRET : 'test_webhook_secret',
|
||||||
|
},
|
||||||
|
};
|
||||||
213
src/controllers/review.ts
Normal file
213
src/controllers/review.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { giteaService } from '../services/gitea';
|
||||||
|
import { aiReviewService } from '../services/ai-review';
|
||||||
|
// import config from '../config';
|
||||||
|
// import * as crypto from 'crypto';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// 判断是否为开发环境
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Webhook请求签名
|
||||||
|
*/
|
||||||
|
// function verifyWebhookSignature(body: string, signature: string): boolean {
|
||||||
|
// // 开发环境下跳过签名验证
|
||||||
|
// if (isDev && !signature) {
|
||||||
|
// logger.warn('开发环境: 跳过Webhook签名验证');
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (!config.app.webhookSecret) return false;
|
||||||
|
|
||||||
|
// const hmac = crypto.createHmac('sha256', config.app.webhookSecret);
|
||||||
|
// hmac.update(body);
|
||||||
|
// const calculatedSignature = `sha256=${hmac.digest('hex')}`;
|
||||||
|
|
||||||
|
// // 如果签名不存在,直接返回false
|
||||||
|
// if (!signature) return false;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// return crypto.timingSafeEqual(
|
||||||
|
// Buffer.from(calculatedSignature),
|
||||||
|
// Buffer.from(signature)
|
||||||
|
// );
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error('签名验证失败', error);
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理Pull Request事件
|
||||||
|
*/
|
||||||
|
export async function handlePullRequestEvent(c: Context): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// 验证Webhook签名
|
||||||
|
// const signature = c.req.header('X-Gitea-Signature') || '';
|
||||||
|
const rawBody = await c.req.text();
|
||||||
|
|
||||||
|
// if (!verifyWebhookSignature(rawBody, signature)) {
|
||||||
|
// logger.error('Webhook签名验证失败');
|
||||||
|
// return c.json({ error: 'Webhook签名验证失败' }, 401);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 解析请求体
|
||||||
|
const body = JSON.parse(rawBody);
|
||||||
|
|
||||||
|
// 仅处理PR打开或更新事件
|
||||||
|
if (
|
||||||
|
body.action !== 'opened' &&
|
||||||
|
body.action !== 'reopened' &&
|
||||||
|
body.action !== 'synchronize' &&
|
||||||
|
body.action !== 'edited'
|
||||||
|
) {
|
||||||
|
return c.json({ status: 'ignored', message: '无需处理的事件类型' }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从事件中提取必要信息
|
||||||
|
const {
|
||||||
|
pull_request: pullRequest,
|
||||||
|
repository: repo
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!pullRequest || !repo) {
|
||||||
|
return c.json({ error: '无效的Webhook数据' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prNumber = pullRequest.number;
|
||||||
|
const owner = repo.owner.login;
|
||||||
|
const repoName = repo.name;
|
||||||
|
|
||||||
|
logger.info(`收到PR事件`, { owner, repo: repoName, prNumber, action: body.action });
|
||||||
|
|
||||||
|
// 开始异步审查流程
|
||||||
|
reviewPullRequest(owner, repoName, prNumber).catch(error => {
|
||||||
|
logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 立即返回以不阻塞Webhook
|
||||||
|
return c.json({ status: 'accepted', message: '代码审查请求已接受' }, 202);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('处理Webhook事件失败:', error);
|
||||||
|
return c.json({ error: '处理Webhook事件失败' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审查Pull Request的代码
|
||||||
|
*/
|
||||||
|
async function reviewPullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`开始审查PR ${owner}/${repo}#${prNumber}`);
|
||||||
|
|
||||||
|
// 如果是开发环境,模拟PR差异和详情
|
||||||
|
let prDetails;
|
||||||
|
let diffContent;
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境中的测试数据
|
||||||
|
logger.info('开发环境: 使用测试数据');
|
||||||
|
prDetails = {
|
||||||
|
id: prNumber,
|
||||||
|
number: prNumber,
|
||||||
|
title: '测试PR',
|
||||||
|
head: {
|
||||||
|
sha: 'abcd1234abcd1234abcd1234abcd1234abcd1234'
|
||||||
|
},
|
||||||
|
base: {
|
||||||
|
repo: {
|
||||||
|
owner: {
|
||||||
|
login: owner
|
||||||
|
},
|
||||||
|
name: repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试用diff内容
|
||||||
|
diffContent = `diff --git a/test.js b/test.js
|
||||||
|
index 1234567..abcdefg 100644
|
||||||
|
--- a/test.js
|
||||||
|
+++ b/test.js
|
||||||
|
@@ -1,5 +1,9 @@
|
||||||
|
function add(a, b) {
|
||||||
|
- return a + b;
|
||||||
|
+ return a + b; // 简单的加法函数
|
||||||
|
}
|
||||||
|
|
||||||
|
-console.log(add(1, 2));
|
||||||
|
+// 不安全的数据处理
|
||||||
|
+function processUserData(data) {
|
||||||
|
+ eval(data); // 这里有安全问题
|
||||||
|
+}
|
||||||
|
+console.log(add(1, 2));`;
|
||||||
|
} else {
|
||||||
|
// 生产环境中从Gitea获取真实数据
|
||||||
|
[prDetails, diffContent] = await Promise.all([
|
||||||
|
giteaService.getPullRequestDetails(owner, repo, prNumber),
|
||||||
|
giteaService.getPullRequestDiff(owner, repo, prNumber)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取commit SHA
|
||||||
|
const commitId = prDetails.head.sha;
|
||||||
|
|
||||||
|
// 使用增强的AI代码审查服务
|
||||||
|
const reviewResult = await aiReviewService.reviewCode(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
diffContent,
|
||||||
|
commitId
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('代码审查结果', {
|
||||||
|
summary: reviewResult.summary.substring(0, 100) + '...',
|
||||||
|
commentCount: reviewResult.lineComments.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加总结评论
|
||||||
|
if (isDev) {
|
||||||
|
logger.info('开发环境: 模拟添加PR评论', {
|
||||||
|
comment: reviewResult.summary
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('生产环境: 添加PR评论', {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
comment: reviewResult.summary
|
||||||
|
});
|
||||||
|
await giteaService.addPullRequestComment(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
`## AI代码审查结果\n\n${reviewResult.summary}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加行级评论
|
||||||
|
if (reviewResult.lineComments.length > 0) {
|
||||||
|
if (isDev) {
|
||||||
|
logger.info('开发环境: 模拟添加行评论', {
|
||||||
|
commentCount: reviewResult.lineComments.length,
|
||||||
|
comments: reviewResult.lineComments
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await giteaService.addLineComments(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
commitId,
|
||||||
|
reviewResult.lineComments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`完成PR ${owner}/${repo}#${prNumber} 的代码审查`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`审查PR失败:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/index.ts
Normal file
23
src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { handlePullRequestEvent } from './controllers/review';
|
||||||
|
import config from './config';
|
||||||
|
|
||||||
|
// 创建Hono应用实例
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// 健康检查路由
|
||||||
|
app.get('/', (c) => {
|
||||||
|
return c.json({ status: 'ok', message: 'AI Code Review 服务运行中' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gitea webhook路由
|
||||||
|
app.post('/webhook/gitea', handlePullRequestEvent);
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
const port = config.app.port;
|
||||||
|
console.log(`⚡️ 服务启动在 http://localhost:${port}`);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port,
|
||||||
|
fetch: app.fetch,
|
||||||
|
};
|
||||||
326
src/services/ai-review.ts
Normal file
326
src/services/ai-review.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import config from '../config';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { giteaService, PullRequestFile } from './gitea';
|
||||||
|
|
||||||
|
// 创建OpenAI客户端
|
||||||
|
const openai = new OpenAI({
|
||||||
|
baseURL: config.openai.baseUrl,
|
||||||
|
apiKey: config.openai.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 代码审查结果接口
|
||||||
|
export interface CodeReviewResult {
|
||||||
|
summary: string;
|
||||||
|
lineComments: LineComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行评论接口
|
||||||
|
export interface LineComment {
|
||||||
|
path: string;
|
||||||
|
line: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审查上下文
|
||||||
|
interface ReviewContext {
|
||||||
|
changedFiles: PullRequestFile[];
|
||||||
|
fileContents: Record<string, string>;
|
||||||
|
diffContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI代码审查服务
|
||||||
|
export const aiReviewService = {
|
||||||
|
/**
|
||||||
|
* 对代码差异进行审查
|
||||||
|
* @param owner 仓库所有者
|
||||||
|
* @param repo 仓库名称
|
||||||
|
* @param prNumber PR编号
|
||||||
|
* @param diffContent 代码差异内容
|
||||||
|
* @param commitSha 提交SHA
|
||||||
|
* @returns 代码审查结果
|
||||||
|
*/
|
||||||
|
async reviewCode(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number,
|
||||||
|
diffContent: string,
|
||||||
|
commitSha: string
|
||||||
|
): Promise<CodeReviewResult> {
|
||||||
|
try {
|
||||||
|
logger.info('开始代码审查', { owner, repo, prNumber });
|
||||||
|
|
||||||
|
// 获取完整的审查上下文
|
||||||
|
const context = await this.getReviewContext(owner, repo, prNumber, diffContent, commitSha);
|
||||||
|
|
||||||
|
// 使用上下文进行总体评价
|
||||||
|
const summary = await this.generateSummary(context);
|
||||||
|
|
||||||
|
// 使用上下文生成行级评论
|
||||||
|
const lineComments = await this.generateLineComments(context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
lineComments,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('AI代码审查失败:', error);
|
||||||
|
throw new Error(`AI代码审查失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的审查上下文
|
||||||
|
*/
|
||||||
|
async getReviewContext(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number,
|
||||||
|
diffContent: string,
|
||||||
|
commitSha: string
|
||||||
|
): Promise<ReviewContext> {
|
||||||
|
try {
|
||||||
|
// 获取PR变更的文件列表
|
||||||
|
const changedFiles = await giteaService.getPullRequestFiles(owner, repo, prNumber);
|
||||||
|
logger.info(`获取到 ${changedFiles.length} 个变更文件`);
|
||||||
|
|
||||||
|
// 获取所有变更文件的完整内容
|
||||||
|
const fileContents = await giteaService.getRelatedFiles(owner, repo, changedFiles, commitSha);
|
||||||
|
logger.info(`获取到 ${Object.keys(fileContents).length} 个文件的完整内容`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
changedFiles,
|
||||||
|
fileContents,
|
||||||
|
diffContent
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('获取审查上下文失败:', error);
|
||||||
|
// 如果获取上下文失败,至少返回diff内容
|
||||||
|
return {
|
||||||
|
changedFiles: [],
|
||||||
|
fileContents: {},
|
||||||
|
diffContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成总体评价
|
||||||
|
* @param context 审查上下文
|
||||||
|
* @returns 总体评价
|
||||||
|
*/
|
||||||
|
async generateSummary(context: ReviewContext): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 准备上下文信息
|
||||||
|
const fileInfo = context.changedFiles.map(file => {
|
||||||
|
return {
|
||||||
|
path: file.filename,
|
||||||
|
status: file.status,
|
||||||
|
additions: file.additions,
|
||||||
|
deletions: file.deletions,
|
||||||
|
content: context.fileContents[file.filename] || '无法获取文件内容'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建更丰富的提示
|
||||||
|
const summaryPrompt = `作为经验丰富的代码审查专家,请对以下代码变更进行深入审查,提供一个全面详细的评价和建议。
|
||||||
|
关注点包括但不限于:代码质量、潜在bug、性能问题、安全问题、最佳实践等。
|
||||||
|
请用中文回复,保持专业简洁。
|
||||||
|
|
||||||
|
==== diff变更内容 ====
|
||||||
|
${context.diffContent}
|
||||||
|
|
||||||
|
==== 变更文件的完整信息 ====
|
||||||
|
${JSON.stringify(fileInfo, null, 2)}
|
||||||
|
|
||||||
|
请根据以上信息,特别是考虑每个文件的完整内容和上下文,提供详细的代码审查评价。`;
|
||||||
|
|
||||||
|
// 获取总体评价
|
||||||
|
const summaryResponse = await openai.chat.completions.create({
|
||||||
|
model: config.openai.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: '你是一个专业的代码审查助手,擅长提供有建设性的代码评审意见。你会查看代码的完整上下文,而不仅仅是变更部分,从而提供更准确的评价。'
|
||||||
|
},
|
||||||
|
{ role: 'user', content: summaryPrompt }
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = summaryResponse.choices[0]?.message.content || '无法生成代码审查摘要';
|
||||||
|
return summary;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('生成总体评价失败:', error);
|
||||||
|
return '由于技术原因,无法生成详细的代码审查评价。';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成行级评论
|
||||||
|
* @param context 审查上下文
|
||||||
|
* @returns 行级评论数组
|
||||||
|
*/
|
||||||
|
async generateLineComments(context: ReviewContext): Promise<LineComment[]> {
|
||||||
|
try {
|
||||||
|
// 解析差异内容,提取文件和变更行
|
||||||
|
const diffFiles = this.parseDiff(context.diffContent);
|
||||||
|
const lineComments: LineComment[] = [];
|
||||||
|
|
||||||
|
// 对每个文件的变更行进行审查
|
||||||
|
for (const file of diffFiles) {
|
||||||
|
// 只对添加的行进行评论
|
||||||
|
const addedLines = file.changes.filter(change => change.type === 'add');
|
||||||
|
if (addedLines.length === 0) continue;
|
||||||
|
|
||||||
|
// 获取文件的完整内容作为上下文
|
||||||
|
const fileContent = context.fileContents[file.path] || '';
|
||||||
|
|
||||||
|
// 构建提示
|
||||||
|
const filePrompt = `分析以下代码文件的新增代码行,找出潜在问题并提供具体行级评论。
|
||||||
|
只对有问题的代码行提供评论,如果代码行没有明显问题则不需要评论。
|
||||||
|
为每个评论提供行号和具体的改进建议。
|
||||||
|
|
||||||
|
文件路径: ${file.path}
|
||||||
|
|
||||||
|
完整文件内容:
|
||||||
|
${fileContent}
|
||||||
|
|
||||||
|
变更部分上下文:
|
||||||
|
${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
|
||||||
|
|
||||||
|
请以JSON格式返回评论,格式如下:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"line": 行号,
|
||||||
|
"comment": "评论内容"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
只返回JSON数组,不要有其他文本。`;
|
||||||
|
|
||||||
|
// 获取行级评论
|
||||||
|
const lineResponse = await openai.chat.completions.create({
|
||||||
|
model: config.openai.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: '你是一个精确的代码审查助手,只对有问题的代码行提供具体评论。你会考虑文件的完整上下文,而不仅仅是变更部分。请以JSON格式返回结果。'
|
||||||
|
},
|
||||||
|
{ role: 'user', content: filePrompt }
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = lineResponse.choices[0]?.message.content;
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析JSON响应
|
||||||
|
const responseObject = JSON.parse(content);
|
||||||
|
const comments = Array.isArray(responseObject) ? responseObject : (responseObject.comments || []);
|
||||||
|
|
||||||
|
// 添加到结果中
|
||||||
|
for (const comment of comments) {
|
||||||
|
if (comment.line && comment.comment) {
|
||||||
|
lineComments.push({
|
||||||
|
path: file.path,
|
||||||
|
line: comment.line,
|
||||||
|
comment: comment.comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError: any) {
|
||||||
|
logger.error(`解析行评论JSON失败: ${parseError.message}`, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lineComments;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('生成行级评论失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析git diff内容
|
||||||
|
* @param diffContent diff内容
|
||||||
|
* @returns 解析后的文件变更信息
|
||||||
|
*/
|
||||||
|
parseDiff(diffContent: string): Array<{
|
||||||
|
path: string;
|
||||||
|
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||||
|
}> {
|
||||||
|
const files: Array<{
|
||||||
|
path: string;
|
||||||
|
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const diffLines = diffContent.split('\n');
|
||||||
|
let currentFile: {
|
||||||
|
path: string;
|
||||||
|
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let lineNumber = 0;
|
||||||
|
let inHunk = false;
|
||||||
|
|
||||||
|
for (const line of diffLines) {
|
||||||
|
// 新文件开始
|
||||||
|
if (line.startsWith('diff --git')) {
|
||||||
|
if (currentFile) {
|
||||||
|
files.push(currentFile);
|
||||||
|
}
|
||||||
|
currentFile = { path: '', changes: [] };
|
||||||
|
inHunk = false;
|
||||||
|
}
|
||||||
|
// 获取文件路径
|
||||||
|
else if (line.startsWith('+++ b/')) {
|
||||||
|
if (currentFile) {
|
||||||
|
currentFile.path = line.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hunk头,记录起始行号
|
||||||
|
else if (line.startsWith('@@')) {
|
||||||
|
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
lineNumber = parseInt(match[1], 10) - 1; // 因为下面会+1
|
||||||
|
inHunk = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解析变更行
|
||||||
|
else if (inHunk && currentFile) {
|
||||||
|
if (line.startsWith('+')) {
|
||||||
|
// 添加的行
|
||||||
|
lineNumber++;
|
||||||
|
currentFile.changes.push({
|
||||||
|
lineNumber,
|
||||||
|
content: line.substring(1),
|
||||||
|
type: 'add'
|
||||||
|
});
|
||||||
|
} else if (line.startsWith(' ')) {
|
||||||
|
// 上下文行
|
||||||
|
lineNumber++;
|
||||||
|
currentFile.changes.push({
|
||||||
|
lineNumber,
|
||||||
|
content: line.substring(1),
|
||||||
|
type: 'context'
|
||||||
|
});
|
||||||
|
} else if (line.startsWith('-')) {
|
||||||
|
// 删除的行,不增加行号
|
||||||
|
// 我们不对删除的行做评论,所以这里不处理
|
||||||
|
} else {
|
||||||
|
// 其他行,比如"No newline at end of file"
|
||||||
|
// 不增加行号,不做特殊处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加最后一个文件
|
||||||
|
if (currentFile) {
|
||||||
|
files.push(currentFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
};
|
||||||
207
src/services/gitea.ts
Normal file
207
src/services/gitea.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import config from '../config';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { LineComment } from './ai-review';
|
||||||
|
|
||||||
|
// 创建API客户端
|
||||||
|
const giteaClient = axios.create({
|
||||||
|
baseURL: config.gitea.apiUrl,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${config.gitea.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gitea服务接口定义
|
||||||
|
export interface GiteaService {
|
||||||
|
// 获取PR的文件差异
|
||||||
|
getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise<string>;
|
||||||
|
|
||||||
|
// 获取PR详情
|
||||||
|
getPullRequestDetails(owner: string, repo: string, prNumber: number): Promise<PullRequestDetails>;
|
||||||
|
|
||||||
|
// 获取PR变更的文件列表
|
||||||
|
getPullRequestFiles(owner: string, repo: string, prNumber: number): Promise<PullRequestFile[]>;
|
||||||
|
|
||||||
|
// 获取文件内容
|
||||||
|
getFileContent(owner: string, repo: string, path: string, ref?: string): Promise<string>;
|
||||||
|
|
||||||
|
// 获取引用的相关文件
|
||||||
|
getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>>;
|
||||||
|
|
||||||
|
// 添加PR评论
|
||||||
|
addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void>;
|
||||||
|
|
||||||
|
// 添加代码行评论
|
||||||
|
addLineComments(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number,
|
||||||
|
commitId: string,
|
||||||
|
comments: LineComment[]
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR详情接口
|
||||||
|
export interface PullRequestDetails {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
head: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
base: {
|
||||||
|
repo: {
|
||||||
|
owner: {
|
||||||
|
login: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR文件接口
|
||||||
|
export interface PullRequestFile {
|
||||||
|
filename: string;
|
||||||
|
status: string; // 'added', 'modified', 'removed'
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
changes: number;
|
||||||
|
raw_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea服务实现
|
||||||
|
export const giteaService: GiteaService = {
|
||||||
|
// 获取PR的差异
|
||||||
|
async getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}.diff`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('获取PR差异失败:', error);
|
||||||
|
throw new Error(`获取PR差异失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取PR详情
|
||||||
|
async getPullRequestDetails(owner: string, repo: string, prNumber: number): Promise<PullRequestDetails> {
|
||||||
|
try {
|
||||||
|
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('获取PR详情失败:', error);
|
||||||
|
throw new Error(`获取PR详情失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取PR变更的文件列表
|
||||||
|
async getPullRequestFiles(owner: string, repo: string, prNumber: number): Promise<PullRequestFile[]> {
|
||||||
|
try {
|
||||||
|
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}/files`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('获取PR文件列表失败:', error);
|
||||||
|
throw new Error(`获取PR文件列表失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取文件内容
|
||||||
|
async getFileContent(owner: string, repo: string, path: string, ref?: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const url = `/repos/${owner}/${repo}/contents/${path}${ref ? `?ref=${ref}` : ''}`;
|
||||||
|
const response = await giteaClient.get(url);
|
||||||
|
|
||||||
|
// Gitea API可能返回base64编码的内容
|
||||||
|
if (response.data.content) {
|
||||||
|
return Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`获取文件内容失败: ${path}`, error);
|
||||||
|
// 文件不存在时不抛出错误,而是返回空字符串
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取引用的相关文件
|
||||||
|
async getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 对每个修改过的文件,获取其完整内容
|
||||||
|
for (const file of files) {
|
||||||
|
// 排除删除的文件
|
||||||
|
if (file.status !== 'removed') {
|
||||||
|
try {
|
||||||
|
const content = await giteaService.getFileContent(owner, repo, file.filename, commitSha);
|
||||||
|
if (content) {
|
||||||
|
result[file.filename] = content;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`无法获取文件内容: ${file.filename}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加PR评论
|
||||||
|
async addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await giteaClient.post(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, { body });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('添加PR评论失败:', error);
|
||||||
|
throw new Error(`添加PR评论失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加代码行评论
|
||||||
|
async addLineComments(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number,
|
||||||
|
commitId: string,
|
||||||
|
comments: LineComment[]
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 如果没有评论,直接返回
|
||||||
|
if (comments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 PR Review API 批量添加评论
|
||||||
|
await giteaClient.post(`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, {
|
||||||
|
event: 'COMMENT',
|
||||||
|
commit_id: commitId,
|
||||||
|
comments: comments.map(comment => ({
|
||||||
|
path: comment.path,
|
||||||
|
body: comment.comment,
|
||||||
|
new_position: comment.line,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`成功添加 ${comments.length} 条代码行评论`);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('添加代码行评论失败:', error);
|
||||||
|
|
||||||
|
// 如果批量添加失败,尝试逐条添加
|
||||||
|
logger.info('尝试逐条添加评论...');
|
||||||
|
try {
|
||||||
|
for (const comment of comments) {
|
||||||
|
await giteaClient.post(`/repos/${owner}/${repo}/pulls/${prNumber}/comments`, {
|
||||||
|
body: comment.comment,
|
||||||
|
commit_id: commitId,
|
||||||
|
path: comment.path,
|
||||||
|
line: comment.line,
|
||||||
|
position: comment.line, // Gitea使用position参数表示行号
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info(`成功逐条添加 ${comments.length} 条评论`);
|
||||||
|
} catch (fallbackError: any) {
|
||||||
|
logger.error('逐条添加评论失败:', fallbackError);
|
||||||
|
throw new Error(`添加代码行评论失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
58
src/utils/logger.ts
Normal file
58
src/utils/logger.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// 简单的日志实用工具
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志级别
|
||||||
|
*/
|
||||||
|
export enum LogLevel {
|
||||||
|
DEBUG = 'DEBUG',
|
||||||
|
INFO = 'INFO',
|
||||||
|
WARN = 'WARN',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间
|
||||||
|
*/
|
||||||
|
function formatTime(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日志消息
|
||||||
|
*/
|
||||||
|
function formatMessage(level: LogLevel, message: string, meta?: any): string {
|
||||||
|
const timestamp = formatTime();
|
||||||
|
|
||||||
|
let formattedMessage = `[${timestamp}] [${level}] ${message}`;
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
try {
|
||||||
|
formattedMessage += ` - ${JSON.stringify(meta)}`;
|
||||||
|
} catch (error) {
|
||||||
|
formattedMessage += ` - ${meta}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志实用工具
|
||||||
|
*/
|
||||||
|
export const logger = {
|
||||||
|
debug(message: string, meta?: any) {
|
||||||
|
console.debug(formatMessage(LogLevel.DEBUG, message, meta));
|
||||||
|
},
|
||||||
|
|
||||||
|
info(message: string, meta?: any) {
|
||||||
|
console.info(formatMessage(LogLevel.INFO, message, meta));
|
||||||
|
},
|
||||||
|
|
||||||
|
warn(message: string, meta?: any) {
|
||||||
|
console.warn(formatMessage(LogLevel.WARN, message, meta));
|
||||||
|
},
|
||||||
|
|
||||||
|
error(message: string, meta?: any) {
|
||||||
|
console.error(formatMessage(LogLevel.ERROR, message, meta));
|
||||||
|
},
|
||||||
|
};
|
||||||
25
test-webhook.json
Normal file
25
test-webhook.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"action": "opened",
|
||||||
|
"pull_request": {
|
||||||
|
"id": 1,
|
||||||
|
"number": 1,
|
||||||
|
"title": "测试PR",
|
||||||
|
"head": {
|
||||||
|
"sha": "abcd1234abcd1234abcd1234abcd1234abcd1234"
|
||||||
|
},
|
||||||
|
"base": {
|
||||||
|
"repo": {
|
||||||
|
"owner": {
|
||||||
|
"login": "test-owner"
|
||||||
|
},
|
||||||
|
"name": "test-repo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"owner": {
|
||||||
|
"login": "test-owner"
|
||||||
|
},
|
||||||
|
"name": "test-repo"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tsconfig.json
Normal file
46
tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": [
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
|
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": false,
|
||||||
|
"removeComments": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"declaration": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
|
||||||
|
"pretty": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"typings/**/*",
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
tslint.json
Normal file
3
tslint.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "tslint:latest"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user