diff --git a/.gitignore b/.gitignore index deed335..8fb0065 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .env +kubernetes.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3990375 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +NAME = ai-code-review +REGISTRY = docker-hosted.nexus.satfabric.com +SHA1 = $(shell git rev-parse HEAD) +REVISION = $(shell git rev-list --count HEAD) +LABELS = --label SHA1=${SHA1} --label REVISION=${REVISION} +VERSION = $(shell ./auto-ver.sh).${REVISION} + +OS ?= linux +ARCH ?= amd64 + +.PHONY: build +build: + npm run build + +.PHONY: container.build +container.build: + make build + docker buildx build --platform=${OS}/${ARCH} -t ${REGISTRY}/${NAME}:v${VERSION} ${LABELS} -f Dockerfile . + +.PHONY: container.push +container.push: + make build + docker buildx build --platform=${OS}/${ARCH} -t ${REGISTRY}/${NAME}:v${VERSION} ${LABELS} -f Dockerfile . --push + +.PHONY: k8s.yaml +k8s.yaml: + cp ./kubernetes.yaml.template ./kubernetes.yaml + sed -i.bak 's@<%= IMAGE_FROM %>@registry.kuiper.com/${NAME}:v${VERSION}@g' ./kubernetes.yaml + sed -i.bak 's@<%= APP_NAME %>@${NAME}@g' ./kubernetes.yaml + rm -f ./kubernetes.yaml.bak + +.PHONY: help +help: + @echo 'Usage: make [target]' + + @echo 'Available targets:' + @printf " %-25s %s\n" "container." "本地构建(或构建加推送)容器镜像,若不执行TAG参数,则自动生成VERSION字段" + @printf " %-25s %s\n" "k8s.yaml" "生成kubernetes.yaml文件" + @printf " %-25s %s\n" "help" "显示此帮助信息" diff --git a/README.md b/README.md index 50623db..49d6927 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# AI Code Review for Gitea +# Gitea Assistant -基于Bun和TypeScript的Gitea代码审查助手,自动为Pull Request和单个提交提供AI驱动的代码审查。 +Gitea功能增强助手,基于Bun和TypeScript开发,提供AI驱动的代码审查等增强功能。 ## 功能特点 @@ -68,21 +68,15 @@ 2. 在Gitea仓库中配置Webhook - 在Gitea仓库设置中添加两个Webhook: + 在Gitea仓库设置中添加Webhook: - **Pull Request审查webhook**: - - URL: `http://your-server:3000/webhook/gitea/pull_request` + **统一Webhook端点**: + - URL: `http://your-server:3000/webhook/gitea` - 内容类型: `application/json` - 秘钥: 设置为与`WEBHOOK_SECRET`环境变量相同的值 - - 触发事件: 选择"Pull Request" + - 触发事件: 选择"Pull Request"和"Status"事件 - **提交状态审查webhook**: - - URL: `http://your-server:3000/webhook/gitea/status` - - 内容类型: `application/json` - - 秘钥: 设置为与`WEBHOOK_SECRET`环境变量相同的值 - - 触发事件: 选择"Status" - - > 注意: 老端点 `/webhook/gitea` 仍然支持Pull Request审查,但仅作向后兼容使用。 + > 注意: 系统使用统一的webhook端点处理所有事件类型,包括Pull Request和Commit Status事件。 ### Webhook签名验证 @@ -92,6 +86,7 @@ 2. 在Gitea的Webhook配置中,使用相同的字符串作为"秘钥" 3. 每次请求时,系统会验证请求头中的`X-Gitea-Signature` 4. 如果签名验证失败,请求会被拒绝处理 +5. 在开发环境下(`NODE_ENV=development`),如果没有提供签名,系统会跳过验证 验证方法使用SHA-256哈希算法,在处理高负载的情况下这能防止恶意请求并保证请求来源的真实性。 @@ -139,6 +134,6 @@ MIT - `${file.path}` - 当前文件路径 - `${fileContent}` - 文件的完整内容 -- `${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}` - 变更行的上下文 +- ```${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}``` - 变更行的上下文 请确保你的自定义提示返回正确的格式,特别是对于行评论,必须返回有效的JSON数组。 diff --git a/auto-ver.sh b/auto-ver.sh new file mode 100755 index 0000000..fc48252 --- /dev/null +++ b/auto-ver.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# 生成基于日期的版本号函数 +generate_date_version() { + date +"%Y.%-m.%-d" +} + +# 由于可能允许在detached状态下执行脚本,因此我们允许外部指定HEAD的REF +HEAD_REF_NAME=$1 +if [ -z "$HEAD_REF_NAME" ]; then + HEAD_REF_NAME=$(git rev-parse --abbrev-ref HEAD) +fi +if [ "$HEAD_REF_NAME" = "main" ]; then + # 对于main分支,使用日期格式的版本号 + generate_date_version + exit 0 +fi +TAG_RECENT=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null) +if [ -z "$TAG_RECENT" ]; then + # 无法获取到符合semver格式的tag时,使用日期格式的版本号 + generate_date_version + exit 0 +fi +TAG_COMMIT=$(git log $TAG_RECENT --oneline -1 | awk '{print $1}') +TAG_VERSION=$(echo $TAG_RECENT | tr -d "v") + +sub_version_by_type() +{ + type=$1 + field=3 + if [ "$type" = "major" ]; then + field=1 + elif [ "$type" = "minor" ]; then + field=2 + else + field=3 + fi + echo $TAG_VERSION | awk -F '.' -v field=$field '{print $field}' +} + +MAJOR=$(sub_version_by_type major) +MINOR=$(sub_version_by_type minor) +PATCH=$(sub_version_by_type patch) +REVISION_TO_HEAD=$(git rev-list --count $TAG_COMMIT...HEAD) + +echo "$MAJOR.$MINOR.$(($PATCH+$REVISION_TO_HEAD))" diff --git a/docker-compose.yml b/docker-compose.yml index feec743..8b01d17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ version: '3.8' services: - ai-code-review: + gitea-assistant: build: context: . dockerfile: Dockerfile - image: registry.kuiper.com/ai-code-review:latest - container_name: ai-code-review + image: registry.kuiper.com/gitea-assistant:latest + container_name: gitea-assistant restart: unless-stopped ports: - "3000:3000" diff --git a/kubernetes.yaml.template b/kubernetes.yaml.template new file mode 100644 index 0000000..d76a838 --- /dev/null +++ b/kubernetes.yaml.template @@ -0,0 +1,97 @@ +# ConfigMap 用于存储非敏感配置 +apiVersion: v1 +kind: ConfigMap +metadata: + name: gitea-assistant-config +data: + GITEA_API_URL: "http://gitea.kuiper.com/api/v1" + OPENAI_BASE_URL: "https://aihubmix.com/v1" + OPENAI_MODEL: "gpt-4o-mini" + PORT: "3000" + +--- +# Secret 用于存储敏感信息 +apiVersion: v1 +kind: Secret +metadata: + name: gitea-assistant-secrets +type: Opaque +data: + # base64 编码的敏感数据 + GITEA_ACCESS_TOKEN: "{{GITEA_ACCESS_TOKEN}}" + OPENAI_API_KEY: "{{OPENAI_API_KEY}}" + WEBHOOK_SECRET: "{{WEBHOOK_SECRET}}" + +--- +# Deployment 定义应用程序部署 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea-assistant + labels: + app: gitea-assistant +spec: + replicas: 1 + selector: + matchLabels: + app: gitea-assistant + template: + metadata: + labels: + app: gitea-assistant + spec: + containers: + - name: gitea-assistant + image: registry.kuiper.com/gitea-assistant:{{VERSION}} + imagePullPolicy: Always + ports: + - containerPort: 3000 + name: http + resources: + limits: + memory: "512Mi" + cpu: "500m" + requests: + memory: "256Mi" + cpu: "100m" + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + securityContext: + runAsUser: 1001 + runAsGroup: 1001 + allowPrivilegeEscalation: false + envFrom: + - configMapRef: + name: gitea-assistant-config + - secretRef: + name: gitea-assistant-secrets + +--- +# Service 暴露应用程序 +apiVersion: v1 +kind: Service +metadata: + name: gitea-assistant + labels: + app: gitea-assistant +spec: + selector: + app: gitea-assistant + ports: + - port: 3000 + targetPort: 3000 + nodePort: 30300 + name: http + type: NodePort diff --git a/package.json b/package.json index 7b96f5a..c664c64 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "gitea-ai-reviewer", + "name": "gitea-assistant", "version": "1.0.0", - "description": "AI-driven code review for Gitea", + "description": "Gitea功能增强助手,包含AI代码审核功能", "engines": { "bun": ">=1.2.5" }, diff --git a/src/controllers/review.ts b/src/controllers/review.ts index a22259e..2da93be 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -8,6 +8,13 @@ import { logger } from '../utils/logger'; // 判断是否为开发环境 const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; +// Gitea webhook事件类型 +enum GiteaEventType { + PullRequest = 'pull_request', + Status = 'status', + Unknown = 'unknown' +} + /** * 验证Webhook请求签名 */ @@ -48,9 +55,9 @@ function verifyWebhookSignature(body: string, signature: string): boolean { } /** - * 处理Pull Request事件 + * 统一处理Gitea Webhook事件 */ -export async function handlePullRequestEvent(c: Context): Promise { +export async function handleGiteaWebhook(c: Context): Promise { try { // 验证Webhook签名 const signature = c.req.header('X-Gitea-Signature') || ''; @@ -64,137 +71,160 @@ export async function handlePullRequestEvent(c: Context): Promise { // 解析请求体 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 eventType = determineEventType(c, body); + logger.info(`收到Gitea Webhook事件: ${eventType}`); + + // 根据事件类型路由到相应的处理逻辑 + switch (eventType) { + case GiteaEventType.PullRequest: + return await handlePullRequestEvent(c, body); + case GiteaEventType.Status: + return await handleCommitStatusEvent(c, body); + default: + logger.warn(`未支持的Webhook事件类型: ${eventType}`); + return c.json({ status: 'ignored', message: '未支持的Webhook事件类型' }, 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); + logger.error('处理Gitea Webhook事件失败:', error); + return c.json({ error: '处理Gitea Webhook事件失败' }, 500); } } +/** + * 确定Gitea Webhook事件类型 + */ +function determineEventType(c: Context, body: any): GiteaEventType { + // 优先从请求头获取事件类型 + const eventHeader = c.req.header('X-Gitea-Event'); + if (eventHeader) { + if (eventHeader === 'pull_request') return GiteaEventType.PullRequest; + if (eventHeader === 'status') return GiteaEventType.Status; + } + + // 如果没有事件头,尝试从请求体判断 + if (body.pull_request) return GiteaEventType.PullRequest; + if (body.state && (body.sha || body.commit)) return GiteaEventType.Status; + + // 无法确定事件类型 + return GiteaEventType.Unknown; +} + +/** + * 处理Pull Request事件 + */ +async function handlePullRequestEvent(c: Context, body: any): Promise { + // 仅处理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); +} + /** * 处理提交状态更新事件 */ -export async function handleCommitStatusEvent(c: Context): Promise { - try { - // 验证Webhook签名 - const signature = c.req.header('X-Gitea-Signature') || ''; - const rawBody = await c.req.text(); +async function handleCommitStatusEvent(c: Context, body: any): Promise { + // 记录收到的数据,方便调试 + logger.debug('收到提交状态webhook数据', { + state: body.state, + sha: body.sha, + commit_id: body.commit?.id, + context: body.context, + repo: body.repository?.full_name + }); - if (!verifyWebhookSignature(rawBody, signature)) { - logger.error('Webhook签名验证失败'); - return c.json({ error: 'Webhook签名验证失败' }, 401); - } - - const body = JSON.parse(rawBody); - - // 记录收到的数据,方便调试 - logger.debug('收到提交状态webhook数据', { - state: body.state, - sha: body.sha, - commit_id: body.commit?.id, - context: body.context, - repo: body.repository?.full_name - }); - - // 验证请求体中是否包含必要信息 - if (!body.commit || !body.repository || !body.state) { - logger.error('无效的Webhook数据', { body: JSON.stringify(body).substring(0, 500) }); - return c.json({ error: '无效的Webhook数据' }, 400); - } - - // 只处理成功状态的提交 - if (body.state !== 'success') { - return c.json({ status: 'ignored', message: `忽略非成功状态的提交: ${body.state}` }, 200); - } - - // 获取关键信息 - const commitSha = body.sha || body.commit.id; // 兼容不同版本的Gitea - const owner = body.repository.owner.login; - const repoName = body.repository.name; - - // 检查提交是否与PR相关 - let relatedPR: PullRequestDetails | null = null; - try { - relatedPR = await giteaService.getRelatedPullRequest(owner, repoName, commitSha); - if (!relatedPR) { - logger.info(`提交 ${commitSha} 不与任何PR关联,跳过审查`); - return c.json({ status: 'ignored', message: '提交不与任何PR关联' }, 200); - } - logger.info(`提交 ${commitSha} 关联到PR #${relatedPR.number}`); - } catch (error) { - logger.warn(`检查提交 ${commitSha} 是否与PR关联时出错`, error); - // 继续处理,因为有可能API临时错误,但提交仍需审查 - } - - // 提取commit信息 - const commitInfo = { - sha: commitSha, - message: body.commit.message || '', - added: body.commit.added || [], - removed: body.commit.removed || [], - modified: body.commit.modified || [] - }; - - logger.info(`收到提交状态更新事件`, { - owner, - repo: repoName, - commitSha, - state: body.state, - relatedPR: relatedPR?.number || 'unknown', - added: commitInfo.added.length, - modified: commitInfo.modified.length, - removed: commitInfo.removed.length - }); - - // 如果没有文件变更信息,则忽略 - if (commitInfo.added.length === 0 && commitInfo.modified.length === 0 && commitInfo.removed.length === 0) { - logger.warn('提交没有文件变更信息,忽略审查', { commitSha }); - return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200); - } - - // 开始异步审查流程,传入关联的PR信息 - reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch(error => { - logger.error(`审查提交 ${owner}/${repoName}@${commitSha} 失败:`, error); - }); - - // 立即返回以不阻塞Webhook - return c.json({ status: 'accepted', message: '提交代码审查请求已接受' }, 202); - } catch (error) { - logger.error('处理提交状态Webhook事件失败:', error); - return c.json({ error: '处理提交状态Webhook事件失败' }, 500); + // 验证请求体中是否包含必要信息 + if (!body.commit || !body.repository || !body.state) { + logger.error('无效的Webhook数据', { body: JSON.stringify(body).substring(0, 500) }); + return c.json({ error: '无效的Webhook数据' }, 400); } + + // 只处理成功状态的提交 + if (body.state !== 'success') { + return c.json({ status: 'ignored', message: `忽略非成功状态的提交: ${body.state}` }, 200); + } + + // 获取关键信息 + const commitSha = body.sha || body.commit.id; // 兼容不同版本的Gitea + const owner = body.repository.owner.login; + const repoName = body.repository.name; + + // 检查提交是否与PR相关 + let relatedPR: PullRequestDetails | null = null; + try { + relatedPR = await giteaService.getRelatedPullRequest(owner, repoName, commitSha); + if (!relatedPR) { + logger.info(`提交 ${commitSha} 不与任何PR关联,跳过审查`); + return c.json({ status: 'ignored', message: '提交不与任何PR关联' }, 200); + } + logger.info(`提交 ${commitSha} 关联到PR #${relatedPR.number}`); + } catch (error) { + logger.warn(`检查提交 ${commitSha} 是否与PR关联时出错`, error); + // 继续处理,因为有可能API临时错误,但提交仍需审查 + } + + // 提取commit信息 + const commitInfo = { + sha: commitSha, + message: body.commit.message || '', + added: body.commit.added || [], + removed: body.commit.removed || [], + modified: body.commit.modified || [] + }; + + logger.info(`收到提交状态更新事件`, { + owner, + repo: repoName, + commitSha, + state: body.state, + relatedPR: relatedPR?.number || 'unknown', + added: commitInfo.added.length, + modified: commitInfo.modified.length, + removed: commitInfo.removed.length + }); + + // 如果没有文件变更信息,则忽略 + if (commitInfo.added.length === 0 && commitInfo.modified.length === 0 && commitInfo.removed.length === 0) { + logger.warn('提交没有文件变更信息,忽略审查', { commitSha }); + return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200); + } + + // 开始异步审查流程,传入关联的PR信息 + reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch(error => { + logger.error(`审查提交 ${owner}/${repoName}@${commitSha} 失败:`, error); + }); + + // 立即返回以不阻塞Webhook + return c.json({ status: 'accepted', message: '提交代码审查请求已接受' }, 202); } /** diff --git a/src/index.ts b/src/index.ts index 7cf04c1..b0d89cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { handlePullRequestEvent, handleCommitStatusEvent } from './controllers/review'; +import { handleGiteaWebhook } from './controllers/review'; import config from './config'; // 创建Hono应用实例 @@ -12,13 +12,11 @@ app.get('/', (c) => { return c.json({ status: 'ok', message: 'AI Code Review 服务运行中', - version: '1.1.0', + version: '2.0.0', webhookSecurityEnabled: webhookSecretConfigured, configuration: { webhookEndpoints: { - pullRequest: '/webhook/gitea/pull_request', - commitStatus: '/webhook/gitea/status', - legacy: '/webhook/gitea (仅支持Pull Request事件)' + unified: '/webhook/gitea (支持Pull Request和Commit Status事件)' }, signature: webhookSecretConfigured ? '签名验证已启用 (使用X-Gitea-Signature头)' @@ -27,14 +25,8 @@ app.get('/', (c) => { }); }); -// Gitea webhook路由 - 处理PR事件 -app.post('/webhook/gitea/pull_request', handlePullRequestEvent); - -// Gitea webhook路由 - 处理提交状态更新事件 -app.post('/webhook/gitea/status', handleCommitStatusEvent); - -// 向后兼容的路由(将保留一段时间) -app.post('/webhook/gitea', handlePullRequestEvent); +// 统一的Gitea webhook路由 - 处理所有事件类型 +app.post('/webhook/gitea', handleGiteaWebhook); // 启动服务器 const port = config.app.port;