优化卡片内容与字段合并

This commit is contained in:
2025-12-02 17:13:38 +08:00
parent d92b558b6e
commit 3939e61691
9 changed files with 6021 additions and 76 deletions

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# Dependencies
node_modules/
# Logs
logs/
*.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
Thumbs.db

View File

@@ -110,6 +110,59 @@ npm start
- `logs/error.log` 错误日志
- `logs/combined.log` 所有日志
## 自定义消息格式
如果你希望修改飞书卡片的样式、字段或映射关系,可以编辑 `src/transformers/giteaToFeishu.js` 文件。该文件导出了 `transformToFeishuCard` 函数,负责将 Gitea 事件转换为飞书卡片。
### 主要可配置部分
1. **动作映射**`getCardInfo(action)` 函数定义了每个 Gitea 动作对应的卡片标题和颜色模板。你可以修改 `mapping` 对象来更改标题或颜色。
2. **优先级提取**`extractPriority(labels)` 函数根据标签确定优先级。你可以调整逻辑以适应你的标签命名规范。
3. **卡片元素**`transformToFeishuCard` 函数中的 `card.elements` 数组定义了卡片显示的字段。你可以添加、删除或修改元素来改变卡片内容。
### 标签颜色映射
服务支持根据工单标签自动调整卡片颜色。例如,如果工单带有 `scope:非必要任务``scope:普通任务``scope:紧急任务``scope:重要任务` 等标签,卡片头部颜色会相应变化:
| 标签 | 颜色 | 说明 |
|------|------|------|
| `scope:非必要任务` | 灰色 (`grey`) | 低优先级任务 |
| `scope:普通任务` | 蓝色 (`blue`) | 默认任务 |
| `scope:紧急任务` | 红色 (`red`) | 需要立即处理 |
| `scope:重要任务` | 橙色 (`orange`) | 重要但不紧急 |
颜色映射逻辑位于 `getScopeColor(labels)` 函数中。你可以修改该函数以支持更多标签或更改颜色。
此外,卡片会显示一个“范围”字段,展示匹配的标签名称。
### 示例:添加新字段
假设你想显示工单的创建时间,可以在 `elements` 数组中添加一个新的 `div` 元素:
```javascript
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**创建时间**${new Date(issue.created_at).toLocaleString()}`
}
}
```
### 高级自定义
如果你需要更灵活的自定义(例如根据不同的仓库使用不同的模板),可以考虑以下方案:
- 创建新的转换器模块,并在 `src/webhooks/gitea.js` 中替换导入。
- 使用配置文件(如 `config/cardTemplates.json`)来定义模板,并在转换器中读取。
### 注意事项
- 飞书卡片支持的元素和样式请参考[飞书开放平台文档](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/overview)。
- 修改后请重启服务以使更改生效。
## Docker 部署
提供 Dockerfile 便于容器化部署。

163
gitea_Webhooks_example.md Normal file
View File

@@ -0,0 +1,163 @@
Webhooks
Gitea supports webhooks for repository events. This can be configured in the settings page /:username/:reponame/settings/hooks by a repository admin. Webhooks can also be configured on a per-organization and whole system basis. All event pushes are POST requests. The methods currently supported are:
Gitea (can also be a GET request)
Gogs
Slack
Discord
Dingtalk
Telegram
Microsoft Teams
Feishu
Wechatwork
Packagist
Event information
warning
The secret field in the payload is deprecated as of Gitea 1.13.0 and will be removed in 1.14.0: https://github.com/go-gitea/gitea/issues/11755
The following is an example of event information that will be sent by Gitea to a Payload URL:
X-GitHub-Delivery: f6266f16-1bf3-46a5-9ea4-602e06ead473
X-GitHub-Event: push
X-Gogs-Delivery: f6266f16-1bf3-46a5-9ea4-602e06ead473
X-Gogs-Event: push
X-Gitea-Delivery: f6266f16-1bf3-46a5-9ea4-602e06ead473
X-Gitea-Event: push
{
"secret": "3gEsCfjlV2ugRwgpU#w1*WaW*wa4NXgGmpCfkbG3",
"ref": "refs/heads/develop",
"before": "28e1879d029cb852e4844d9c718537df08844e03",
"after": "bffeb74224043ba2feb48d137756c8a9331c449a",
"compare_url": "http://localhost:3000/gitea/webhooks/compare/28e1879d029cb852e4844d9c718537df08844e03...bffeb74224043ba2feb48d137756c8a9331c449a",
"commits": [
{
"id": "bffeb74224043ba2feb48d137756c8a9331c449a",
"message": "Webhooks Yay!",
"url": "http://localhost:3000/gitea/webhooks/commit/bffeb74224043ba2feb48d137756c8a9331c449a",
"author": {
"name": "Gitea",
"email": "someone@gitea.io",
"username": "gitea"
},
"committer": {
"name": "Gitea",
"email": "someone@gitea.io",
"username": "gitea"
},
"timestamp": "2017-03-13T13:52:11-04:00"
}
],
"repository": {
"id": 140,
"owner": {
"id": 1,
"login": "gitea",
"full_name": "Gitea",
"email": "someone@gitea.io",
"avatar_url": "https://localhost:3000/avatars/1",
"username": "gitea"
},
"name": "webhooks",
"full_name": "gitea/webhooks",
"description": "",
"private": false,
"fork": false,
"html_url": "http://localhost:3000/gitea/webhooks",
"ssh_url": "ssh://gitea@localhost:2222/gitea/webhooks.git",
"clone_url": "http://localhost:3000/gitea/webhooks.git",
"website": "",
"stars_count": 0,
"forks_count": 1,
"watchers_count": 1,
"open_issues_count": 7,
"default_branch": "master",
"created_at": "2017-02-26T04:29:06-05:00",
"updated_at": "2017-03-13T13:51:58-04:00"
},
"pusher": {
"id": 1,
"login": "gitea",
"full_name": "Gitea",
"email": "someone@gitea.io",
"avatar_url": "https://localhost:3000/avatars/1",
"username": "gitea"
},
"sender": {
"id": 1,
"login": "gitea",
"full_name": "Gitea",
"email": "someone@gitea.io",
"avatar_url": "https://localhost:3000/avatars/1",
"username": "gitea"
}
}
Example
This is an example of how to use webhooks to run a php script upon push requests to the repository. In your repository Settings, under Webhooks, Setup a Gitea webhook as follows:
Target URL: http://mydomain.com/webhook.php
HTTP Method: POST
POST Content Type: application/json
Secret: 123
Trigger On: Push Events
Active: Checked
Now on your server create the php file webhook.php
<?php
$secret_key = '123';
// check for POST request
if ($_SERVER['REQUEST_METHOD'] != 'POST') {
error_log('FAILED - not POST - '. $_SERVER['REQUEST_METHOD']);
exit();
}
// get content type
$content_type = isset($_SERVER['CONTENT_TYPE']) ? strtolower(trim($_SERVER['CONTENT_TYPE'])) : '';
if ($content_type != 'application/json') {
error_log('FAILED - not application/json - '. $content_type);
exit();
}
// get payload
$payload = trim(file_get_contents("php://input"));
if (empty($payload)) {
error_log('FAILED - no payload');
exit();
}
// get header signature
$header_signature = isset($_SERVER['HTTP_X_GITEA_SIGNATURE']) ? $_SERVER['HTTP_X_GITEA_SIGNATURE'] : '';
if (empty($header_signature)) {
error_log('FAILED - header signature missing');
exit();
}
// calculate payload signature
$payload_signature = hash_hmac('sha256', $payload, $secret_key, false);
// check payload signature against header signature
if ($header_signature !== $payload_signature) {
error_log('FAILED - payload signature');
exit();
}
// convert json to array
$decoded = json_decode($payload, true);
// check for json decode errors
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('FAILED - json decode - '. json_last_error());
exit();
}
// success, do something
There is a Test Delivery button in the webhook settings that allows to test the configuration as well as a list of the most Recent Deliveries.

5377
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,20 @@
const express = require('express');
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const logger = require('./utils/logger');
const giteaWebhookHandler = require('./webhooks/gitea');
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.json({
verify: (req, res, buf, encoding) => {
// Store raw body for signature verification
req.rawBody = buf;
}
}));
app.use(express.urlencoded({ extended: true }));
// Health check

View File

@@ -18,16 +18,33 @@ function getCardInfo(action) {
}
/**
* Extract priority from labels
* Determine card color based on scope labels
*/
function extractPriority(labels) {
if (!labels) return '中';
const priorityLabels = labels.filter(l => l.name.includes('priority:') || l.name.includes('优先级'));
if (priorityLabels.length === 0) return '中';
const label = priorityLabels[0].name.toLowerCase();
if (label.includes('high') || label.includes('高')) return '高';
if (label.includes('low') || label.includes('低')) return '';
return '';
function getScopeColor(labels) {
if (!labels) return null;
// Look for labels containing "scope" and one of the known scope values
const scopeLabels = labels.filter(l => l.name.toLowerCase().includes('scope'));
if (scopeLabels.length === 0) return null;
const label = scopeLabels[0].name.toLowerCase();
if (label.includes('非必要任务')) return 'grey';
if (label.includes('普通任务')) return 'blue';
if (label.includes('紧急任务')) return 'red';
if (label.includes('重要任务')) return 'orange';
return null;
}
/**
* Extract scope label display name
*/
function getScopeLabel(labels) {
if (!labels) return null;
const scopeLabels = labels.filter(l => l.name.toLowerCase().includes('scope'));
if (scopeLabels.length === 0) return null;
const label = scopeLabels[0].name;
// Extract the part after colon or slash for display
const parts = label.split(/[:/]/);
const display = parts.length > 1 ? parts[1].trim() : label;
return display;
}
/**
@@ -40,68 +57,61 @@ function transformToFeishuCard(giteaEvent) {
return null;
}
// Ignore certain actions if needed
const ignoredActions = ['labeled', 'unlabeled']; // optional
if (ignoredActions.includes(action)) {
logger.debug(`Action ${action} ignored`);
return null;
// Determine card color: priority is scope label, fallback to action-based color
const scopeColor = getScopeColor(issue.labels);
const cardInfo = getCardInfo(action);
const finalColor = scopeColor || cardInfo.color;
const issueUrl = `${repository.html_url}/issues/${issue.number}`;
// Build header title with action prefix
const actionPrefix = {
opened: '创建',
closed: '关闭',
reopened: '重新打开',
edited: '更新',
assigned: '指派',
unassigned: '取消指派',
labeled: '标签',
unlabeled: '移除标签',
}[action] || '更新';
const headerTitle = `${actionPrefix}】#${issue.number} ${issue.title}`;
// Build elements array (compact)
const elements = [];
// Description if present
if (issue.body && issue.body.trim() !== '') {
// Truncate long description
const maxLength = 200;
let description = issue.body.trim();
if (description.length > maxLength) {
description = description.substring(0, maxLength) + '...';
}
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**描述**${description}`,
},
});
}
const cardInfo = getCardInfo(action);
const priority = extractPriority(issue.labels);
const issueUrl = `${repository.html_url}/issues/${issue.number}`;
const assigneeText = issue.assignee ? issue.assignee.full_name || issue.assignee.login : '未指派';
// Scope label if present (display as priority)
const scopeLabel = getScopeLabel(issue.labels);
if (scopeLabel) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**优先级**${scopeLabel}`,
},
});
}
const card = {
msg_type: 'interactive',
card: {
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: 'plain_text',
content: cardInfo.title,
},
template: cardInfo.color,
},
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**工单标题**${issue.title}`,
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**状态**${issue.state === 'open' ? '打开' : '关闭'}`,
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**创建者**${issue.user.full_name || issue.user.login}`,
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**优先级**${priority}`,
},
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**指派给**${assigneeText}`,
},
},
{
// View button
elements.push({
tag: 'action',
actions: [
{
@@ -114,8 +124,22 @@ function transformToFeishuCard(giteaEvent) {
url: issueUrl,
},
],
});
const card = {
msg_type: 'interactive',
card: {
config: {
wide_screen_mode: true,
},
],
header: {
title: {
tag: 'plain_text',
content: headerTitle,
},
template: finalColor,
},
elements,
},
};

View File

@@ -0,0 +1,158 @@
const { transformToFeishuCard } = require('./giteaToFeishu');
describe('Gitea to Feishu transformer', () => {
const baseEvent = {
action: 'opened',
issue: {
id: 123,
number: 45,
title: '测试工单',
body: '这是一个测试工单',
state: 'open',
created_at: '2025-12-02T05:00:00Z',
user: {
id: 1,
login: 'testuser',
full_name: '测试用户'
},
assignee: null,
labels: [],
milestone: null
},
repository: {
id: 1,
name: 'test-repo',
full_name: 'org/test-repo',
html_url: 'https://gitea.example.com/org/test-repo'
},
sender: {
id: 1,
login: 'testuser'
}
};
it('should generate card for opened action', () => {
const card = transformToFeishuCard(baseEvent);
expect(card).not.toBeNull();
expect(card.msg_type).toBe('interactive');
expect(card.card.header.title.content).toBe('【创建】#45 测试工单');
expect(card.card.header.template).toBe('blue'); // default color
// Should contain description element
const descElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('描述')
);
expect(descElement).toBeDefined();
expect(descElement.text.content).toBe('**描述**:这是一个测试工单');
// Should NOT contain priority element because no scope label
const priorityElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('优先级')
);
expect(priorityElement).toBeUndefined();
// Should contain action button
const actionElement = card.card.elements.find(el => el.tag === 'action');
expect(actionElement).toBeDefined();
});
it('should apply scope color for non-essential task', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
labels: [{ name: 'scope/非必要任务' }]
}
};
const card = transformToFeishuCard(event);
expect(card.card.header.template).toBe('grey');
});
it('should apply scope color for normal task', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
labels: [{ name: 'scope:普通任务' }]
}
};
const card = transformToFeishuCard(event);
expect(card.card.header.template).toBe('blue');
});
it('should apply scope color for urgent task', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
labels: [{ name: 'scope/紧急任务' }]
}
};
const card = transformToFeishuCard(event);
expect(card.card.header.template).toBe('red');
});
it('should apply scope color for important task', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
labels: [{ name: 'scope/重要任务' }]
}
};
const card = transformToFeishuCard(event);
expect(card.card.header.template).toBe('orange');
});
it('should include scope label in elements as priority', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
labels: [{ name: 'scope/普通任务' }]
}
};
const card = transformToFeishuCard(event);
const priorityElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('优先级')
);
expect(priorityElement).toBeDefined();
expect(priorityElement.text.content).toBe('**优先级**:普通任务');
});
it('should not include priority element if no scope label', () => {
const card = transformToFeishuCard(baseEvent);
const priorityElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('优先级')
);
expect(priorityElement).toBeUndefined();
});
it('should truncate long description', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
body: 'a'.repeat(300)
}
};
const card = transformToFeishuCard(event);
const descElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('描述')
);
expect(descElement).toBeDefined();
expect(descElement.text.content).toMatch(/^\*\*描述\*\*a{200}\.\.\.$/);
});
it('should handle missing description', () => {
const event = {
...baseEvent,
issue: {
...baseEvent.issue,
body: ''
}
};
const card = transformToFeishuCard(event);
const descElement = card.card.elements.find(el =>
el.text && el.text.content && el.text.content.includes('描述')
);
expect(descElement).toBeUndefined();
});
});

View File

@@ -13,8 +13,10 @@ function verifySignature(req, secret) {
logger.warn('Missing signature header');
return false;
}
// Use raw body if available (provided by middleware), otherwise fallback to JSON.stringify
const rawBody = req.rawBody || Buffer.from(JSON.stringify(req.body));
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(req.body));
hmac.update(rawBody);
const rawExpected = hmac.digest('hex'); // 64 hex chars
// Extract raw hex from signature (strip algorithm prefix if present)
let rawSignature = signature;

111
src/webhooks/gitea.test.js Normal file
View File

@@ -0,0 +1,111 @@
const request = require('supertest');
const express = require('express');
const crypto = require('crypto');
// Mock the feishu client before requiring giteaWebhookHandler
jest.mock('../clients/feishu', () => ({
sendToFeishu: jest.fn().mockResolvedValue({ code: 0, msg: 'success' })
}));
const giteaWebhookHandler = require('./gitea');
describe('Gitea webhook handler', () => {
let app;
const secret = 'test_secret';
const sampleEvent = {
action: 'opened',
issue: {
id: 123,
number: 45,
title: '测试工单',
body: '这是一个测试工单',
state: 'open',
created_at: '2025-12-02T05:00:00Z',
user: {
id: 1,
login: 'testuser',
full_name: '测试用户'
},
assignee: null,
labels: [],
milestone: null
},
repository: {
id: 1,
name: 'test-repo',
full_name: 'org/test-repo',
html_url: 'https://gitea.example.com/org/test-repo'
},
sender: {
id: 1,
login: 'testuser'
}
};
beforeAll(() => {
process.env.GITEA_WEBHOOK_SECRET = secret;
process.env.FEISHU_WEBHOOK_URL = 'https://example.com'; // dummy
});
beforeEach(() => {
app = express();
app.use(express.json({
verify: (req, res, buf, encoding) => {
req.rawBody = buf;
}
}));
app.post('/webhook/gitea', giteaWebhookHandler);
});
afterAll(() => {
delete process.env.GITEA_WEBHOOK_SECRET;
delete process.env.FEISHU_WEBHOOK_URL;
});
it('should accept a valid signature', async () => {
const payload = JSON.stringify(sampleEvent);
const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const header = `sha256=${signature}`;
const response = await request(app)
.post('/webhook/gitea')
.set('Content-Type', 'application/json')
.set('X-Gitea-Signature', header)
.send(sampleEvent);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Webhook processed successfully');
});
it('should reject an invalid signature', async () => {
const payload = JSON.stringify(sampleEvent);
const signature = crypto.createHmac('sha256', 'wrong_secret').update(payload).digest('hex');
const header = `sha256=${signature}`;
const response = await request(app)
.post('/webhook/gitea')
.set('Content-Type', 'application/json')
.set('X-Gitea-Signature', header)
.send(sampleEvent);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid signature');
});
it('should process labeled action', async () => {
const labeledEvent = { ...sampleEvent, action: 'labeled' };
const payload = JSON.stringify(labeledEvent);
const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const header = `sha256=${signature}`;
const response = await request(app)
.post('/webhook/gitea')
.set('Content-Type', 'application/json')
.set('X-Gitea-Signature', header)
.send(labeledEvent);
// The handler should process labeled action (transformToFeishuCard returns a card)
expect(response.status).toBe(200);
expect(response.body.message).toBe('Webhook processed successfully');
});
});