@bangdao-ai/zentao-mcp 2.0.2 → 2.0.4
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/.env.example +5 -8
- package/README.md +18 -12
- package/index.js +2 -2
- package/package.json +1 -1
- package/src/core/zentao_nexus.py +219 -81
- package/src/zenbot/bot_service.py +51 -7
- package/src/zenbot/dingtalk_api.py +89 -1
- package/src/zenbot/dingtalk_handler.py +47 -6
- package/src/zenbot/formatters.py +23 -0
- package/src/zenbot/handlers/bug_handlers.py +47 -3
- package/src/zenbot/handlers/story_handlers.py +6 -1
- package/src/zenbot/handlers/task_handlers.py +4 -1
- package/src/zenbot/intent_parser.py +15 -1
- package/src/zenbot/llm_client.py +95 -0
- package/src/zenbot/session_manager.py +39 -0
- package/src/zenbot/zentao_client.py +35 -0
- package/src/bug_classifier.py +0 -436
- package/src/mcp_server.py +0 -682
- package/src/zentao_nexus.py +0 -1673
package/.env.example
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
# ═══════════════ MCP 通用 ═══════════════
|
|
2
|
-
ZENTAO_BASE_URL=https://zentao.example.com
|
|
3
|
-
ZENTAO_PRODUCT_ID=365
|
|
4
|
-
ZENTAO_OPENED_BY=your_account
|
|
5
|
-
ZENTAO_KEY=your_api_key
|
|
6
|
-
ZENTAO_USE_TEST_ENV=false
|
|
7
1
|
|
|
8
|
-
|
|
2
|
+
|
|
3
|
+
# ═══════════════ ZenBot 部署必填 ═══════════════
|
|
4
|
+
|
|
9
5
|
# 钉钉应用
|
|
10
6
|
DINGTALK_APP_KEY=your_app_key
|
|
11
7
|
DINGTALK_APP_SECRET=your_app_secret
|
|
@@ -14,8 +10,9 @@ DINGTALK_APP_SECRET=your_app_secret
|
|
|
14
10
|
OPENAI_API_KEY=your_openai_api_key
|
|
15
11
|
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
16
12
|
LLM_MODEL=qwen-plus
|
|
13
|
+
LLM_VISION_MODEL=qwen-vl-plus
|
|
17
14
|
|
|
18
|
-
# 禅道 MySQL
|
|
15
|
+
# 禅道 MySQL(用户工号→禅道账号自动映射)
|
|
19
16
|
MYSQL_ZENTAO_HOST=127.0.0.1
|
|
20
17
|
MYSQL_ZENTAO_PORT=3306
|
|
21
18
|
MYSQL_ZENTAO_USER=root
|
package/README.md
CHANGED
|
@@ -159,18 +159,24 @@
|
|
|
159
159
|
|
|
160
160
|
```
|
|
161
161
|
zentao-mcp/
|
|
162
|
-
├── index.js
|
|
162
|
+
├── index.js # Node.js 入口(自动检测Python、安装依赖)
|
|
163
|
+
├── main.py # 钉钉机器人入口
|
|
163
164
|
├── src/
|
|
164
|
-
│ ├──
|
|
165
|
-
│ ├── zentao_nexus.py # 禅道原生API客户端 + 业务层
|
|
166
|
-
│ └── bug_classifier.py # Bug 智能分类引擎
|
|
165
|
+
│ ├── core/
|
|
166
|
+
│ │ ├── zentao_nexus.py # 禅道原生API客户端 + 业务层
|
|
167
|
+
│ │ └── bug_classifier.py # Bug 智能分类引擎
|
|
168
|
+
│ ├── mcp/
|
|
169
|
+
│ │ └── server.py # MCP 服务器(工具注册和路由)
|
|
170
|
+
│ └── zenbot/ # 钉钉机器人模块
|
|
171
|
+
│ ├── bot_service.py # 意图路由 & handler 调度
|
|
172
|
+
│ ├── handlers/ # 各领域 handler
|
|
173
|
+
│ └── ...
|
|
167
174
|
├── testing/
|
|
168
|
-
│ ├── test_zentao_api.py
|
|
169
|
-
│
|
|
170
|
-
│ └── mcp.json # 测试环境配置
|
|
175
|
+
│ ├── test_zentao_api.py # Pytest 全能力测试
|
|
176
|
+
│ └── mcp.json # 测试环境配置
|
|
171
177
|
├── docs/
|
|
172
|
-
│ ├── zentao-native-api.md
|
|
173
|
-
│ └── RELEASE.md
|
|
178
|
+
│ ├── zentao-native-api.md # 原生 API 文档
|
|
179
|
+
│ └── RELEASE.md # 发布说明
|
|
174
180
|
├── package.json
|
|
175
181
|
├── requirements.txt
|
|
176
182
|
└── README.md
|
|
@@ -181,9 +187,9 @@ zentao-mcp/
|
|
|
181
187
|
```
|
|
182
188
|
AI 编辑器 (Cursor/Claude)
|
|
183
189
|
↕ MCP 协议 (stdio)
|
|
184
|
-
MCP Server (
|
|
185
|
-
↕
|
|
186
|
-
ZenTaoNexus (zentao_nexus.py) — 业务封装层
|
|
190
|
+
MCP Server (src/mcp/server.py)
|
|
191
|
+
↕ Tool 定义 & 路由
|
|
192
|
+
ZenTaoNexus (src/core/zentao_nexus.py) — 业务封装层
|
|
187
193
|
↕
|
|
188
194
|
ZenTaoNativeClient — 原生 API 客户端
|
|
189
195
|
↕ HTTP POST (api.php?m=module&f=action)
|
package/index.js
CHANGED
|
@@ -243,10 +243,10 @@ function main() {
|
|
|
243
243
|
console.error('\n故障排查:');
|
|
244
244
|
console.error('1. 检查 Python 是否安装: python3 --version');
|
|
245
245
|
if (isWindows) {
|
|
246
|
-
console.error('2. 检查脚本是否存在: dir src\\
|
|
246
|
+
console.error('2. 检查脚本是否存在: dir src\\mcp\\server.py');
|
|
247
247
|
console.error('3. 检查依赖是否安装: pip3 list | findstr mcp');
|
|
248
248
|
} else {
|
|
249
|
-
console.error('2. 检查脚本是否存在: ls -la src/
|
|
249
|
+
console.error('2. 检查脚本是否存在: ls -la src/mcp/server.py');
|
|
250
250
|
console.error('3. 检查依赖是否安装: pip3 list | grep mcp');
|
|
251
251
|
}
|
|
252
252
|
process.exit(1);
|
package/package.json
CHANGED
package/src/core/zentao_nexus.py
CHANGED
|
@@ -583,6 +583,36 @@ class ZenTaoNativeClient:
|
|
|
583
583
|
params={'productID': product_id}, form_data=case_data)
|
|
584
584
|
return self.normalize(result)
|
|
585
585
|
|
|
586
|
+
def testcase_import_batch(self, product_id, cases: list) -> Dict:
|
|
587
|
+
"""
|
|
588
|
+
通过禅道 testcase.showImport 批量导入用例。
|
|
589
|
+
|
|
590
|
+
与 testcase.create 的区别:
|
|
591
|
+
- create 的步骤用 steps[1], steps[2]... 逐条,步骤编号由参数下标决定
|
|
592
|
+
- showImport 的步骤用 stepDesc[N] 整段多行文本,禅道自动解析编号
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
product_id: 产品 ID
|
|
596
|
+
cases: [{title, pri, type, stage, module, story, keywords,
|
|
597
|
+
precondition, stepDesc, stepExpect}, ...]
|
|
598
|
+
"""
|
|
599
|
+
form_data = {}
|
|
600
|
+
for idx, case in enumerate(cases, 1):
|
|
601
|
+
form_data[f'title[{idx}]'] = case.get('title', '')
|
|
602
|
+
form_data[f'module[{idx}]'] = case.get('module', '0')
|
|
603
|
+
form_data[f'story[{idx}]'] = case.get('story', '')
|
|
604
|
+
form_data[f'pri[{idx}]'] = case.get('pri', '3')
|
|
605
|
+
form_data[f'type[{idx}]'] = case.get('type', 'feature')
|
|
606
|
+
form_data[f'stage[{idx}]'] = case.get('stage', '')
|
|
607
|
+
form_data[f'keywords[{idx}]'] = case.get('keywords', '')
|
|
608
|
+
form_data[f'precondition[{idx}]'] = case.get('precondition', '')
|
|
609
|
+
form_data[f'stepDesc[{idx}]'] = case.get('stepDesc', '')
|
|
610
|
+
form_data[f'stepExpect[{idx}]'] = case.get('stepExpect', '')
|
|
611
|
+
result = self._call('testcase', 'showImport',
|
|
612
|
+
params={'productID': product_id, 'branch': '0'},
|
|
613
|
+
form_data=form_data)
|
|
614
|
+
return self.normalize(result)
|
|
615
|
+
|
|
586
616
|
def testcase_create_by_gateway(self, case_data: Dict) -> Dict:
|
|
587
617
|
"""
|
|
588
618
|
通过历史稳定网关创建用例(allneedlist/createcasebyapi)。
|
|
@@ -757,18 +787,43 @@ class ZenTaoNativeClient:
|
|
|
757
787
|
|
|
758
788
|
def file_upload(self, object_type: str, object_id, file_path: str,
|
|
759
789
|
custom_filename: Optional[str] = None) -> Dict:
|
|
790
|
+
"""
|
|
791
|
+
上传文件并关联到指定对象。
|
|
792
|
+
优先使用 allneedlist/uploadbyapi(token 认证兼容),
|
|
793
|
+
回退到 file/upload(需要 web session 权限,部分环境 401)。
|
|
794
|
+
"""
|
|
760
795
|
file_path = _safe_path(file_path, must_exist=True)
|
|
761
796
|
if not os.path.exists(file_path):
|
|
762
797
|
return {"status": 0, "message": "fail",
|
|
763
798
|
"info": f"文件不存在: {file_path}", "data": {}}
|
|
764
799
|
|
|
765
800
|
filename = custom_filename or os.path.basename(file_path)
|
|
801
|
+
oid = str(object_id)
|
|
802
|
+
|
|
766
803
|
try:
|
|
804
|
+
with open(file_path, 'rb') as f:
|
|
805
|
+
files = {'files[]': (filename, f, 'image/png')}
|
|
806
|
+
form_data = {'objectType': object_type, 'objectID': oid, 'id': oid}
|
|
807
|
+
result = self._call('allneedlist', 'uploadbyapi',
|
|
808
|
+
form_data=form_data, files=files)
|
|
809
|
+
normalized = self.normalize(result)
|
|
810
|
+
if normalized.get("status") == 1:
|
|
811
|
+
return normalized
|
|
812
|
+
|
|
813
|
+
gateway_error = normalized
|
|
814
|
+
|
|
767
815
|
with open(file_path, 'rb') as f:
|
|
768
816
|
files = {'files[]': (filename, f, 'application/octet-stream')}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
817
|
+
result = self._call('file', 'upload',
|
|
818
|
+
params={'objectType': object_type,
|
|
819
|
+
'objectID': oid},
|
|
820
|
+
files=files)
|
|
821
|
+
normalized = self.normalize(result)
|
|
822
|
+
if normalized.get("status") == 1:
|
|
823
|
+
return normalized
|
|
824
|
+
|
|
825
|
+
return gateway_error
|
|
826
|
+
|
|
772
827
|
except Exception as e:
|
|
773
828
|
return {"status": 0, "message": "fail",
|
|
774
829
|
"info": f"文件处理失败: {e}", "data": {}}
|
|
@@ -794,11 +849,11 @@ class ZenTaoNexus:
|
|
|
794
849
|
self.product_id = os.getenv("ZENTAO_PRODUCT_ID") or ""
|
|
795
850
|
|
|
796
851
|
code = code or os.getenv("ZENTAO_OPENED_BY")
|
|
797
|
-
key = key or os.getenv("ZENTAO_KEY")
|
|
852
|
+
key = key or os.getenv("ZENTAO_KEY") or os.getenv("KEY")
|
|
798
853
|
if not code:
|
|
799
854
|
raise ValueError("未设置 code 参数或环境变量 ZENTAO_OPENED_BY")
|
|
800
855
|
if not key:
|
|
801
|
-
raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY")
|
|
856
|
+
raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY / KEY")
|
|
802
857
|
|
|
803
858
|
self.opened_by = code
|
|
804
859
|
use_test = os.getenv("ZENTAO_USE_TEST_ENV", "").lower() in ('1', 'true', 'yes')
|
|
@@ -859,7 +914,7 @@ class ZenTaoNexus:
|
|
|
859
914
|
|
|
860
915
|
def create_bug(self, title, steps, product_id=None, opened_by=None,
|
|
861
916
|
assigned_to=None, screenshot_path=None, **kwargs) -> Dict:
|
|
862
|
-
from bug_classifier import classify_bug
|
|
917
|
+
from core.bug_classifier import classify_bug
|
|
863
918
|
|
|
864
919
|
product_id = product_id or self.product_id
|
|
865
920
|
opened_by = opened_by or self.opened_by
|
|
@@ -1359,90 +1414,169 @@ class ZenTaoNexus:
|
|
|
1359
1414
|
"gateway_error": gateway_result,
|
|
1360
1415
|
}
|
|
1361
1416
|
|
|
1417
|
+
@staticmethod
|
|
1418
|
+
def _build_numbered_text(lines: list) -> str:
|
|
1419
|
+
"""将步骤/预期列表组装为带编号的多行文本(供 showImport 接口使用)。"""
|
|
1420
|
+
if not lines:
|
|
1421
|
+
return ''
|
|
1422
|
+
return '\n'.join(f"{i}. {ln}" for i, ln in enumerate(lines, 1))
|
|
1423
|
+
|
|
1424
|
+
@staticmethod
|
|
1425
|
+
def _split_numbered_lines(text: str) -> list:
|
|
1426
|
+
"""将多行编号文本拆分为独立条目,去掉编号前缀。"""
|
|
1427
|
+
if not text or not text.strip():
|
|
1428
|
+
return []
|
|
1429
|
+
lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
|
|
1430
|
+
result = []
|
|
1431
|
+
for ln in lines:
|
|
1432
|
+
cleaned = re.sub(r'^(\d+)\s*[.、)]\s*', '', ln)
|
|
1433
|
+
cleaned = re.sub(r'^[-–—]\s*', '', cleaned)
|
|
1434
|
+
if cleaned:
|
|
1435
|
+
result.append(cleaned)
|
|
1436
|
+
return result if result else [text.strip()]
|
|
1437
|
+
|
|
1438
|
+
def _parse_csv_rows(self, csv_file_path, case_type=None):
|
|
1439
|
+
"""解析 CSV 文件,返回 (fieldnames, rows, case_type)。
|
|
1440
|
+
|
|
1441
|
+
支持两种 CSV 格式:
|
|
1442
|
+
- 富格式(含 步骤/预期 列):用例标题, 步骤, 预期, 前置条件, 优先级, 关键词
|
|
1443
|
+
- 接口格式(含 接口地址/请求方法 列):用例名称, 接口地址, 请求方法, 请求体, 断言, 预期结果
|
|
1444
|
+
- 简易格式:用例名称, 预期结果
|
|
1445
|
+
"""
|
|
1446
|
+
with open(csv_file_path, 'r', encoding='utf-8', newline='') as f:
|
|
1447
|
+
reader = csv.DictReader(f)
|
|
1448
|
+
fieldnames = list(reader.fieldnames or [])
|
|
1449
|
+
|
|
1450
|
+
if case_type is None:
|
|
1451
|
+
if '接口地址' in fieldnames and '请求方法' in fieldnames:
|
|
1452
|
+
case_type = "interface"
|
|
1453
|
+
else:
|
|
1454
|
+
case_type = "feature"
|
|
1455
|
+
elif case_type == "接口测试":
|
|
1456
|
+
case_type = "interface"
|
|
1457
|
+
elif case_type == "功能测试":
|
|
1458
|
+
case_type = "feature"
|
|
1459
|
+
|
|
1460
|
+
rows = list(reader)
|
|
1461
|
+
return fieldnames, rows, case_type
|
|
1462
|
+
|
|
1362
1463
|
def create_case_from_csv(self, csv_file_path, case_type=None) -> list:
|
|
1363
1464
|
csv_file_path = _safe_path(csv_file_path, must_exist=True)
|
|
1364
1465
|
if not os.path.exists(csv_file_path):
|
|
1365
1466
|
return [{"status": 0, "message": f"CSV文件不存在: {csv_file_path}"}]
|
|
1366
1467
|
|
|
1367
|
-
|
|
1368
|
-
time_str = current_time.strftime("%H:%M:%S")
|
|
1369
|
-
results = []
|
|
1370
|
-
updated_rows = []
|
|
1468
|
+
time_str = datetime.now().strftime("%H:%M:%S")
|
|
1371
1469
|
|
|
1372
1470
|
try:
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1471
|
+
fieldnames, rows, case_type = self._parse_csv_rows(csv_file_path, case_type)
|
|
1472
|
+
except Exception as e:
|
|
1473
|
+
return [{"status": 0, "message": f"读取CSV文件失败: {e}"}]
|
|
1376
1474
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
case_type = "interface"
|
|
1380
|
-
else:
|
|
1381
|
-
case_type = "feature"
|
|
1382
|
-
elif case_type == "接口测试":
|
|
1383
|
-
case_type = "interface"
|
|
1384
|
-
elif case_type == "功能测试":
|
|
1385
|
-
case_type = "feature"
|
|
1475
|
+
has_rich_steps = '步骤' in fieldnames and '预期' in fieldnames
|
|
1476
|
+
title_col = '用例标题' if '用例标题' in fieldnames else '用例名称'
|
|
1386
1477
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
row['用例ID'] = ''
|
|
1390
|
-
updated_rows.append(row)
|
|
1391
|
-
continue
|
|
1478
|
+
batch_cases = []
|
|
1479
|
+
valid_row_indices = []
|
|
1392
1480
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
"module": "0",
|
|
1398
|
-
"stage": "intergrate",
|
|
1399
|
-
"keywords": f"ai {time_str}",
|
|
1400
|
-
"openedBy": self.opened_by,
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if case_type == "interface":
|
|
1404
|
-
interface_url = row.get('接口地址', '')
|
|
1405
|
-
request_method = row.get('请求方法', 'POST')
|
|
1406
|
-
request_body = row.get('请求体', '')
|
|
1407
|
-
assertion = row.get('断言', '')
|
|
1408
|
-
expected_result = row.get('预期结果', '')
|
|
1409
|
-
|
|
1410
|
-
steps = ["准备测试数据", f"发送请求体:{request_body}",
|
|
1411
|
-
f"调用接口 {interface_url}", "验证响应结果"]
|
|
1412
|
-
expects = ["测试数据准备完成", f"请求体格式正确:{request_body}",
|
|
1413
|
-
f"接口调用成功,方法:{request_method}",
|
|
1414
|
-
f"断言验证:{assertion}\\n预期结果:{expected_result}"]
|
|
1415
|
-
case_data["precondition"] = f"前置条件:\\n1. 系统已启动\\n2. 接口服务正常运行\\n3. 接口地址:{interface_url}\\n4. 请求方法:{request_method}"
|
|
1416
|
-
else:
|
|
1417
|
-
steps = ["打开系统页面", "执行相关操作", "验证功能结果"]
|
|
1418
|
-
expects = ["系统页面正常打开", "操作执行成功",
|
|
1419
|
-
row.get('预期结果', '功能符合预期')]
|
|
1420
|
-
case_data["precondition"] = "前置条件:\\n1. 系统已启动\\n2. 用户已登录"
|
|
1421
|
-
|
|
1422
|
-
for i, step in enumerate(steps, 1):
|
|
1423
|
-
case_data[f"steps[{i}]"] = step
|
|
1424
|
-
for i, expect in enumerate(expects, 1):
|
|
1425
|
-
case_data[f"expects[{i}]"] = expect
|
|
1426
|
-
|
|
1427
|
-
result = self.create_case(case_data)
|
|
1428
|
-
result['case_name'] = row['用例名称']
|
|
1429
|
-
results.append(result)
|
|
1430
|
-
|
|
1431
|
-
if result.get('status') == 1:
|
|
1432
|
-
row['用例ID'] = result.get('data', {}).get('id', result.get('info', ''))
|
|
1433
|
-
else:
|
|
1434
|
-
row['用例ID'] = f"创建失败: {result.get('info', '未知错误')}"
|
|
1435
|
-
updated_rows.append(row)
|
|
1481
|
+
for idx, row in enumerate(rows):
|
|
1482
|
+
title = (row.get(title_col) or '').strip()
|
|
1483
|
+
if not title:
|
|
1484
|
+
continue
|
|
1436
1485
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1486
|
+
kw = (row.get('关键词') or '').strip()
|
|
1487
|
+
pri = (row.get('优先级') or '').strip()
|
|
1488
|
+
|
|
1489
|
+
case = {
|
|
1490
|
+
'title': title,
|
|
1491
|
+
'module': '0',
|
|
1492
|
+
'story': '',
|
|
1493
|
+
'pri': pri if pri in ('1', '2', '3', '4') else '3',
|
|
1494
|
+
'type': case_type,
|
|
1495
|
+
'stage': '',
|
|
1496
|
+
'keywords': f"{kw} {time_str}" if kw else f"ai {time_str}",
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if has_rich_steps:
|
|
1500
|
+
raw_steps = row.get('步骤', '')
|
|
1501
|
+
raw_expects = row.get('预期', '')
|
|
1502
|
+
case['stepDesc'] = raw_steps.strip()
|
|
1503
|
+
case['stepExpect'] = raw_expects.strip()
|
|
1504
|
+
precond = (row.get('前置条件') or '').strip()
|
|
1505
|
+
case['precondition'] = precond if precond else ''
|
|
1506
|
+
elif case_type == "interface":
|
|
1507
|
+
interface_url = row.get('接口地址', '')
|
|
1508
|
+
request_method = row.get('请求方法', 'POST')
|
|
1509
|
+
request_body = row.get('请求体', '')
|
|
1510
|
+
assertion = row.get('断言', '')
|
|
1511
|
+
expected_result = row.get('预期结果', '')
|
|
1512
|
+
steps = ["准备测试数据", f"发送请求体:{request_body}",
|
|
1513
|
+
f"调用接口 {interface_url}", "验证响应结果"]
|
|
1514
|
+
expects = ["测试数据准备完成", f"请求体格式正确:{request_body}",
|
|
1515
|
+
f"接口调用成功,方法:{request_method}",
|
|
1516
|
+
f"断言验证:{assertion},预期结果:{expected_result}"]
|
|
1517
|
+
case['stepDesc'] = self._build_numbered_text(steps)
|
|
1518
|
+
case['stepExpect'] = self._build_numbered_text(expects)
|
|
1519
|
+
case['precondition'] = (
|
|
1520
|
+
f"1. 系统已启动\n2. 接口服务正常运行"
|
|
1521
|
+
f"\n3. 接口地址:{interface_url}\n4. 请求方法:{request_method}"
|
|
1522
|
+
)
|
|
1523
|
+
else:
|
|
1524
|
+
steps = ["打开系统页面", "执行相关操作", "验证功能结果"]
|
|
1525
|
+
expects = ["系统页面正常打开", "操作执行成功",
|
|
1526
|
+
row.get('预期结果', '功能符合预期')]
|
|
1527
|
+
case['stepDesc'] = self._build_numbered_text(steps)
|
|
1528
|
+
case['stepExpect'] = self._build_numbered_text(expects)
|
|
1529
|
+
case['precondition'] = "1. 系统已启动\n2. 用户已登录"
|
|
1530
|
+
|
|
1531
|
+
batch_cases.append(case)
|
|
1532
|
+
valid_row_indices.append(idx)
|
|
1533
|
+
|
|
1534
|
+
if not batch_cases:
|
|
1535
|
+
return [{"status": 0, "message": "CSV 中无有效用例行"}]
|
|
1536
|
+
|
|
1537
|
+
result = self.client.testcase_import_batch(self.product_id, batch_cases)
|
|
1538
|
+
ok = result.get('status') == 1
|
|
1539
|
+
|
|
1540
|
+
if ok:
|
|
1541
|
+
latest = self.client.testcase_browse(
|
|
1542
|
+
self.product_id, orderBy='id_desc',
|
|
1543
|
+
recPerPage=str(len(batch_cases) + 5))
|
|
1544
|
+
latest_cases = latest.get("data", {}).get("cases", [])
|
|
1545
|
+
if isinstance(latest_cases, dict):
|
|
1546
|
+
latest_cases = list(latest_cases.values())
|
|
1547
|
+
title_to_id = {}
|
|
1548
|
+
for c in latest_cases:
|
|
1549
|
+
t = str(c.get('title', '')).strip()
|
|
1550
|
+
cid = str(c.get('id', '')).strip()
|
|
1551
|
+
if t and cid and t not in title_to_id:
|
|
1552
|
+
title_to_id[t] = cid
|
|
1553
|
+
|
|
1554
|
+
results = []
|
|
1555
|
+
for i, case in enumerate(batch_cases):
|
|
1556
|
+
row_idx = valid_row_indices[i]
|
|
1557
|
+
entry = {'case_name': case['title'], 'status': 1 if ok else 0}
|
|
1558
|
+
if ok:
|
|
1559
|
+
cid = title_to_id.get(case['title'], '')
|
|
1560
|
+
entry['caseID'] = cid
|
|
1561
|
+
rows[row_idx]['用例ID'] = cid
|
|
1562
|
+
else:
|
|
1563
|
+
entry['info'] = result.get('info', result.get('message', '批量导入失败'))
|
|
1564
|
+
rows[row_idx]['用例ID'] = f"失败: {entry['info']}"
|
|
1565
|
+
results.append(entry)
|
|
1566
|
+
|
|
1567
|
+
for idx, row in enumerate(rows):
|
|
1568
|
+
if idx not in valid_row_indices:
|
|
1569
|
+
row.setdefault('用例ID', '')
|
|
1570
|
+
|
|
1571
|
+
if '用例ID' not in fieldnames:
|
|
1572
|
+
fieldnames.append('用例ID')
|
|
1573
|
+
try:
|
|
1439
1574
|
with open(csv_file_path, 'w', encoding='utf-8', newline='') as f:
|
|
1440
1575
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
1441
1576
|
writer.writeheader()
|
|
1442
|
-
writer.writerows(
|
|
1443
|
-
|
|
1577
|
+
writer.writerows(rows)
|
|
1444
1578
|
except Exception as e:
|
|
1445
|
-
|
|
1579
|
+
logger.warning("写回CSV失败: %s", e)
|
|
1446
1580
|
|
|
1447
1581
|
return results
|
|
1448
1582
|
|
|
@@ -1457,10 +1591,10 @@ class ZenTaoNexus:
|
|
|
1457
1591
|
|
|
1458
1592
|
results = self.create_case_from_csv(csv_file_path, case_type)
|
|
1459
1593
|
success_count = sum(1 for r in results if r.get('status') == 1)
|
|
1460
|
-
failed_count =
|
|
1594
|
+
failed_count = sum(1 for r in results if r.get('status') != 1)
|
|
1461
1595
|
|
|
1462
1596
|
return {
|
|
1463
|
-
"status": "success", "message": "CSV
|
|
1597
|
+
"status": "success", "message": "CSV批量导入完成(showImport接口)",
|
|
1464
1598
|
"total_cases": len(results), "success_count": success_count,
|
|
1465
1599
|
"failed_count": failed_count,
|
|
1466
1600
|
"success_rate": (success_count / len(results) * 100) if results else 0.0,
|
|
@@ -1474,10 +1608,14 @@ class ZenTaoNexus:
|
|
|
1474
1608
|
|
|
1475
1609
|
# ─────────── 我的地盘 ───────────
|
|
1476
1610
|
|
|
1611
|
+
_MY_BUG_TYPE_MAP = {
|
|
1612
|
+
'assigntome': 'assignedTo',
|
|
1613
|
+
'assignedtome': 'assignedTo',
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1477
1616
|
def get_my_bug(self, type_='assigntome') -> Dict:
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
return self.client.my_bug(type_)
|
|
1617
|
+
api_type = self._MY_BUG_TYPE_MAP.get(type_, type_)
|
|
1618
|
+
return self.client.my_bug(api_type)
|
|
1481
1619
|
|
|
1482
1620
|
def get_my_task(self, type_='assignedtome') -> Dict:
|
|
1483
1621
|
return self.client.my_task(type_)
|
|
@@ -1490,7 +1628,7 @@ class ZenTaoNexus:
|
|
|
1490
1628
|
|
|
1491
1629
|
def get_my_work(self) -> Dict:
|
|
1492
1630
|
"""获取"我的待办"概览"""
|
|
1493
|
-
bugs = self.client.my_bug('
|
|
1631
|
+
bugs = self.client.my_bug('assignedTo')
|
|
1494
1632
|
tasks = self.client.my_task('assignedtome')
|
|
1495
1633
|
stories = self.client.my_story('assignedtome')
|
|
1496
1634
|
todos = self.client.my_todo('all')
|
|
@@ -8,11 +8,14 @@ from .config import build_user_context
|
|
|
8
8
|
logger = logging.getLogger("zentaoagent")
|
|
9
9
|
audit_logger = logging.getLogger("zentao_audit")
|
|
10
10
|
from .intent_parser import parse_intent, IntentResult
|
|
11
|
-
from .session_manager import
|
|
12
|
-
|
|
11
|
+
from .session_manager import (
|
|
12
|
+
get_session, create_session, update_session, clear_session, is_cancel_keyword,
|
|
13
|
+
get_last_bug_ids, set_last_action, get_last_action,
|
|
14
|
+
)
|
|
13
15
|
from .handlers import HANDLERS
|
|
14
16
|
from .formatters import format_unbound_user, format_unknown, format_error
|
|
15
17
|
from .zentao_client import ZentaoPermissionError
|
|
18
|
+
from . import llm_client
|
|
16
19
|
|
|
17
20
|
_ALLOWED_INTENTS = frozenset(HANDLERS.keys()) | {"unknown"}
|
|
18
21
|
|
|
@@ -50,6 +53,7 @@ def dispatch(
|
|
|
50
53
|
session_webhook: str,
|
|
51
54
|
conversation_id: str,
|
|
52
55
|
conversation_type: int,
|
|
56
|
+
image_paths: list[str] | None = None,
|
|
53
57
|
) -> str:
|
|
54
58
|
user_context = build_user_context(staff_id, staff_nick, session_webhook, conversation_id, conversation_type)
|
|
55
59
|
if not user_context:
|
|
@@ -68,7 +72,9 @@ def dispatch(
|
|
|
68
72
|
if pending and content.strip().lower() in _CONFIRM_KEYWORDS:
|
|
69
73
|
_pending_confirms.pop(session_key, None)
|
|
70
74
|
logger.info("二次确认通过 intent=%s session=%s", pending.intent, session_key)
|
|
71
|
-
|
|
75
|
+
reply = _execute_handler(pending, user_context)
|
|
76
|
+
set_last_action(session_key, pending.intent, pending.params, reply)
|
|
77
|
+
return reply
|
|
72
78
|
elif pending:
|
|
73
79
|
_pending_confirms.pop(session_key, None)
|
|
74
80
|
|
|
@@ -81,11 +87,12 @@ def dispatch(
|
|
|
81
87
|
)
|
|
82
88
|
merged_params = {**session_state.params, **intent_result.params}
|
|
83
89
|
intent_result.params = merged_params
|
|
84
|
-
intent_result.missing_params = [p for p in intent_result.missing_params if
|
|
90
|
+
intent_result.missing_params = [p for p in intent_result.missing_params if not merged_params.get(p)]
|
|
85
91
|
if intent_result.intent == "bug_close":
|
|
86
92
|
_fix_bug_close_missing(intent_result, session_key)
|
|
87
93
|
if intent_result.intent == "bug_resolve":
|
|
88
94
|
_fix_bug_resolve_missing(intent_result)
|
|
95
|
+
_inject_image_paths(intent_result, image_paths)
|
|
89
96
|
if intent_result.missing_params:
|
|
90
97
|
_enrich_product_suggestion(intent_result, user_context)
|
|
91
98
|
if intent_result.intent == "bug_close" and ("bug_id" in intent_result.missing_params or "bug_ids" in intent_result.missing_params):
|
|
@@ -103,16 +110,22 @@ def dispatch(
|
|
|
103
110
|
return _format_missing_params(intent_result.missing_params)
|
|
104
111
|
clear_session(session_key)
|
|
105
112
|
else:
|
|
106
|
-
|
|
113
|
+
last_action = get_last_action(session_key)
|
|
114
|
+
intent_result = parse_intent(content, user_context, last_action=last_action)
|
|
107
115
|
|
|
108
|
-
if intent_result.confidence < 0.6 and intent_result.clarify_question:
|
|
116
|
+
if intent_result.intent != "unknown" and intent_result.confidence < 0.6 and intent_result.clarify_question:
|
|
109
117
|
return intent_result.clarify_question
|
|
110
118
|
|
|
119
|
+
if intent_result.intent == "unknown" or intent_result.intent not in HANDLERS:
|
|
120
|
+
return _handle_general_chat(content, image_paths, user_context)
|
|
121
|
+
|
|
111
122
|
if intent_result.intent == "bug_close":
|
|
112
123
|
_fix_bug_close_missing(intent_result, session_key)
|
|
113
124
|
if intent_result.intent == "bug_resolve":
|
|
114
125
|
_fix_bug_resolve_missing(intent_result)
|
|
115
126
|
|
|
127
|
+
_inject_image_paths(intent_result, image_paths)
|
|
128
|
+
|
|
116
129
|
if intent_result.missing_params:
|
|
117
130
|
_enrich_product_suggestion(intent_result, user_context)
|
|
118
131
|
if intent_result.intent == "bug_close" and ("bug_id" in intent_result.missing_params or "bug_ids" in intent_result.missing_params):
|
|
@@ -139,7 +152,9 @@ def dispatch(
|
|
|
139
152
|
logger.info("意图解析 intent=%s confidence=%.2f params=%s missing=%s",
|
|
140
153
|
intent_result.intent, intent_result.confidence,
|
|
141
154
|
intent_result.params, intent_result.missing_params)
|
|
142
|
-
|
|
155
|
+
reply = _execute_handler(intent_result, user_context)
|
|
156
|
+
set_last_action(session_key, intent_result.intent, intent_result.params, reply)
|
|
157
|
+
return reply
|
|
143
158
|
|
|
144
159
|
|
|
145
160
|
_WRITE_INTENTS = frozenset({
|
|
@@ -178,6 +193,16 @@ def _execute_handler(intent_result: IntentResult, user_context) -> str:
|
|
|
178
193
|
return format_error(str(e))
|
|
179
194
|
|
|
180
195
|
|
|
196
|
+
def _inject_image_paths(intent_result: IntentResult, image_paths: list[str] | None) -> None:
|
|
197
|
+
"""当用户通过钉钉直接发图且意图需要文件时,自动注入本地文件路径。"""
|
|
198
|
+
if not image_paths:
|
|
199
|
+
return
|
|
200
|
+
if intent_result.intent == "bug_upload" and not intent_result.params.get("file_path"):
|
|
201
|
+
intent_result.params["file_path"] = image_paths[0]
|
|
202
|
+
intent_result.missing_params = [p for p in intent_result.missing_params if p != "file_path"]
|
|
203
|
+
logger.info("自动注入图片路径 file_path=%s", image_paths[0])
|
|
204
|
+
|
|
205
|
+
|
|
181
206
|
def _fix_bug_resolve_missing(intent_result: IntentResult) -> None:
|
|
182
207
|
"""resolution 默认 fixed,不追问用户"""
|
|
183
208
|
if "resolution" in intent_result.missing_params:
|
|
@@ -246,3 +271,22 @@ def _format_missing_params(missing_params: list) -> str:
|
|
|
246
271
|
lines.append(f"{i}️⃣ {name}是什么?")
|
|
247
272
|
lines.append("\n直接告诉我就行,比如「登录报错,指派给张三」")
|
|
248
273
|
return "\n".join(lines)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _handle_general_chat(content: str, image_paths: list[str] | None, user_context) -> str:
|
|
277
|
+
"""非禅道意图 → 千问通用问答(含图片分析),失败时回退到 format_unknown"""
|
|
278
|
+
try:
|
|
279
|
+
from .intent_parser import _load_soul
|
|
280
|
+
soul = _load_soul()
|
|
281
|
+
reply = llm_client.general_chat(
|
|
282
|
+
content,
|
|
283
|
+
image_paths=image_paths,
|
|
284
|
+
user_name=user_context.zentao_name,
|
|
285
|
+
soul_content=soul,
|
|
286
|
+
)
|
|
287
|
+
if reply:
|
|
288
|
+
logger.info("通用问答回复 user=%s reply=%s", user_context.zentao_code, reply[:100])
|
|
289
|
+
return reply
|
|
290
|
+
except Exception:
|
|
291
|
+
logger.exception("通用问答异常")
|
|
292
|
+
return format_unknown()
|