commit dc4fb459b403fe4f0f87127ea9af83d3f5854b49 Author: jeffusion Date: Fri Mar 14 11:20:02 2025 +0800 [feat] 初始化gitea代码AI审核程序 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" +}