feat: 增加pr提醒,支持飞书机器人消息通知

This commit is contained in:
jeffusion
2025-04-23 13:38:26 +08:00
parent b8e5c5eb41
commit e9d4f6776c
8 changed files with 307 additions and 62 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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=="],

View File

@@ -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

View File

@@ -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"

View File

@@ -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',

View File

@@ -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<Response> {
try {
// 验证Webhook签名
const signature = c.req.header('X-Gitea-Signature') || '';
const rawBody = await c.req.text();
if (!verifyWebhookSignature(rawBody, signature)) {
logger.error('Webhook签名验证失败');
return c.json({ error: 'Webhook签名验证失败' }, 401);
}
// 解析请求体
const body = JSON.parse(rawBody);
// 确定事件类型
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<Response>
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<Response>
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<Response>
return c.json({ status: 'accepted', message: '提交代码审查请求已接受' }, 202);
}
/**
* 处理工单事件
*/
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
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<Response> {
try {
// 验证Webhook签名
const signature = c.req.header('X-Gitea-Signature') || '';
const rawBody = await c.req.text();
if (!verifyWebhookSignature(rawBody, signature)) {
logger.error('Webhook签名验证失败');
return c.json({ error: 'Webhook签名验证失败' }, 401);
}
// 解析请求体
const body = JSON.parse(rawBody);
// 确定事件类型
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);
}
}

138
src/services/feishu.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`;
await this.sendMessage(content, reviewerUsernames);
}
}
export const feishuService = new FeishuService();