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/error.log` – 错误日志
|
||||||
- `logs/combined.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 部署
|
## Docker 部署
|
||||||
|
|
||||||
提供 Dockerfile 便于容器化部署。
|
提供 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');
|
const dotenv = require('dotenv');
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
const logger = require('./utils/logger');
|
const logger = require('./utils/logger');
|
||||||
const giteaWebhookHandler = require('./webhooks/gitea');
|
const giteaWebhookHandler = require('./webhooks/gitea');
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// Middleware
|
// 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 }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
|
|||||||
@@ -18,16 +18,33 @@ function getCardInfo(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract priority from labels
|
* Determine card color based on scope labels
|
||||||
*/
|
*/
|
||||||
function extractPriority(labels) {
|
function getScopeColor(labels) {
|
||||||
if (!labels) return '中';
|
if (!labels) return null;
|
||||||
const priorityLabels = labels.filter(l => l.name.includes('priority:') || l.name.includes('优先级'));
|
// Look for labels containing "scope" and one of the known scope values
|
||||||
if (priorityLabels.length === 0) return '中';
|
const scopeLabels = labels.filter(l => l.name.toLowerCase().includes('scope'));
|
||||||
const label = priorityLabels[0].name.toLowerCase();
|
if (scopeLabels.length === 0) return null;
|
||||||
if (label.includes('high') || label.includes('高')) return '高';
|
const label = scopeLabels[0].name.toLowerCase();
|
||||||
if (label.includes('low') || label.includes('低')) return '低';
|
if (label.includes('非必要任务')) return 'grey';
|
||||||
return '中';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore certain actions if needed
|
// Determine card color: priority is scope label, fallback to action-based color
|
||||||
const ignoredActions = ['labeled', 'unlabeled']; // optional
|
const scopeColor = getScopeColor(issue.labels);
|
||||||
if (ignoredActions.includes(action)) {
|
const cardInfo = getCardInfo(action);
|
||||||
logger.debug(`Action ${action} ignored`);
|
const finalColor = scopeColor || cardInfo.color;
|
||||||
return null;
|
|
||||||
|
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);
|
// Scope label if present (display as priority)
|
||||||
const priority = extractPriority(issue.labels);
|
const scopeLabel = getScopeLabel(issue.labels);
|
||||||
const issueUrl = `${repository.html_url}/issues/${issue.number}`;
|
if (scopeLabel) {
|
||||||
const assigneeText = issue.assignee ? issue.assignee.full_name || issue.assignee.login : '未指派';
|
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 = {
|
const card = {
|
||||||
msg_type: 'interactive',
|
msg_type: 'interactive',
|
||||||
@@ -61,61 +135,11 @@ function transformToFeishuCard(giteaEvent) {
|
|||||||
header: {
|
header: {
|
||||||
title: {
|
title: {
|
||||||
tag: 'plain_text',
|
tag: 'plain_text',
|
||||||
content: cardInfo.title,
|
content: headerTitle,
|
||||||
},
|
},
|
||||||
template: cardInfo.color,
|
template: finalColor,
|
||||||
},
|
},
|
||||||
elements: [
|
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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');
|
logger.warn('Missing signature header');
|
||||||
return false;
|
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);
|
const hmac = crypto.createHmac('sha256', secret);
|
||||||
hmac.update(JSON.stringify(req.body));
|
hmac.update(rawBody);
|
||||||
const rawExpected = hmac.digest('hex'); // 64 hex chars
|
const rawExpected = hmac.digest('hex'); // 64 hex chars
|
||||||
// Extract raw hex from signature (strip algorithm prefix if present)
|
// Extract raw hex from signature (strip algorithm prefix if present)
|
||||||
let rawSignature = signature;
|
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