@bangdao-ai/zentao-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/bug_config.example.json +7 -0
- package/index.js +124 -0
- package/package.json +35 -0
- package/requirements.txt +6 -0
- package/src/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/src/__pycache__/zentao_nexus.cpython-313.pyc +0 -0
- package/src/mcp_server.py +345 -0
- package/src/zentao_nexus.py +673 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# 禅道 MCP 工具
|
|
2
|
+
|
|
3
|
+
这是一个基于 Python 的 MCP(Model Context Protocol)工具,用于连接禅道Bug管理系统。支持通过 npm/npx 直接使用。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- ✅ 创建Bug(支持截图上传)
|
|
8
|
+
- ✅ 创建测试用例
|
|
9
|
+
- ✅ 批量从CSV文件创建测试用例
|
|
10
|
+
- ✅ 上传图片到Bug附件
|
|
11
|
+
- ✅ 支持配置文件(支持JSON注释)
|
|
12
|
+
- ✅ 自动格式化步骤为HTML
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
通过 npm/npx 使用(推荐):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx -y @bangdao-ai/zentao-mcp@latest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 配置
|
|
23
|
+
|
|
24
|
+
### 在 Cursor 中配置
|
|
25
|
+
|
|
26
|
+
在 Cursor 的 MCP 配置文件中添加:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"zentao": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": [
|
|
34
|
+
"-y",
|
|
35
|
+
"@bangdao-ai/zentao-mcp@latest"
|
|
36
|
+
],
|
|
37
|
+
"env": {
|
|
38
|
+
"ZENTAO_PRODUCT_ID": "365",
|
|
39
|
+
"ZENTAO_OPENED_BY": "69610",
|
|
40
|
+
"ZENTAO_KEYWORDS": "AI",
|
|
41
|
+
"ZENTAO_TITLE_PREFIX": ""
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**配置说明**:
|
|
49
|
+
- `ZENTAO_PRODUCT_ID`(必需):产品ID
|
|
50
|
+
- `ZENTAO_OPENED_BY`(必需):创建人和指派人ID(同一个人)
|
|
51
|
+
- `ZENTAO_KEYWORDS`(可选):关键词,默认为空
|
|
52
|
+
- `ZENTAO_TITLE_PREFIX`(可选):标题前缀,默认为空
|
|
53
|
+
|
|
54
|
+
### 兜底配置(可选)
|
|
55
|
+
|
|
56
|
+
如果未在环境变量中配置,可以使用配置文件 `bug_config.json`(参考 `bug_config.example.json`)。配置文件路径可通过环境变量 `ZENTAO_CONFIG_PATH` 指定。
|
|
57
|
+
|
|
58
|
+
## MCP 工具
|
|
59
|
+
|
|
60
|
+
### 1. create_bug
|
|
61
|
+
|
|
62
|
+
创建Bug到禅道系统。
|
|
63
|
+
|
|
64
|
+
**参数**:
|
|
65
|
+
- `title` (必需): Bug标题
|
|
66
|
+
- `steps` (必需): 重现步骤(支持换行,会自动转换为HTML格式)
|
|
67
|
+
- `product_id` (可选): 产品ID
|
|
68
|
+
- `opened_by` (可选): 创建人和指派人
|
|
69
|
+
- `screenshot_path` (可选): 截图文件路径
|
|
70
|
+
- `severity` (可选): 严重程度(默认3)
|
|
71
|
+
- `pri` (可选): 优先级(默认3)
|
|
72
|
+
- `type` (可选): Bug类型(默认codeissue)
|
|
73
|
+
- `environment` (可选): 环境(默认测试环境)
|
|
74
|
+
- `story` (可选): 相关需求id
|
|
75
|
+
- `task` (可选): 相关任务id
|
|
76
|
+
- `mailto` (可选): 抄送人账号
|
|
77
|
+
- `keywords` (可选): 关键词
|
|
78
|
+
|
|
79
|
+
### 2. upload_image_to_bug
|
|
80
|
+
|
|
81
|
+
上传图片到指定Bug的附件。
|
|
82
|
+
|
|
83
|
+
**参数**:
|
|
84
|
+
- `bug_id` (必需): Bug ID
|
|
85
|
+
- `image_path` (必需): 图片文件路径
|
|
86
|
+
- `custom_filename` (可选): 自定义文件名
|
|
87
|
+
|
|
88
|
+
### 3. create_case
|
|
89
|
+
|
|
90
|
+
创建测试用例到禅道系统。
|
|
91
|
+
|
|
92
|
+
**参数**:
|
|
93
|
+
- `product` (必需): 产品ID
|
|
94
|
+
- `type` (必需): 用例类型(interface 或 feature)
|
|
95
|
+
- `title` (必需): 用例名称
|
|
96
|
+
- `opened_by` (必需): 创建人
|
|
97
|
+
- `module` (可选): 模块ID(默认0)
|
|
98
|
+
- `stage` (可选): 阶段(默认intergrate)
|
|
99
|
+
- `precondition` (可选): 前置条件
|
|
100
|
+
- `keywords` (可选): 关键词
|
|
101
|
+
- `steps` (可选): 测试步骤数组
|
|
102
|
+
- `expects` (可选): 预期结果数组
|
|
103
|
+
|
|
104
|
+
### 4. create_cases_from_csv
|
|
105
|
+
|
|
106
|
+
从CSV文件批量创建测试用例。
|
|
107
|
+
|
|
108
|
+
**参数**:
|
|
109
|
+
- `csv_file_path` (必需): CSV文件路径
|
|
110
|
+
- `case_type` (可选): 用例类型(interface、feature、接口测试、功能测试,会自动识别)
|
|
111
|
+
- `product_id` (可选): 产品ID
|
|
112
|
+
- `opened_by` (可选): 创建人
|
|
113
|
+
- `keywords` (可选): 关键词
|
|
114
|
+
|
|
115
|
+
**CSV格式**:
|
|
116
|
+
- 接口测试用例需要包含:`用例名称`、`接口地址`、`请求方法`、`请求体`、`断言`、`预期结果`
|
|
117
|
+
- 功能测试用例需要包含:`用例名称`、`预期结果`
|
|
118
|
+
|
|
119
|
+
### 5. submit_csv_cases_to_zentao
|
|
120
|
+
|
|
121
|
+
将CSV文件中的所有测试用例提交到禅道(会自动备份原文件)。
|
|
122
|
+
|
|
123
|
+
**参数**:
|
|
124
|
+
- `csv_file_path` (必需): CSV文件路径
|
|
125
|
+
- `case_type` (可选): 用例类型(interface、feature、接口测试、功能测试,会自动识别)
|
|
126
|
+
- `product_id` (可选): 产品ID
|
|
127
|
+
- `opened_by` (可选): 创建人
|
|
128
|
+
- `keywords` (可选): 关键词
|
|
129
|
+
|
|
130
|
+
### 6. get_config
|
|
131
|
+
|
|
132
|
+
获取当前配置信息。
|
|
133
|
+
|
|
134
|
+
## 项目结构
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
禅道mcp/
|
|
138
|
+
├── index.js # npm 入口文件
|
|
139
|
+
├── package.json # npm 包配置
|
|
140
|
+
├── src/
|
|
141
|
+
│ ├── mcp_server.py # MCP服务器主文件
|
|
142
|
+
│ └── zentao_nexus.py # 禅道工具类
|
|
143
|
+
├── requirements.txt # Python 依赖
|
|
144
|
+
├── README.md # 项目说明
|
|
145
|
+
├── PUBLISH.md # 发布指南
|
|
146
|
+
└── bug_config.example.json # 兜底配置示例
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## 发布
|
|
150
|
+
|
|
151
|
+
参考 [PUBLISH.md](PUBLISH.md) 了解如何发布到 npm。
|
|
152
|
+
|
|
153
|
+
## 许可证
|
|
154
|
+
|
|
155
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 禅道 MCP 服务器入口
|
|
4
|
+
* 通过 npm/npx 调用 Python MCP 服务器
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
|
|
11
|
+
// 获取 Python 可执行文件路径
|
|
12
|
+
function findPython() {
|
|
13
|
+
const pythonCommands = ['python3', 'python'];
|
|
14
|
+
|
|
15
|
+
for (const cmd of pythonCommands) {
|
|
16
|
+
try {
|
|
17
|
+
const result = require('child_process').execSync(`which ${cmd}`, { encoding: 'utf-8' }).trim();
|
|
18
|
+
if (result) {
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
} catch (e) {
|
|
22
|
+
// 继续尝试下一个
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error('未找到 Python 3,请确保已安装 Python 3.8+');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 获取脚本路径
|
|
30
|
+
function getScriptPath() {
|
|
31
|
+
// 获取当前包的目录
|
|
32
|
+
const packageDir = __dirname;
|
|
33
|
+
const scriptPath = path.join(packageDir, 'src', 'mcp_server.py');
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(scriptPath)) {
|
|
36
|
+
// 尝试相对路径(开发环境)
|
|
37
|
+
const devPath = path.join(packageDir, '..', 'src', 'mcp_server.py');
|
|
38
|
+
if (fs.existsSync(devPath)) {
|
|
39
|
+
return devPath;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`找不到 MCP 服务器脚本: ${scriptPath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return scriptPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 检查 Python 依赖
|
|
48
|
+
function checkPythonDependencies() {
|
|
49
|
+
const requirementsPath = path.join(__dirname, 'requirements.txt');
|
|
50
|
+
|
|
51
|
+
if (fs.existsSync(requirementsPath)) {
|
|
52
|
+
// 可以在这里添加依赖检查逻辑
|
|
53
|
+
// 例如:尝试导入模块,如果失败则提示用户安装
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 主函数
|
|
58
|
+
function main() {
|
|
59
|
+
try {
|
|
60
|
+
const python = findPython();
|
|
61
|
+
const scriptPath = getScriptPath();
|
|
62
|
+
|
|
63
|
+
// 检查 Python 依赖(可选)
|
|
64
|
+
checkPythonDependencies();
|
|
65
|
+
|
|
66
|
+
// 启动 Python MCP 服务器
|
|
67
|
+
// 注意:MCP 服务器通过 stdio 通信,不会输出到控制台
|
|
68
|
+
// 这是正常行为,服务器会等待 MCP 协议消息
|
|
69
|
+
const child = spawn(python, [scriptPath], {
|
|
70
|
+
stdio: 'inherit',
|
|
71
|
+
env: process.env
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 处理退出
|
|
75
|
+
child.on('exit', (code) => {
|
|
76
|
+
if (code !== 0 && code !== null) {
|
|
77
|
+
// 非正常退出,可能有问题
|
|
78
|
+
process.exit(code);
|
|
79
|
+
} else {
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 处理错误
|
|
85
|
+
child.on('error', (err) => {
|
|
86
|
+
console.error('启动 MCP 服务器失败:', err.message);
|
|
87
|
+
console.error('请确保:');
|
|
88
|
+
console.error('1. Python 3.8+ 已安装');
|
|
89
|
+
console.error('2. 已安装依赖: pip install -r requirements.txt');
|
|
90
|
+
console.error('3. 脚本路径正确:', scriptPath);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 处理信号
|
|
95
|
+
process.on('SIGINT', () => {
|
|
96
|
+
if (child && !child.killed) {
|
|
97
|
+
child.kill('SIGINT');
|
|
98
|
+
}
|
|
99
|
+
process.exit(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
process.on('SIGTERM', () => {
|
|
103
|
+
if (child && !child.killed) {
|
|
104
|
+
child.kill('SIGTERM');
|
|
105
|
+
}
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('错误:', error.message);
|
|
111
|
+
console.error('\n故障排查:');
|
|
112
|
+
console.error('1. 检查 Python 是否安装: python3 --version');
|
|
113
|
+
console.error('2. 检查脚本是否存在: ls -la src/mcp_server.py');
|
|
114
|
+
console.error('3. 检查依赖是否安装: pip3 list | grep mcp');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 如果直接运行此文件
|
|
120
|
+
if (require.main === module) {
|
|
121
|
+
main();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { main };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bangdao-ai/zentao-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "禅道Bug管理系统MCP工具",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zentao-mcp": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"zentao",
|
|
15
|
+
"bug-management",
|
|
16
|
+
"禅道"
|
|
17
|
+
],
|
|
18
|
+
"author": "bangdao-ai",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"src/",
|
|
23
|
+
"requirements.txt",
|
|
24
|
+
"bug_config.example.json",
|
|
25
|
+
"README.md",
|
|
26
|
+
"package.json"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=14.0.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/bangdao-ai/zentao-mcp.git"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/requirements.txt
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
禅道 MCP 服务器
|
|
5
|
+
基于 Python 的 MCP(Model Context Protocol)服务器实现
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
from mcp.server import Server
|
|
16
|
+
from mcp.server.stdio import stdio_server
|
|
17
|
+
from mcp.types import Tool, TextContent
|
|
18
|
+
|
|
19
|
+
from zentao_nexus import ZenTaoNexus
|
|
20
|
+
|
|
21
|
+
# 加载 .env 文件
|
|
22
|
+
load_dotenv()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ZenTaoMCPServer:
|
|
26
|
+
"""禅道 MCP 服务器"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.server = Server("zentao-mcp-server")
|
|
30
|
+
self.nexus: Optional[ZenTaoNexus] = None
|
|
31
|
+
self.setup_handlers()
|
|
32
|
+
|
|
33
|
+
def setup_handlers(self):
|
|
34
|
+
"""设置请求处理器"""
|
|
35
|
+
|
|
36
|
+
@self.server.list_tools()
|
|
37
|
+
async def list_tools() -> list[Tool]:
|
|
38
|
+
"""列出可用工具"""
|
|
39
|
+
return [
|
|
40
|
+
Tool(
|
|
41
|
+
name="create_bug",
|
|
42
|
+
description="创建Bug到禅道系统。支持上传截图。",
|
|
43
|
+
inputSchema={
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"title": {"type": "string", "description": "Bug标题"},
|
|
47
|
+
"steps": {"type": "string", "description": "重现步骤(支持换行,会自动转换为HTML格式)"},
|
|
48
|
+
"product_id": {"type": "string", "description": "产品ID(可选,默认使用配置文件中的值)"},
|
|
49
|
+
"opened_by": {"type": "string", "description": "创建人和指派人(可选,默认使用配置文件中的值)"},
|
|
50
|
+
"screenshot_path": {"type": "string", "description": "截图文件路径(可选)"},
|
|
51
|
+
"severity": {"type": "string", "description": "严重程度(可选,默认3)"},
|
|
52
|
+
"pri": {"type": "string", "description": "优先级(可选,默认3)"},
|
|
53
|
+
"type": {"type": "string", "description": "Bug类型(可选,默认codeissue)"},
|
|
54
|
+
"environment": {"type": "string", "description": "环境(可选,默认测试环境)"},
|
|
55
|
+
"story": {"type": "string", "description": "相关需求id(可选)"},
|
|
56
|
+
"task": {"type": "string", "description": "相关任务id(可选)"},
|
|
57
|
+
"mailto": {"type": "string", "description": "抄送人账号(可选)"},
|
|
58
|
+
"keywords": {"type": "string", "description": "关键词(可选)"}
|
|
59
|
+
},
|
|
60
|
+
"required": ["title", "steps"]
|
|
61
|
+
}
|
|
62
|
+
),
|
|
63
|
+
Tool(
|
|
64
|
+
name="upload_image_to_bug",
|
|
65
|
+
description="上传图片到指定Bug的附件",
|
|
66
|
+
inputSchema={
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"bug_id": {"type": "string", "description": "Bug ID"},
|
|
70
|
+
"image_path": {"type": "string", "description": "图片文件路径"},
|
|
71
|
+
"custom_filename": {"type": "string", "description": "自定义文件名(可选)"}
|
|
72
|
+
},
|
|
73
|
+
"required": ["bug_id", "image_path"]
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
Tool(
|
|
77
|
+
name="create_case",
|
|
78
|
+
description="创建测试用例到禅道系统",
|
|
79
|
+
inputSchema={
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"product": {"type": "string", "description": "产品ID"},
|
|
83
|
+
"type": {"type": "string", "description": "用例类型(interface或feature)", "enum": ["interface", "feature"]},
|
|
84
|
+
"title": {"type": "string", "description": "用例名称"},
|
|
85
|
+
"module": {"type": "string", "description": "模块ID(默认0)"},
|
|
86
|
+
"stage": {"type": "string", "description": "阶段(默认intergrate)"},
|
|
87
|
+
"precondition": {"type": "string", "description": "前置条件"},
|
|
88
|
+
"keywords": {"type": "string", "description": "关键词"},
|
|
89
|
+
"opened_by": {"type": "string", "description": "创建人"},
|
|
90
|
+
"steps": {"type": "array", "description": "测试步骤数组", "items": {"type": "string"}},
|
|
91
|
+
"expects": {"type": "array", "description": "预期结果数组", "items": {"type": "string"}}
|
|
92
|
+
},
|
|
93
|
+
"required": ["product", "type", "title", "opened_by"]
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
Tool(
|
|
97
|
+
name="create_cases_from_csv",
|
|
98
|
+
description="从CSV文件批量创建测试用例",
|
|
99
|
+
inputSchema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"csv_file_path": {"type": "string", "description": "CSV文件路径"},
|
|
103
|
+
"case_type": {"type": "string", "description": "用例类型(interface、feature、接口测试、功能测试,可选,会自动识别)"},
|
|
104
|
+
"product_id": {"type": "string", "description": "产品ID(可选,默认使用配置文件中的值)"},
|
|
105
|
+
"opened_by": {"type": "string", "description": "创建人(可选,默认使用配置文件中的值)"},
|
|
106
|
+
"keywords": {"type": "string", "description": "关键词(可选,默认使用配置文件中的值)"}
|
|
107
|
+
},
|
|
108
|
+
"required": ["csv_file_path"]
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
Tool(
|
|
112
|
+
name="submit_csv_cases_to_zentao",
|
|
113
|
+
description="将CSV文件中的所有测试用例提交到禅道(会自动备份原文件)",
|
|
114
|
+
inputSchema={
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"csv_file_path": {"type": "string", "description": "CSV文件路径"},
|
|
118
|
+
"case_type": {"type": "string", "description": "用例类型(interface、feature、接口测试、功能测试,可选,会自动识别)"},
|
|
119
|
+
"product_id": {"type": "string", "description": "产品ID(可选,默认使用配置文件中的值)"},
|
|
120
|
+
"opened_by": {"type": "string", "description": "创建人(可选,默认使用配置文件中的值)"},
|
|
121
|
+
"keywords": {"type": "string", "description": "关键词(可选,默认使用配置文件中的值)"}
|
|
122
|
+
},
|
|
123
|
+
"required": ["csv_file_path"]
|
|
124
|
+
}
|
|
125
|
+
),
|
|
126
|
+
Tool(
|
|
127
|
+
name="get_config",
|
|
128
|
+
description="获取当前配置信息",
|
|
129
|
+
inputSchema={"type": "object", "properties": {}}
|
|
130
|
+
)
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
@self.server.call_tool()
|
|
134
|
+
async def call_tool(name: str, arguments: Dict[str, Any]) -> list[TextContent]:
|
|
135
|
+
"""处理工具调用"""
|
|
136
|
+
try:
|
|
137
|
+
# 延迟初始化 nexus(只在第一次使用时初始化)
|
|
138
|
+
if self.nexus is None:
|
|
139
|
+
# 优先使用环境变量配置(Cursor MCP 配置)
|
|
140
|
+
config_path = os.getenv("ZENTAO_CONFIG_PATH")
|
|
141
|
+
|
|
142
|
+
# 如果通过环境变量直接配置了产品ID和创建人,则创建临时配置文件
|
|
143
|
+
product_id = os.getenv("ZENTAO_PRODUCT_ID")
|
|
144
|
+
opened_by = os.getenv("ZENTAO_OPENED_BY")
|
|
145
|
+
|
|
146
|
+
if product_id and opened_by and not config_path:
|
|
147
|
+
# 使用环境变量配置,创建临时配置
|
|
148
|
+
import tempfile
|
|
149
|
+
temp_config = {
|
|
150
|
+
"product_id": product_id,
|
|
151
|
+
"opened_by": opened_by,
|
|
152
|
+
"keywords": os.getenv("ZENTAO_KEYWORDS", ""),
|
|
153
|
+
"title_prefix": os.getenv("ZENTAO_TITLE_PREFIX", "")
|
|
154
|
+
}
|
|
155
|
+
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
|
|
156
|
+
json.dump(temp_config, temp_file, ensure_ascii=False)
|
|
157
|
+
temp_file.close()
|
|
158
|
+
config_path = temp_file.name
|
|
159
|
+
|
|
160
|
+
self.nexus = ZenTaoNexus(config_path)
|
|
161
|
+
|
|
162
|
+
if name == "create_bug":
|
|
163
|
+
title = arguments["title"]
|
|
164
|
+
steps = arguments["steps"]
|
|
165
|
+
product_id = arguments.get("product_id")
|
|
166
|
+
opened_by = arguments.get("opened_by")
|
|
167
|
+
screenshot_path = arguments.get("screenshot_path")
|
|
168
|
+
|
|
169
|
+
# 提取其他可选参数
|
|
170
|
+
kwargs = {}
|
|
171
|
+
for key in ["severity", "pri", "type", "environment", "story", "task", "mailto", "keywords"]:
|
|
172
|
+
if key in arguments:
|
|
173
|
+
kwargs[key] = arguments[key]
|
|
174
|
+
|
|
175
|
+
result = self.nexus.create_bug(
|
|
176
|
+
title, steps, product_id, opened_by, screenshot_path, **kwargs
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
180
|
+
|
|
181
|
+
elif name == "upload_image_to_bug":
|
|
182
|
+
bug_id = arguments["bug_id"]
|
|
183
|
+
image_path = arguments["image_path"]
|
|
184
|
+
custom_filename = arguments.get("custom_filename")
|
|
185
|
+
|
|
186
|
+
if not os.path.exists(image_path):
|
|
187
|
+
return [TextContent(
|
|
188
|
+
type="text",
|
|
189
|
+
text=json.dumps({
|
|
190
|
+
"status": -1,
|
|
191
|
+
"message": f"图片文件不存在: {image_path}"
|
|
192
|
+
}, ensure_ascii=False, indent=2)
|
|
193
|
+
)]
|
|
194
|
+
|
|
195
|
+
result = self.nexus.upload_image_to_bug(bug_id, image_path, custom_filename)
|
|
196
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
197
|
+
|
|
198
|
+
elif name == "create_case":
|
|
199
|
+
product = arguments["product"]
|
|
200
|
+
case_type = arguments["type"]
|
|
201
|
+
title = arguments["title"]
|
|
202
|
+
opened_by = arguments["opened_by"]
|
|
203
|
+
module = arguments.get("module", "0")
|
|
204
|
+
stage = arguments.get("stage", "intergrate")
|
|
205
|
+
precondition = arguments.get("precondition", "")
|
|
206
|
+
keywords = arguments.get("keywords", "")
|
|
207
|
+
steps = arguments.get("steps", [])
|
|
208
|
+
expects = arguments.get("expects", [])
|
|
209
|
+
|
|
210
|
+
case_data = {
|
|
211
|
+
"product": product,
|
|
212
|
+
"type": case_type,
|
|
213
|
+
"title": title,
|
|
214
|
+
"module": module,
|
|
215
|
+
"stage": stage,
|
|
216
|
+
"precondition": precondition,
|
|
217
|
+
"keywords": keywords,
|
|
218
|
+
"openedBy": opened_by
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# 添加步骤和预期结果
|
|
222
|
+
for i, step in enumerate(steps, 1):
|
|
223
|
+
case_data[f"steps[{i}]"] = step
|
|
224
|
+
|
|
225
|
+
for i, expect in enumerate(expects, 1):
|
|
226
|
+
case_data[f"expects[{i}]"] = expect
|
|
227
|
+
|
|
228
|
+
result = self.nexus.client.create_case_by_api(case_data)
|
|
229
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
230
|
+
|
|
231
|
+
elif name == "create_cases_from_csv":
|
|
232
|
+
csv_file_path = arguments["csv_file_path"]
|
|
233
|
+
case_type = arguments.get("case_type")
|
|
234
|
+
product_id = arguments.get("product_id")
|
|
235
|
+
opened_by = arguments.get("opened_by")
|
|
236
|
+
keywords = arguments.get("keywords")
|
|
237
|
+
|
|
238
|
+
if not os.path.exists(csv_file_path):
|
|
239
|
+
return [TextContent(
|
|
240
|
+
type="text",
|
|
241
|
+
text=json.dumps({
|
|
242
|
+
"status": -1,
|
|
243
|
+
"message": f"CSV文件不存在: {csv_file_path}"
|
|
244
|
+
}, ensure_ascii=False, indent=2)
|
|
245
|
+
)]
|
|
246
|
+
|
|
247
|
+
# 如果提供了参数,需要临时修改配置
|
|
248
|
+
original_product_id = self.nexus.product_id
|
|
249
|
+
original_opened_by = self.nexus.opened_by
|
|
250
|
+
original_keywords = self.nexus.keywords
|
|
251
|
+
|
|
252
|
+
if product_id:
|
|
253
|
+
self.nexus.product_id = product_id
|
|
254
|
+
if opened_by:
|
|
255
|
+
self.nexus.opened_by = opened_by
|
|
256
|
+
if keywords:
|
|
257
|
+
self.nexus.keywords = keywords
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
results = self.nexus.create_case_from_csv(csv_file_path, case_type)
|
|
261
|
+
finally:
|
|
262
|
+
# 恢复原始配置
|
|
263
|
+
self.nexus.product_id = original_product_id
|
|
264
|
+
self.nexus.opened_by = original_opened_by
|
|
265
|
+
self.nexus.keywords = original_keywords
|
|
266
|
+
|
|
267
|
+
return [TextContent(type="text", text=json.dumps(results, ensure_ascii=False, indent=2))]
|
|
268
|
+
|
|
269
|
+
elif name == "submit_csv_cases_to_zentao":
|
|
270
|
+
csv_file_path = arguments["csv_file_path"]
|
|
271
|
+
case_type = arguments.get("case_type")
|
|
272
|
+
product_id = arguments.get("product_id")
|
|
273
|
+
opened_by = arguments.get("opened_by")
|
|
274
|
+
keywords = arguments.get("keywords")
|
|
275
|
+
|
|
276
|
+
if not os.path.exists(csv_file_path):
|
|
277
|
+
return [TextContent(
|
|
278
|
+
type="text",
|
|
279
|
+
text=json.dumps({
|
|
280
|
+
"status": "error",
|
|
281
|
+
"message": f"CSV文件不存在: {csv_file_path}",
|
|
282
|
+
"total_cases": 0,
|
|
283
|
+
"success_count": 0,
|
|
284
|
+
"failed_count": 0,
|
|
285
|
+
"success_rate": 0.0
|
|
286
|
+
}, ensure_ascii=False, indent=2)
|
|
287
|
+
)]
|
|
288
|
+
|
|
289
|
+
# 如果提供了参数,需要临时修改配置
|
|
290
|
+
original_product_id = self.nexus.product_id
|
|
291
|
+
original_opened_by = self.nexus.opened_by
|
|
292
|
+
original_keywords = self.nexus.keywords
|
|
293
|
+
|
|
294
|
+
if product_id:
|
|
295
|
+
self.nexus.product_id = product_id
|
|
296
|
+
if opened_by:
|
|
297
|
+
self.nexus.opened_by = opened_by
|
|
298
|
+
if keywords:
|
|
299
|
+
self.nexus.keywords = keywords
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
result = self.nexus.submit_csv_cases_to_zentao(csv_file_path, case_type)
|
|
303
|
+
finally:
|
|
304
|
+
# 恢复原始配置
|
|
305
|
+
self.nexus.product_id = original_product_id
|
|
306
|
+
self.nexus.opened_by = original_opened_by
|
|
307
|
+
self.nexus.keywords = original_keywords
|
|
308
|
+
|
|
309
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
310
|
+
|
|
311
|
+
elif name == "get_config":
|
|
312
|
+
config = self.nexus.get_config()
|
|
313
|
+
return [TextContent(type="text", text=json.dumps(config, ensure_ascii=False, indent=2))]
|
|
314
|
+
|
|
315
|
+
else:
|
|
316
|
+
raise ValueError(f"未知的工具: {name}")
|
|
317
|
+
|
|
318
|
+
except Exception as e:
|
|
319
|
+
error_result = {
|
|
320
|
+
"status": -1,
|
|
321
|
+
"message": str(e),
|
|
322
|
+
"error": str(e)
|
|
323
|
+
}
|
|
324
|
+
import traceback
|
|
325
|
+
error_result["traceback"] = traceback.format_exc()
|
|
326
|
+
return [TextContent(type="text", text=json.dumps(error_result, ensure_ascii=False, indent=2))]
|
|
327
|
+
|
|
328
|
+
async def run(self):
|
|
329
|
+
"""运行服务器"""
|
|
330
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
331
|
+
await self.server.run(
|
|
332
|
+
read_stream,
|
|
333
|
+
write_stream,
|
|
334
|
+
self.server.create_initialization_options()
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def main():
|
|
339
|
+
"""主函数"""
|
|
340
|
+
server = ZenTaoMCPServer()
|
|
341
|
+
await server.run()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
ZenTao Nexus - 禅道Bug管理系统连接工具类
|
|
5
|
+
支持创建Bug、上传截图、批量创建测试用例等功能
|
|
6
|
+
配置通过JSON文件管理,只需配置 product_id、opened_by 和 keywords
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import hashlib
|
|
12
|
+
import time
|
|
13
|
+
import requests
|
|
14
|
+
import csv
|
|
15
|
+
import shutil
|
|
16
|
+
import re
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, Any, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ==================== 配置加载 ====================
|
|
23
|
+
|
|
24
|
+
class ConfigLoader:
|
|
25
|
+
"""配置加载器类"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
28
|
+
"""
|
|
29
|
+
初始化配置加载器
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config_path: 配置文件路径,如果为None则使用默认路径
|
|
33
|
+
"""
|
|
34
|
+
self.config_path = config_path
|
|
35
|
+
self.config: Dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
# 如果提供了配置文件路径,加载配置文件
|
|
38
|
+
if config_path and os.path.exists(config_path):
|
|
39
|
+
if isinstance(config_path, Path):
|
|
40
|
+
config_path = str(config_path.resolve())
|
|
41
|
+
elif not os.path.isabs(config_path):
|
|
42
|
+
config_path = str(Path(config_path).resolve())
|
|
43
|
+
self.config_path = config_path
|
|
44
|
+
self.load_config()
|
|
45
|
+
elif config_path is None:
|
|
46
|
+
# 尝试使用默认路径
|
|
47
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
project_root = Path(current_dir).parent.parent.parent.parent
|
|
49
|
+
default_config_path = project_root / "testing" / "bug_config.json"
|
|
50
|
+
|
|
51
|
+
if default_config_path.exists():
|
|
52
|
+
self.config_path = str(default_config_path.resolve())
|
|
53
|
+
self.load_config()
|
|
54
|
+
else:
|
|
55
|
+
# 如果没有配置文件,使用空配置(将依赖环境变量)
|
|
56
|
+
self.config = {}
|
|
57
|
+
|
|
58
|
+
def load_config(self) -> None:
|
|
59
|
+
"""加载配置文件(支持JSON注释)"""
|
|
60
|
+
if not self.config_path or not os.path.exists(self.config_path):
|
|
61
|
+
# 如果配置文件不存在,使用空配置(将依赖环境变量)
|
|
62
|
+
self.config = {}
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
67
|
+
content = f.read()
|
|
68
|
+
|
|
69
|
+
# 移除JSON注释(支持 // 和 /* */ 两种格式)
|
|
70
|
+
# 移除单行注释 //
|
|
71
|
+
content = re.sub(r'//.*?$', '', content, flags=re.MULTILINE)
|
|
72
|
+
# 移除多行注释 /* */
|
|
73
|
+
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
|
|
74
|
+
|
|
75
|
+
self.config = json.loads(content)
|
|
76
|
+
except json.JSONDecodeError as e:
|
|
77
|
+
raise ValueError(f"配置文件JSON格式错误: {str(e)}")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise RuntimeError(f"加载配置文件失败: {str(e)}")
|
|
80
|
+
|
|
81
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
82
|
+
"""获取配置值"""
|
|
83
|
+
return self.config.get(key, default)
|
|
84
|
+
|
|
85
|
+
def validate(self) -> bool:
|
|
86
|
+
"""验证配置文件的完整性(如果配置文件存在)"""
|
|
87
|
+
# 如果配置文件不存在或为空,则依赖环境变量,不强制验证
|
|
88
|
+
if not self.config_path or not os.path.exists(self.config_path):
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
# 如果配置文件存在,则验证必需字段
|
|
92
|
+
required_keys = ["product_id", "opened_by"]
|
|
93
|
+
for key in required_keys:
|
|
94
|
+
if key not in self.config:
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ==================== API客户端 ====================
|
|
100
|
+
|
|
101
|
+
class ZenTaoAPIClient:
|
|
102
|
+
"""禅道API客户端"""
|
|
103
|
+
|
|
104
|
+
# 写死的配置
|
|
105
|
+
BASE_URL = "https://zentao.bangdao-tech.com"
|
|
106
|
+
ACCOUNT = "KUBETEST"
|
|
107
|
+
SECRET_KEY = "aa287b7b5a4ef5d051f82fb1825ca1ac"
|
|
108
|
+
CASE_SECRET_KEY = "db1d522a190f1135c6e7c324bd337fda"
|
|
109
|
+
CASE_ACCOUNT = "AUTOTEST"
|
|
110
|
+
|
|
111
|
+
def __init__(self, silent=True):
|
|
112
|
+
"""
|
|
113
|
+
初始化禅道API客户端
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
silent (bool): 是否静默模式,不输出调试信息
|
|
117
|
+
"""
|
|
118
|
+
self.base_url = self.BASE_URL
|
|
119
|
+
self.account = self.ACCOUNT
|
|
120
|
+
self.secret_key = self.SECRET_KEY
|
|
121
|
+
self.case_secret_key = self.CASE_SECRET_KEY
|
|
122
|
+
self.silent = silent
|
|
123
|
+
|
|
124
|
+
def generate_bdtoken(self, timestamp):
|
|
125
|
+
"""生成BDTOKEN(用于BUG创建)"""
|
|
126
|
+
token_string = f"accountfmtamp{timestamp}{self.secret_key}"
|
|
127
|
+
md5_hash = hashlib.md5()
|
|
128
|
+
md5_hash.update(token_string.encode('utf-8'))
|
|
129
|
+
return md5_hash.hexdigest()
|
|
130
|
+
|
|
131
|
+
def generate_case_bdtoken(self, timestamp):
|
|
132
|
+
"""生成用例创建用的BDTOKEN"""
|
|
133
|
+
token_string = f"accountfmtamp{timestamp}{self.case_secret_key}"
|
|
134
|
+
md5_hash = hashlib.md5()
|
|
135
|
+
md5_hash.update(token_string.encode('utf-8'))
|
|
136
|
+
return md5_hash.hexdigest()
|
|
137
|
+
|
|
138
|
+
def create_bug_by_api(self, bug_data, use_form_data=True):
|
|
139
|
+
"""通过API创建bug"""
|
|
140
|
+
current_timestamp = int(time.time())
|
|
141
|
+
bdtoken = self.generate_bdtoken(current_timestamp)
|
|
142
|
+
|
|
143
|
+
url = f"{self.base_url}/zentaopms/www/index.php"
|
|
144
|
+
params = {
|
|
145
|
+
'm': 'allneedlist',
|
|
146
|
+
'f': 'createbugbyapi',
|
|
147
|
+
'account': self.account,
|
|
148
|
+
'tamp': current_timestamp
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
headers = {
|
|
152
|
+
'BDTOKEN': bdtoken,
|
|
153
|
+
'User-Agent': 'Python-ZenTao-Client/1.0'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
if use_form_data:
|
|
158
|
+
response = requests.post(url, params=params, headers=headers, data=bug_data, timeout=30)
|
|
159
|
+
else:
|
|
160
|
+
headers['Content-Type'] = 'application/json'
|
|
161
|
+
response = requests.post(url, params=params, headers=headers, json=bug_data, timeout=30)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
return response.json()
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
return {
|
|
167
|
+
"status": -1,
|
|
168
|
+
"message": "响应不是有效的JSON格式",
|
|
169
|
+
"raw_response": response.text
|
|
170
|
+
}
|
|
171
|
+
except requests.exceptions.RequestException as e:
|
|
172
|
+
return {
|
|
173
|
+
"status": -1,
|
|
174
|
+
"message": f"请求失败: {str(e)}"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def create_case_by_api(self, case_data, use_form_data=True):
|
|
178
|
+
"""通过API创建测试用例"""
|
|
179
|
+
current_timestamp = int(time.time())
|
|
180
|
+
bdtoken = self.generate_case_bdtoken(current_timestamp)
|
|
181
|
+
|
|
182
|
+
url = f"{self.base_url}/zentaopms/www/index.php"
|
|
183
|
+
params = {
|
|
184
|
+
'm': 'allneedlist',
|
|
185
|
+
'f': 'createcasebyapi',
|
|
186
|
+
'account': self.CASE_ACCOUNT,
|
|
187
|
+
'tamp': current_timestamp
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
headers = {
|
|
191
|
+
'BDTOKEN': bdtoken,
|
|
192
|
+
'User-Agent': 'Python-ZenTao-Client/1.0'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
if use_form_data:
|
|
197
|
+
response = requests.post(url, params=params, headers=headers, data=case_data, timeout=30)
|
|
198
|
+
else:
|
|
199
|
+
headers['Content-Type'] = 'application/json'
|
|
200
|
+
response = requests.post(url, params=params, headers=headers, json=case_data, timeout=30)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
return response.json()
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
return {
|
|
206
|
+
"status": -1,
|
|
207
|
+
"message": "响应不是有效的JSON格式",
|
|
208
|
+
"raw_response": response.text
|
|
209
|
+
}
|
|
210
|
+
except requests.exceptions.RequestException as e:
|
|
211
|
+
return {
|
|
212
|
+
"status": -1,
|
|
213
|
+
"message": f"请求失败: {str(e)}"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def upload_image_to_bug(self, bug_id, image_path, custom_filename=None):
|
|
217
|
+
"""上传图片到BUG附件"""
|
|
218
|
+
if not os.path.exists(image_path):
|
|
219
|
+
return {
|
|
220
|
+
"status": -1,
|
|
221
|
+
"message": f"图片文件不存在: {image_path}"
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
current_timestamp = int(datetime.now().timestamp())
|
|
225
|
+
url = f"{self.base_url}/zentaopms/www/index.php"
|
|
226
|
+
params = {
|
|
227
|
+
'm': 'allneedlist',
|
|
228
|
+
'f': 'uploadbyapi',
|
|
229
|
+
'account': self.CASE_ACCOUNT,
|
|
230
|
+
'tamp': current_timestamp
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
token_string = f"accountfmtamp{current_timestamp}{self.case_secret_key}"
|
|
234
|
+
md5_hash = hashlib.md5()
|
|
235
|
+
md5_hash.update(token_string.encode('utf-8'))
|
|
236
|
+
bdtoken = md5_hash.hexdigest()
|
|
237
|
+
|
|
238
|
+
headers = {
|
|
239
|
+
'BDTOKEN': bdtoken,
|
|
240
|
+
'User-Agent': 'Python-ZenTao-Client/1.0'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
filename = custom_filename or os.path.basename(image_path)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
with open(image_path, 'rb') as f:
|
|
247
|
+
files = {
|
|
248
|
+
'files[]': (filename, f, 'application/octet-stream')
|
|
249
|
+
}
|
|
250
|
+
data = {'id': bug_id}
|
|
251
|
+
response = requests.post(url, params=params, headers=headers, files=files, data=data, timeout=30)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
return response.json()
|
|
255
|
+
except json.JSONDecodeError:
|
|
256
|
+
return {
|
|
257
|
+
"status": -1,
|
|
258
|
+
"message": "响应不是有效的JSON格式",
|
|
259
|
+
"raw_response": response.text
|
|
260
|
+
}
|
|
261
|
+
except requests.exceptions.RequestException as e:
|
|
262
|
+
return {
|
|
263
|
+
"status": -1,
|
|
264
|
+
"message": f"请求失败: {str(e)}"
|
|
265
|
+
}
|
|
266
|
+
except Exception as e:
|
|
267
|
+
return {
|
|
268
|
+
"status": -1,
|
|
269
|
+
"message": f"文件处理失败: {str(e)}"
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def create_case_from_csv(self, csv_file_path, case_type=None, product_id="365", opened_by="69610", keywords="AI"):
|
|
273
|
+
"""从CSV文件批量创建测试用例"""
|
|
274
|
+
if not os.path.exists(csv_file_path):
|
|
275
|
+
return [{"status": -1, "message": f"CSV文件不存在: {csv_file_path}"}]
|
|
276
|
+
|
|
277
|
+
current_time = datetime.now()
|
|
278
|
+
time_str = current_time.strftime("%H:%M:%S")
|
|
279
|
+
results = []
|
|
280
|
+
updated_rows = []
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
with open(csv_file_path, 'r', encoding='utf-8') as f:
|
|
284
|
+
reader = csv.DictReader(f)
|
|
285
|
+
fieldnames = reader.fieldnames
|
|
286
|
+
|
|
287
|
+
if case_type is None:
|
|
288
|
+
if '接口地址' in fieldnames and '请求方法' in fieldnames:
|
|
289
|
+
case_type = "interface"
|
|
290
|
+
else:
|
|
291
|
+
case_type = "feature"
|
|
292
|
+
else:
|
|
293
|
+
if case_type == "接口测试":
|
|
294
|
+
case_type = "interface"
|
|
295
|
+
elif case_type == "功能测试":
|
|
296
|
+
case_type = "feature"
|
|
297
|
+
|
|
298
|
+
for row in reader:
|
|
299
|
+
if not row.get('用例名称'):
|
|
300
|
+
row['用例ID'] = ''
|
|
301
|
+
updated_rows.append(row)
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
if case_type == "interface":
|
|
305
|
+
interface_url = row.get('接口地址', '')
|
|
306
|
+
request_method = row.get('请求方法', 'POST')
|
|
307
|
+
request_body = row.get('请求体', '')
|
|
308
|
+
assertion = row.get('断言', '')
|
|
309
|
+
expected_result = row.get('预期结果', '')
|
|
310
|
+
|
|
311
|
+
steps = [
|
|
312
|
+
"准备测试数据",
|
|
313
|
+
f"发送请求体:{request_body}",
|
|
314
|
+
f"调用接口 {interface_url}",
|
|
315
|
+
"验证响应结果"
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
expects = [
|
|
319
|
+
"测试数据准备完成",
|
|
320
|
+
f"请求体格式正确:{request_body}",
|
|
321
|
+
f"接口调用成功,方法:{request_method}",
|
|
322
|
+
f"断言验证:{assertion}\\n预期结果:{expected_result}"
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
case_data = {
|
|
326
|
+
"product": product_id,
|
|
327
|
+
"type": "interface",
|
|
328
|
+
"title": row['用例名称'],
|
|
329
|
+
"module": "0",
|
|
330
|
+
"stage": "intergrate",
|
|
331
|
+
"precondition": f"前置条件:\\n1. 系统已启动\\n2. 接口服务正常运行\\n3. 接口地址:{interface_url}\\n4. 请求方法:{request_method}",
|
|
332
|
+
"keywords": f"{keywords} {time_str}",
|
|
333
|
+
"openedBy": opened_by
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for i, step in enumerate(steps, 1):
|
|
337
|
+
case_data[f"steps[{i}]"] = step
|
|
338
|
+
|
|
339
|
+
for i, expect in enumerate(expects, 1):
|
|
340
|
+
case_data[f"expects[{i}]"] = expect
|
|
341
|
+
else:
|
|
342
|
+
steps = [
|
|
343
|
+
"打开系统页面",
|
|
344
|
+
"执行相关操作",
|
|
345
|
+
"验证功能结果"
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
expects = [
|
|
349
|
+
"系统页面正常打开",
|
|
350
|
+
"操作执行成功",
|
|
351
|
+
row.get('预期结果', '功能符合预期')
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
case_data = {
|
|
355
|
+
"product": product_id,
|
|
356
|
+
"type": "feature",
|
|
357
|
+
"title": row['用例名称'],
|
|
358
|
+
"module": "0",
|
|
359
|
+
"stage": "intergrate",
|
|
360
|
+
"precondition": f"前置条件:\\n1. 系统已启动\\n2. 用户已登录",
|
|
361
|
+
"keywords": f"{keywords} {time_str}",
|
|
362
|
+
"openedBy": opened_by
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for i, step in enumerate(steps, 1):
|
|
366
|
+
case_data[f"steps[{i}]"] = step
|
|
367
|
+
|
|
368
|
+
for i, expect in enumerate(expects, 1):
|
|
369
|
+
case_data[f"expects[{i}]"] = expect
|
|
370
|
+
|
|
371
|
+
result = self.create_case_by_api(case_data)
|
|
372
|
+
result['case_name'] = row['用例名称']
|
|
373
|
+
results.append(result)
|
|
374
|
+
|
|
375
|
+
if result.get('status') == 'success':
|
|
376
|
+
row['用例ID'] = result.get('info', '')
|
|
377
|
+
else:
|
|
378
|
+
row['用例ID'] = f"创建失败: {result.get('message', '未知错误')}"
|
|
379
|
+
|
|
380
|
+
updated_rows.append(row)
|
|
381
|
+
|
|
382
|
+
if '用例ID' not in fieldnames:
|
|
383
|
+
fieldnames.append('用例ID')
|
|
384
|
+
|
|
385
|
+
with open(csv_file_path, 'w', encoding='utf-8', newline='') as f:
|
|
386
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
387
|
+
writer.writeheader()
|
|
388
|
+
writer.writerows(updated_rows)
|
|
389
|
+
|
|
390
|
+
except Exception as e:
|
|
391
|
+
results.append({"status": -1, "message": f"处理CSV文件失败: {str(e)}"})
|
|
392
|
+
|
|
393
|
+
return results
|
|
394
|
+
|
|
395
|
+
def submit_csv_cases_to_zentao(self, csv_file_path, case_type=None, product_id="365", opened_by="69610", keywords="AI"):
|
|
396
|
+
"""将CSV文件中的所有测试用例提交到禅道"""
|
|
397
|
+
if not os.path.exists(csv_file_path):
|
|
398
|
+
return {
|
|
399
|
+
"status": "error",
|
|
400
|
+
"message": f"CSV文件不存在: {csv_file_path}",
|
|
401
|
+
"total_cases": 0,
|
|
402
|
+
"success_count": 0,
|
|
403
|
+
"failed_count": 0,
|
|
404
|
+
"success_rate": 0.0
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
backup_file = csv_file_path + ".backup_submit"
|
|
408
|
+
shutil.copy2(csv_file_path, backup_file)
|
|
409
|
+
|
|
410
|
+
total_cases = 0
|
|
411
|
+
with open(csv_file_path, 'r', encoding='utf-8') as f:
|
|
412
|
+
reader = csv.DictReader(f)
|
|
413
|
+
for row in reader:
|
|
414
|
+
if row.get('用例名称'):
|
|
415
|
+
total_cases += 1
|
|
416
|
+
|
|
417
|
+
results = self.create_case_from_csv(csv_file_path, case_type, product_id, opened_by, keywords)
|
|
418
|
+
|
|
419
|
+
success_count = 0
|
|
420
|
+
failed_count = 0
|
|
421
|
+
|
|
422
|
+
for result in results:
|
|
423
|
+
if result.get('status') == 'success':
|
|
424
|
+
success_count += 1
|
|
425
|
+
else:
|
|
426
|
+
failed_count += 1
|
|
427
|
+
|
|
428
|
+
success_rate = (success_count / len(results) * 100) if results else 0.0
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"status": "success",
|
|
432
|
+
"message": "CSV文件测试用例提交完成",
|
|
433
|
+
"total_cases": len(results),
|
|
434
|
+
"success_count": success_count,
|
|
435
|
+
"failed_count": failed_count,
|
|
436
|
+
"success_rate": success_rate,
|
|
437
|
+
"backup_file": backup_file
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ==================== 工具类 ====================
|
|
442
|
+
|
|
443
|
+
class ZenTaoNexus:
|
|
444
|
+
"""
|
|
445
|
+
ZenTao Nexus 工具类
|
|
446
|
+
|
|
447
|
+
用于连接禅道Bug管理系统,支持创建Bug、上传截图等功能。
|
|
448
|
+
配置通过 config.json 文件管理,只需配置 product_id、opened_by 和 keywords。
|
|
449
|
+
|
|
450
|
+
示例:
|
|
451
|
+
>>> nexus = ZenTaoNexus()
|
|
452
|
+
>>> result = nexus.create_bug(
|
|
453
|
+
... title="测试Bug",
|
|
454
|
+
... steps="重现步骤\\n1. 第一步\\n2. 第二步"
|
|
455
|
+
... )
|
|
456
|
+
>>> print(result)
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
# 写死的默认值
|
|
460
|
+
OPENED_BUILD = "trunk"
|
|
461
|
+
ENVIRONMENT = "测试环境"
|
|
462
|
+
BUG_TYPE = "codeissue"
|
|
463
|
+
BUG_SOURCE = "codemiss"
|
|
464
|
+
SEVERITY = "3"
|
|
465
|
+
PRIORITY = "3"
|
|
466
|
+
DEADLINE_DAYS = 2
|
|
467
|
+
LOWER_BUG = "0"
|
|
468
|
+
REGRESSION = "0"
|
|
469
|
+
|
|
470
|
+
def __init__(self, config_path: str = None):
|
|
471
|
+
"""
|
|
472
|
+
初始化ZenTao Nexus工具
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
config_path: 配置文件路径,如果为None则使用默认路径(当前目录下的config.json)
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
ValueError: 配置文件验证失败时抛出
|
|
479
|
+
FileNotFoundError: 配置文件不存在时抛出
|
|
480
|
+
"""
|
|
481
|
+
# 加载配置
|
|
482
|
+
self.config_loader = ConfigLoader(config_path)
|
|
483
|
+
|
|
484
|
+
# 验证配置(如果配置文件存在)
|
|
485
|
+
if not self.config_loader.validate():
|
|
486
|
+
# 检查是否可以通过环境变量获取必需配置
|
|
487
|
+
product_id = os.getenv("ZENTAO_PRODUCT_ID") or os.getenv("PRODUCT_ID")
|
|
488
|
+
opened_by = os.getenv("ZENTAO_OPENED_BY") or os.getenv("OPENED_BY")
|
|
489
|
+
|
|
490
|
+
if not product_id or not opened_by:
|
|
491
|
+
raise ValueError("配置文件验证失败,且未找到必需的环境变量 ZENTAO_PRODUCT_ID 和 ZENTAO_OPENED_BY。请检查配置文件格式或设置环境变量。")
|
|
492
|
+
|
|
493
|
+
# 从配置加载(优先使用环境变量,支持 ZENTAO_ 前缀)
|
|
494
|
+
self.product_id = os.getenv("ZENTAO_PRODUCT_ID") or os.getenv("PRODUCT_ID") or self.config_loader.get("product_id", "365")
|
|
495
|
+
self.opened_by = os.getenv("ZENTAO_OPENED_BY") or os.getenv("OPENED_BY") or self.config_loader.get("opened_by", "69610")
|
|
496
|
+
# 关键词处理:如果配置中有 keywords,则使用 "ai," 拼接;如果没有,则使用 "ai"
|
|
497
|
+
env_keywords = os.getenv("ZENTAO_KEYWORDS") or os.getenv("KEYWORDS")
|
|
498
|
+
config_keywords = env_keywords or self.config_loader.get("keywords")
|
|
499
|
+
if config_keywords:
|
|
500
|
+
self.keywords = f"ai,{config_keywords}"
|
|
501
|
+
else:
|
|
502
|
+
self.keywords = "ai"
|
|
503
|
+
|
|
504
|
+
# 标题前缀
|
|
505
|
+
env_title_prefix = os.getenv("ZENTAO_TITLE_PREFIX") or os.getenv("TITLE_PREFIX")
|
|
506
|
+
self.title_prefix = env_title_prefix or self.config_loader.get("title_prefix", "")
|
|
507
|
+
|
|
508
|
+
# 创建API客户端
|
|
509
|
+
self.client = ZenTaoAPIClient(silent=True)
|
|
510
|
+
|
|
511
|
+
def _format_steps_to_html(self, steps):
|
|
512
|
+
"""将steps中的换行转换为HTML格式"""
|
|
513
|
+
if not steps:
|
|
514
|
+
return ""
|
|
515
|
+
|
|
516
|
+
lines = steps.strip().split('\n')
|
|
517
|
+
html_steps = []
|
|
518
|
+
|
|
519
|
+
for line in lines:
|
|
520
|
+
line = line.strip()
|
|
521
|
+
if not line:
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
if line and line[0].isdigit() and ('.' in line[:3] or '、' in line[:3]):
|
|
525
|
+
html_steps.append(f"<p>{line}</p>")
|
|
526
|
+
elif line.startswith('- '):
|
|
527
|
+
html_steps.append(f"<p>• {line[2:]}</p>")
|
|
528
|
+
elif ':' in line or ':' in line:
|
|
529
|
+
html_steps.append(f"<p><strong>{line}</strong></p>")
|
|
530
|
+
else:
|
|
531
|
+
html_steps.append(f"<p>{line}</p>")
|
|
532
|
+
|
|
533
|
+
return ''.join(html_steps)
|
|
534
|
+
|
|
535
|
+
def create_bug(self, title, steps, product_id=None, opened_by=None, screenshot_path=None, **kwargs):
|
|
536
|
+
"""
|
|
537
|
+
创建Bug(支持截图上传)
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
title (str): Bug标题
|
|
541
|
+
steps (str): 重现步骤(支持换行,会自动转换为HTML格式)
|
|
542
|
+
product_id (str): 产品ID,如果为None则使用配置值
|
|
543
|
+
opened_by (str): 创建人和指派人,如果为None则使用配置值
|
|
544
|
+
screenshot_path (str): 截图文件路径,如果提供则会上传到禅道
|
|
545
|
+
**kwargs: 其他可选参数
|
|
546
|
+
- severity: 严重程度(默认3)
|
|
547
|
+
- pri: 优先级(默认3)
|
|
548
|
+
- type: Bug类型(默认功能缺陷)
|
|
549
|
+
- environment: 环境(默认测试环境)
|
|
550
|
+
- story: 相关需求id
|
|
551
|
+
- task: 相关任务id
|
|
552
|
+
- mailto: 抄送人账号
|
|
553
|
+
- keywords: 关键词
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
dict: 创建结果
|
|
557
|
+
"""
|
|
558
|
+
product_id = product_id or self.product_id
|
|
559
|
+
opened_by = opened_by or self.opened_by
|
|
560
|
+
keywords = kwargs.get("keywords", self.keywords)
|
|
561
|
+
|
|
562
|
+
# 处理标题前缀:如果 title_prefix 不为空字符串,则在标题前面加上【title_prefix】
|
|
563
|
+
original_title = title
|
|
564
|
+
if self.title_prefix and self.title_prefix.strip():
|
|
565
|
+
title = f"【{self.title_prefix}】{title}"
|
|
566
|
+
|
|
567
|
+
html_steps = self._format_steps_to_html(steps)
|
|
568
|
+
|
|
569
|
+
bug_data = {
|
|
570
|
+
"product": product_id,
|
|
571
|
+
"openedBuild": kwargs.get("opened_build", self.OPENED_BUILD),
|
|
572
|
+
"environment": kwargs.get("environment", self.ENVIRONMENT),
|
|
573
|
+
"title": title,
|
|
574
|
+
"assignedTo": opened_by,
|
|
575
|
+
"deadline": (datetime.now() + timedelta(days=self.DEADLINE_DAYS)).strftime("%Y-%m-%d"),
|
|
576
|
+
"type": kwargs.get("type", self.BUG_TYPE),
|
|
577
|
+
"source": kwargs.get("source", self.BUG_SOURCE),
|
|
578
|
+
"lowerBug": kwargs.get("lowerBug", self.LOWER_BUG),
|
|
579
|
+
"regression": kwargs.get("regression", self.REGRESSION),
|
|
580
|
+
"severity": kwargs.get("severity", self.SEVERITY),
|
|
581
|
+
"pri": kwargs.get("pri", self.PRIORITY),
|
|
582
|
+
"steps": html_steps,
|
|
583
|
+
"story": kwargs.get("story", "0"),
|
|
584
|
+
"task": kwargs.get("task", "0"),
|
|
585
|
+
"mailto": kwargs.get("mailto", ""),
|
|
586
|
+
"keywords": keywords,
|
|
587
|
+
"openedBy": opened_by
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
import contextlib
|
|
591
|
+
with contextlib.redirect_stderr(open(os.devnull, 'w')):
|
|
592
|
+
result = self.client.create_bug_by_api(bug_data)
|
|
593
|
+
|
|
594
|
+
if result.get("status") == 1 and result.get("bugID"):
|
|
595
|
+
bug_id = result.get("bugID")
|
|
596
|
+
bug_url = f"{self.client.base_url}/zentaopms/www/index.php?m=bug&f=view&bugID={bug_id}"
|
|
597
|
+
result["bug_url"] = bug_url
|
|
598
|
+
result["actual_title"] = title # 返回实际提交的标题(包含前缀)
|
|
599
|
+
|
|
600
|
+
if screenshot_path and os.path.exists(screenshot_path):
|
|
601
|
+
try:
|
|
602
|
+
upload_result = self.client.upload_image_to_bug(bug_id, screenshot_path)
|
|
603
|
+
if upload_result.get("status") == 1:
|
|
604
|
+
result["screenshot_uploaded"] = True
|
|
605
|
+
result["screenshot_message"] = "截图上传成功"
|
|
606
|
+
else:
|
|
607
|
+
result["screenshot_uploaded"] = False
|
|
608
|
+
result["screenshot_message"] = f"截图上传失败: {upload_result.get('message')}"
|
|
609
|
+
except Exception as e:
|
|
610
|
+
result["screenshot_uploaded"] = False
|
|
611
|
+
result["screenshot_message"] = f"截图上传异常: {str(e)}"
|
|
612
|
+
else:
|
|
613
|
+
result["screenshot_uploaded"] = False
|
|
614
|
+
result["screenshot_message"] = "未提供截图文件"
|
|
615
|
+
|
|
616
|
+
return result
|
|
617
|
+
|
|
618
|
+
def get_config(self):
|
|
619
|
+
"""获取当前配置信息"""
|
|
620
|
+
return {
|
|
621
|
+
"product_id": self.product_id,
|
|
622
|
+
"opened_by": self.opened_by,
|
|
623
|
+
"keywords": self.keywords,
|
|
624
|
+
"title_prefix": self.title_prefix
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
def upload_image_to_bug(self, bug_id, image_path, custom_filename=None):
|
|
628
|
+
"""上传图片到指定Bug"""
|
|
629
|
+
return self.client.upload_image_to_bug(bug_id, image_path, custom_filename)
|
|
630
|
+
|
|
631
|
+
def create_case_from_csv(self, csv_file_path, case_type=None):
|
|
632
|
+
"""从CSV文件批量创建测试用例"""
|
|
633
|
+
return self.client.create_case_from_csv(csv_file_path, case_type, self.product_id, self.opened_by, self.keywords)
|
|
634
|
+
|
|
635
|
+
def submit_csv_cases_to_zentao(self, csv_file_path, case_type=None):
|
|
636
|
+
"""将CSV文件中的所有测试用例提交到禅道"""
|
|
637
|
+
return self.client.submit_csv_cases_to_zentao(csv_file_path, case_type, self.product_id, self.opened_by, self.keywords)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# 便捷函数
|
|
641
|
+
def create_bug(title, steps, product_id=None, opened_by=None, screenshot_path=None, config_path=None, **kwargs):
|
|
642
|
+
"""便捷函数:快速创建Bug"""
|
|
643
|
+
nexus = ZenTaoNexus(config_path)
|
|
644
|
+
return nexus.create_bug(title, steps, product_id, opened_by, screenshot_path, **kwargs)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
if __name__ == "__main__":
|
|
648
|
+
print("=== ZenTao Nexus 工具类示例 ===\n")
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
nexus = ZenTaoNexus()
|
|
652
|
+
|
|
653
|
+
print("当前配置:")
|
|
654
|
+
config = nexus.get_config()
|
|
655
|
+
for key, value in config.items():
|
|
656
|
+
print(f" {key}: {value}")
|
|
657
|
+
|
|
658
|
+
print("\n✅ ZenTao Nexus 初始化成功!")
|
|
659
|
+
print("\n使用示例:")
|
|
660
|
+
print(" from zentao_nexus import ZenTaoNexus")
|
|
661
|
+
print(" nexus = ZenTaoNexus()")
|
|
662
|
+
print(" result = nexus.create_bug(")
|
|
663
|
+
print(" title='测试Bug',")
|
|
664
|
+
print(" steps='重现步骤\\n1. 第一步'")
|
|
665
|
+
print(" )")
|
|
666
|
+
|
|
667
|
+
except Exception as e:
|
|
668
|
+
print(f"❌ 初始化失败: {str(e)}")
|
|
669
|
+
print("\n请检查:")
|
|
670
|
+
print(" 1. config.json 文件是否存在")
|
|
671
|
+
print(" 2. 配置文件格式是否正确")
|
|
672
|
+
print(" 3. 配置文件中是否包含 product_id、opened_by 和 keywords")
|
|
673
|
+
|