From dc4fb459b403fe4f0f87127ea9af83d3f5854b49 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Fri, 14 Mar 2025 11:20:02 +0800 Subject: [PATCH] =?UTF-8?q?[feat]=20=E5=88=9D=E5=A7=8B=E5=8C=96gitea?= =?UTF-8?q?=E4=BB=A3=E7=A0=81AI=E5=AE=A1=E6=A0=B8=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 44 +++++ .editorconfig | 10 ++ .env.example | 12 ++ .gitignore | 3 + Dockerfile | 42 +++++ README.md | 84 ++++++++++ bun.lock | 198 +++++++++++++++++++++++ docker-compose.yml | 24 +++ package.json | 40 +++++ src/config/index.ts | 55 +++++++ src/controllers/review.ts | 213 +++++++++++++++++++++++++ src/index.ts | 23 +++ src/services/ai-review.ts | 326 ++++++++++++++++++++++++++++++++++++++ src/services/gitea.ts | 207 ++++++++++++++++++++++++ src/utils/logger.ts | 58 +++++++ test-webhook.json | 25 +++ tsconfig.json | 46 ++++++ tslint.json | 3 + 18 files changed, 1413 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bun.lock create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/config/index.ts create mode 100644 src/controllers/review.ts create mode 100644 src/index.ts create mode 100644 src/services/ai-review.ts create mode 100644 src/services/gitea.ts create mode 100644 src/utils/logger.ts create mode 100644 test-webhook.json create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1724cc0 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1ed453a --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44c9f6b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bda9ebe --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d33a2d --- /dev/null +++ b/README.md @@ -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 + 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 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..485fd71 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..feec743 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..2154873 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..5ee8914 --- /dev/null +++ b/src/config/index.ts @@ -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', + }, +}; diff --git a/src/controllers/review.ts b/src/controllers/review.ts new file mode 100644 index 0000000..cb4fc29 --- /dev/null +++ b/src/controllers/review.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5dfd8eb --- /dev/null +++ b/src/index.ts @@ -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, +}; diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts new file mode 100644 index 0000000..ed5ba44 --- /dev/null +++ b/src/services/ai-review.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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; + } +}; diff --git a/src/services/gitea.ts b/src/services/gitea.ts new file mode 100644 index 0000000..b0a00f6 --- /dev/null +++ b/src/services/gitea.ts @@ -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; + + // 获取PR详情 + getPullRequestDetails(owner: string, repo: string, prNumber: number): Promise; + + // 获取PR变更的文件列表 + getPullRequestFiles(owner: string, repo: string, prNumber: number): Promise; + + // 获取文件内容 + getFileContent(owner: string, repo: string, path: string, ref?: string): Promise; + + // 获取引用的相关文件 + getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise>; + + // 添加PR评论 + addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise; + + // 添加代码行评论 + addLineComments( + owner: string, + repo: string, + prNumber: number, + commitId: string, + comments: LineComment[] + ): Promise; +} + +// 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 { + 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 { + 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 { + 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 { + 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> { + const result: Record = {}; + + // 对每个修改过的文件,获取其完整内容 + 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 { + 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 { + 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}`); + } + } + }, +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..1d39edc --- /dev/null +++ b/src/utils/logger.ts @@ -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)); + }, +}; diff --git a/test-webhook.json b/test-webhook.json new file mode 100644 index 0000000..538e598 --- /dev/null +++ b/test-webhook.json @@ -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" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4d4703c --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..dfe4c4a --- /dev/null +++ b/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": "tslint:latest" +}