From e9d4f6776c5750367785757224f6aa03c77972aa Mon Sep 17 00:00:00 2001 From: jeffusion Date: Wed, 23 Apr 2025 13:38:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0pr=E6=8F=90=E9=86=92,?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A3=9E=E4=B9=A6=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 ++-- Makefile | 3 +- bun.lock | 12 ++- kubernetes.yaml.template | 32 ++++---- package.json | 2 + src/config/index.ts | 8 ++ src/controllers/review.ts | 160 +++++++++++++++++++++++++++++--------- src/services/feishu.ts | 138 ++++++++++++++++++++++++++++++++ 8 files changed, 307 insertions(+), 62 deletions(-) create mode 100644 src/services/feishu.ts diff --git a/.env.example b/.env.example index 61bc22d..7d1feb2 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,17 @@ # Gitea配置 -GITEA_API_URL=http://your-gitea-instance.com/api/v1 -GITEA_ACCESS_TOKEN=your_gitea_access_token +GITEA_API_URL=http://localhost:3000/api/v1 +GITEA_ACCESS_TOKEN=your_gitea_token # OpenAI配置 OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_API_KEY=your_openai_api_key +OPENAI_API_KEY=your_openai_key OPENAI_MODEL=gpt-4o-mini +CUSTOM_SUMMARY_PROMPT=your_custom_prompt +CUSTOM_LINE_COMMENT_PROMPT=your_custom_prompt -# 自定义AI Prompt (可选) -# CUSTOM_SUMMARY_PROMPT=你的自定义总结Prompt,变量会自动注入 -# CUSTOM_LINE_COMMENT_PROMPT=你的自定义行评论Prompt,变量会自动注入 +# 飞书配置 +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_webhook_token +FEISHU_WEBHOOK_SECRET=your_webhook_secret # 应用配置 PORT=3000 diff --git a/Makefile b/Makefile index 3990375..b00c216 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -NAME = ai-code-review +NAME = gitea-assistant REGISTRY = docker-hosted.nexus.satfabric.com SHA1 = $(shell git rev-parse HEAD) REVISION = $(shell git rev-list --count HEAD) @@ -27,6 +27,7 @@ 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 + sed -i.bak 's@<%= VERSION %>@${VERSION}@g' ./kubernetes.yaml rm -f ./kubernetes.yaml.bak .PHONY: help diff --git a/bun.lock b/bun.lock index 485fd71..786c774 100644 --- a/bun.lock +++ b/bun.lock @@ -8,13 +8,15 @@ "axios": "^1.8.3", "dotenv": "^16.4.7", "hono": "^4.7.4", + "lodash-es": "^4.17.21", "openai": "^4.87.3", - "tslint": "^6.1.3", - "typescript": "^5.8.2", "zod": "^3.24.2", }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^22.13.10", + "tslint": "^6.1.3", + "typescript": "^5.8.2", }, }, }, @@ -25,6 +27,10 @@ "@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="], + "@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="], + + "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], + "@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=="], @@ -125,6 +131,8 @@ "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=="], + "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/kubernetes.yaml.template b/kubernetes.yaml.template index d76a838..37510f6 100644 --- a/kubernetes.yaml.template +++ b/kubernetes.yaml.template @@ -2,47 +2,49 @@ apiVersion: v1 kind: ConfigMap metadata: - name: gitea-assistant-config + name: <%= APP_NAME %>-config data: - GITEA_API_URL: "http://gitea.kuiper.com/api/v1" - OPENAI_BASE_URL: "https://aihubmix.com/v1" + GITEA_API_URL: "http://your-gitea-addr/api/v1" + OPENAI_BASE_URL: "{{OPENAI_COMPATIBILITY_URL}}" OPENAI_MODEL: "gpt-4o-mini" PORT: "3000" + FEISHU_WEBHOOK_URL: "{{FEISHU_WEBHOOK_URL}}" --- # Secret 用于存储敏感信息 apiVersion: v1 kind: Secret metadata: - name: gitea-assistant-secrets + name: <%= APP_NAME %>-secrets type: Opaque data: # base64 编码的敏感数据 GITEA_ACCESS_TOKEN: "{{GITEA_ACCESS_TOKEN}}" OPENAI_API_KEY: "{{OPENAI_API_KEY}}" WEBHOOK_SECRET: "{{WEBHOOK_SECRET}}" + FEISHU_WEBHOOK_SECRET: "{{FEISHU_WEBHOOK_SECRET}}" --- # Deployment 定义应用程序部署 apiVersion: apps/v1 kind: Deployment metadata: - name: gitea-assistant + name: <%= APP_NAME %> labels: - app: gitea-assistant + app: <%= APP_NAME %> spec: replicas: 1 selector: matchLabels: - app: gitea-assistant + app: <%= APP_NAME %> template: metadata: labels: - app: gitea-assistant + app: <%= APP_NAME %> spec: containers: - - name: gitea-assistant - image: registry.kuiper.com/gitea-assistant:{{VERSION}} + - name: <%= APP_NAME %> + image: <%= IMAGE_FROM %> imagePullPolicy: Always ports: - containerPort: 3000 @@ -74,21 +76,21 @@ spec: allowPrivilegeEscalation: false envFrom: - configMapRef: - name: gitea-assistant-config + name: <%= APP_NAME %>-config - secretRef: - name: gitea-assistant-secrets + name: <%= APP_NAME %>-secrets --- # Service 暴露应用程序 apiVersion: v1 kind: Service metadata: - name: gitea-assistant + name: <%= APP_NAME %> labels: - app: gitea-assistant + app: <%= APP_NAME %> spec: selector: - app: gitea-assistant + app: <%= APP_NAME %> ports: - port: 3000 targetPort: 3000 diff --git a/package.json b/package.json index c664c64..1d2f8f9 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "axios": "^1.8.3", "dotenv": "^16.4.7", "hono": "^4.7.4", + "lodash-es": "^4.17.21", "openai": "^4.87.3", "zod": "^3.24.2" }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^22.13.10", "tslint": "^6.1.3", "typescript": "^5.8.2" diff --git a/src/config/index.ts b/src/config/index.ts index 941b240..135478c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -20,6 +20,10 @@ const envSchema = z.object({ CUSTOM_SUMMARY_PROMPT: z.string().optional(), CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(), + // 飞书配置 + FEISHU_WEBHOOK_URL: z.string().url(), + FEISHU_WEBHOOK_SECRET: z.string().optional(), + // 应用配置 PORT: z.string().transform(Number).default('3000'), WEBHOOK_SECRET: z.string().default('test_webhook_secret'), @@ -52,6 +56,10 @@ export default { customSummaryPrompt: envParseResult.success ? envParseResult.data.CUSTOM_SUMMARY_PROMPT : undefined, customLineCommentPrompt: envParseResult.success ? envParseResult.data.CUSTOM_LINE_COMMENT_PROMPT : undefined, }, + feishu: { + webhookUrl: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_URL : '', + webhookSecret: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_SECRET : '', + }, 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 index 2da93be..f77dcb8 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -1,6 +1,8 @@ import { Context } from 'hono'; +import { map } from 'lodash-es' import { giteaService, PullRequestFile, PullRequestDetails } from '../services/gitea'; import { aiReviewService } from '../services/ai-review'; +import { feishuService } from '../services/feishu'; import config from '../config'; import * as crypto from 'crypto'; import { logger } from '../utils/logger'; @@ -12,6 +14,7 @@ const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; enum GiteaEventType { PullRequest = 'pull_request', Status = 'status', + Issue = 'issues', Unknown = 'unknown' } @@ -54,43 +57,6 @@ function verifyWebhookSignature(body: string, signature: string): boolean { } } -/** - * 统一处理Gitea Webhook事件 - */ -export async function handleGiteaWebhook(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); - - // 确定事件类型 - 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); - } - } catch (error) { - logger.error('处理Gitea Webhook事件失败:', error); - return c.json({ error: '处理Gitea Webhook事件失败' }, 500); - } -} - /** * 确定Gitea Webhook事件类型 */ @@ -100,11 +66,13 @@ function determineEventType(c: Context, body: any): GiteaEventType { if (eventHeader) { if (eventHeader === 'pull_request') return GiteaEventType.PullRequest; if (eventHeader === 'status') return GiteaEventType.Status; + if (eventHeader === 'issues') return GiteaEventType.Issue; } // 如果没有事件头,尝试从请求体判断 if (body.pull_request) return GiteaEventType.PullRequest; if (body.state && (body.sha || body.commit)) return GiteaEventType.Status; + if (body.issue) return GiteaEventType.Issue; // 无法确定事件类型 return GiteaEventType.Unknown; @@ -119,7 +87,8 @@ async function handlePullRequestEvent(c: Context, body: any): Promise body.action !== 'opened' && body.action !== 'reopened' && body.action !== 'synchronize' && - body.action !== 'edited' + body.action !== 'edited' && + body.action !== 'review_requested' ) { return c.json({ status: 'ignored', message: '无需处理的事件类型' }, 200); } @@ -137,9 +106,41 @@ async function handlePullRequestEvent(c: Context, body: any): Promise const prNumber = pullRequest.number; const owner = repo.owner.login; const repoName = repo.name; + const prTitle = pullRequest.title; + const prUrl = pullRequest.html_url; logger.info(`收到PR事件`, { owner, repo: repoName, prNumber, action: body.action }); + // 处理PR审阅者通知 + try { + // 获取PR的审阅者列表 + const reviewerUsernames = map(pullRequest.requested_reviewers, reviewer => reviewer.full_name || reviewer.login); + + // 记录审阅者信息 + if (reviewerUsernames.length > 0) { + logger.info(`PR有指定审阅者`, { + prNumber, + reviewers: reviewerUsernames.join(',') + }); + } + + // 处理PR创建事件,如果有审阅者,则通知 + if (body.action === 'opened' && reviewerUsernames.length > 0) { + await feishuService.sendPrCreatedNotification(prTitle, prUrl, reviewerUsernames); + } + + // 处理审阅者指派事件 + if (body.action === 'review_requested' && body.requested_reviewer) { + const newReviewerUsername = body.requested_reviewer.full_name || body.requested_reviewer.login; + if (newReviewerUsername) { + await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [newReviewerUsername]); + } + } + } catch (error) { + logger.error(`处理PR审阅者通知失败:`, error); + // 继续执行代码审查流程,不因通知失败而中断 + } + // 开始异步审查流程 reviewPullRequest(owner, repoName, prNumber).catch(error => { logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error); @@ -227,6 +228,50 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise return c.json({ status: 'accepted', message: '提交代码审查请求已接受' }, 202); } +/** + * 处理工单事件 + */ +async function handleIssueEvent(c: Context, body: any): Promise { + const { action, issue, repository } = body; + + if (!issue || !repository) { + return c.json({ error: '无效的Webhook数据' }, 400); + } + + const issueTitle = issue.title; + const issueUrl = issue.html_url; + const creatorUsername = issue.user.full_name || issue.user.login; + const assigneeUsernames = map(issue.assignees, assignee => assignee.full_name || assignee.login); + + logger.info(`收到工单事件`, { + action, + issueTitle, + issueUrl, + creatorUsername, + assigneeUsernames: assigneeUsernames.join(',') + }); + + try { + // 处理工单创建事件 + if (action === 'opened' && assigneeUsernames.length > 0) { + await feishuService.sendIssueCreatedNotification(issueTitle, issueUrl, assigneeUsernames); + } + // 处理工单关闭事件 + else if (action === 'closed' && creatorUsername) { + await feishuService.sendIssueClosedNotification(issueTitle, issueUrl, creatorUsername); + } + // 处理工单指派事件 + else if (action === 'assigned' && assigneeUsernames.length > 0) { + await feishuService.sendIssueAssignedNotification(issueTitle, issueUrl, assigneeUsernames); + } + } catch (error) { + logger.error('处理工单事件失败:', error); + return c.json({ error: '处理工单事件失败' }, 500); + } + + return c.json({ status: 'success', message: '工单事件处理完成' }, 200); +} + /** * 审查Pull Request的代码 */ @@ -481,3 +526,42 @@ async function reviewCommit( throw error; } } + +/** + * 统一处理Gitea Webhook事件 + */ +export async function handleGiteaWebhook(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); + + // 确定事件类型 + 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); + case GiteaEventType.Issue: + return await handleIssueEvent(c, body); + default: + logger.warn(`未支持的Webhook事件类型: ${eventType}`); + return c.json({ status: 'ignored', message: '未支持的Webhook事件类型' }, 200); + } + } catch (error) { + logger.error('处理Gitea Webhook事件失败:', error); + return c.json({ error: '处理Gitea Webhook事件失败' }, 500); + } +} diff --git a/src/services/feishu.ts b/src/services/feishu.ts new file mode 100644 index 0000000..4ba9a63 --- /dev/null +++ b/src/services/feishu.ts @@ -0,0 +1,138 @@ +import { logger } from '../utils/logger'; +import config from '../config'; +import * as crypto from 'crypto'; + +export class FeishuService { + private webhookUrl: string; + private webhookSecret?: string; + + constructor() { + this.webhookUrl = config.feishu.webhookUrl; + this.webhookSecret = config.feishu.webhookSecret; + + if (!this.webhookUrl) { + logger.error('飞书webhook URL未配置'); + throw new Error('飞书webhook URL未配置'); + } + + if (!this.webhookSecret) { + logger.warn('飞书webhook密钥未配置,签名验证将被禁用'); + } + } + + /** + * 生成飞书消息签名 + * @param timestamp 时间戳 + * @param secret 密钥 + */ + private generateSign(timestamp: string, secret: string): string { + const stringToSign = `${timestamp}\n${secret}`; + const hmac = crypto.createHmac('sha256', stringToSign); + return hmac.digest('base64'); + } + + /** + * 发送飞书消息 + * @param content 消息内容 + * @param usernames 需要@的用户名列表 + */ + async sendMessage(content: string, usernames: string[] = []): Promise { + try { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const message: any = { + msg_type: 'text', + content: { + text: content + } + }; + + // 如果需要@用户,添加at信息 + if (usernames.length > 0) { + message.content.text += '\n'; + usernames.forEach(username => { + message.content.text += `@${username} `; + }); + } + + // 如果配置了密钥,添加签名 + if (this.webhookSecret) { + message.timestamp = timestamp; + message.sign = this.generateSign(timestamp, this.webhookSecret); + } + + const response = await fetch(this.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + throw new Error(`发送飞书消息失败: ${response.statusText}`); + } + + logger.info('飞书消息发送成功'); + } catch (error) { + logger.error('发送飞书消息失败:', error); + throw error; + } + } + + /** + * 发送工单创建通知 + * @param issueTitle 工单标题 + * @param issueUrl 工单链接 + * @param assigneeUsernames 被指派人用户名列表 + */ + async sendIssueCreatedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise { + const content = `📝 新工单已创建\n标题: ${issueTitle}\n链接: ${issueUrl}`; + await this.sendMessage(content, assigneeUsernames); + } + + /** + * 发送工单关闭通知 + * @param issueTitle 工单标题 + * @param issueUrl 工单链接 + * @param creatorUsername 创建者用户名 + */ + async sendIssueClosedNotification(issueTitle: string, issueUrl: string, creatorUsername: string): Promise { + const content = `✅ 工单已关闭\n标题: ${issueTitle}\n链接: ${issueUrl}`; + await this.sendMessage(content, [creatorUsername]); + } + + /** + * 发送工单指派通知 + * @param issueTitle 工单标题 + * @param issueUrl 工单链接 + * @param assigneeUsernames 被指派人用户名列表 + */ + async sendIssueAssignedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise { + const content = `👤 工单已指派给你\n标题: ${issueTitle}\n链接: ${issueUrl}`; + await this.sendMessage(content, assigneeUsernames); + } + + /** + * 发送PR创建通知给审阅者 + * @param prTitle PR标题 + * @param prUrl PR链接 + * @param reviewerUsernames 审阅者用户名列表 + */ + async sendPrCreatedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise { + const content = `🔄 新PR等待你审阅\n标题: ${prTitle}\n链接: ${prUrl}`; + await this.sendMessage(content, reviewerUsernames); + } + + /** + * 发送PR指派审阅者通知 + * @param prTitle PR标题 + * @param prUrl PR链接 + * @param reviewerUsernames 审阅者用户名列表 + */ + async sendPrReviewerAssignedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise { + const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`; + await this.sendMessage(content, reviewerUsernames); + } +} + +export const feishuService = new FeishuService();