You've already forked GiteaToFeishuMsg
优化卡片内容与字段合并
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal 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
|
||||
53
README.md
53
README.md
@@ -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
163
gitea_Webhooks_example.md
Normal 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
5377
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,17 +57,74 @@ 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// View button
|
||||
elements.push({
|
||||
tag: 'action',
|
||||
actions: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: {
|
||||
tag: 'plain_text',
|
||||
content: '查看工单',
|
||||
},
|
||||
type: 'primary',
|
||||
url: issueUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const card = {
|
||||
msg_type: 'interactive',
|
||||
@@ -61,61 +135,11 @@ function transformToFeishuCard(giteaEvent) {
|
||||
header: {
|
||||
title: {
|
||||
tag: 'plain_text',
|
||||
content: cardInfo.title,
|
||||
content: headerTitle,
|
||||
},
|
||||
template: cardInfo.color,
|
||||
template: finalColor,
|
||||
},
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'action',
|
||||
actions: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: {
|
||||
tag: 'plain_text',
|
||||
content: '查看工单',
|
||||
},
|
||||
type: 'primary',
|
||||
url: issueUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
elements,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
158
src/transformers/giteaToFeishu.test.js
Normal file
158
src/transformers/giteaToFeishu.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
111
src/webhooks/gitea.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user