[feat] 初始化gitea代码AI审核程序

This commit is contained in:
jeffusion
2025-03-14 11:20:02 +08:00
commit dc4fb459b4
18 changed files with 1413 additions and 0 deletions

44
.dockerignore Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.env

42
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"extends": "tslint:latest"
}