mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
feat: 增加pr提醒,支持飞书机器人消息通知
This commit is contained in:
14
.env.example
14
.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
|
||||
|
||||
3
Makefile
3
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
|
||||
|
||||
12
bun.lock
12
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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
138
src/services/feishu.ts
Normal 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();
|
||||
Reference in New Issue
Block a user