@bangdao-ai/zentao-mcp 2.0.5 → 2.0.7

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/main.py CHANGED
@@ -14,15 +14,24 @@ from dingtalk_stream import DingTalkStreamClient, Credential, ChatbotMessage
14
14
 
15
15
  from zenbot.config import JsonFormatter
16
16
  from zenbot.dingtalk_handler import ZentaoBotHandler
17
+ from zenbot.logging_utils import struct_log
17
18
 
18
19
  load_dotenv()
19
20
 
20
- _stderr_handler = logging.StreamHandler(sys.stderr)
21
- _stderr_handler.setFormatter(JsonFormatter())
21
+ def _setup_structured_logging() -> None:
22
+ fmt = JsonFormatter()
23
+ h = logging.StreamHandler(sys.stderr)
24
+ h.setFormatter(fmt)
25
+ for name in ("zentaoagent", "zentao_audit", "zentao_security"):
26
+ lg = logging.getLogger(name)
27
+ lg.handlers.clear()
28
+ lg.propagate = False
29
+ lg.addHandler(h)
30
+ lg.setLevel(logging.INFO)
22
31
 
32
+
33
+ _setup_structured_logging()
23
34
  logger = logging.getLogger("zentaoagent")
24
- logger.addHandler(_stderr_handler)
25
- logger.setLevel(logging.INFO)
26
35
 
27
36
 
28
37
  def main():
@@ -36,7 +45,7 @@ def main():
36
45
  bot_handler = ZentaoBotHandler()
37
46
  client = DingTalkStreamClient(credential, logger)
38
47
  client.register_callback_handler(ChatbotMessage.TOPIC, bot_handler)
39
- logger.info("ZenBot v2 启动中...")
48
+ struct_log(logger, logging.INFO, "zenbot_startup", component="main", version="v2")
40
49
  client.start_forever()
41
50
 
42
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/zentao-mcp",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "禅道项目管理AI中枢 v2.0 — 35+MCP工具覆盖需求·任务·Bug·用例·发布·我的地盘全生命周期,原生API模式,Bug智能分类引擎+CSV批量用例导入+截图上传,一句话驱动禅道",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -15,6 +15,12 @@ import csv
15
15
  import shutil
16
16
  import requests
17
17
 
18
+ try:
19
+ from zenbot.logging_utils import struct_log as _struct_log
20
+ except ImportError:
21
+ def _struct_log(logger, level, event, msg=None, component=None, **ctx):
22
+ logger.log(level, f"{event} {ctx}")
23
+
18
24
  logger = logging.getLogger("zentaoagent")
19
25
  _security_logger = logging.getLogger("zentao_security")
20
26
 
@@ -26,7 +32,12 @@ def _init_allowed_dirs():
26
32
  if not _ALLOWED_WORK_DIRS:
27
33
  cwd = os.path.realpath(os.getcwd())
28
34
  tmp = os.path.realpath(os.getenv("TMPDIR", "/tmp"))
29
- _ALLOWED_WORK_DIRS.extend([cwd, tmp])
35
+ home = os.path.realpath(os.path.expanduser("~"))
36
+ candidates = [cwd, tmp, home, "/tmp"]
37
+ for d in candidates:
38
+ real_d = os.path.realpath(d)
39
+ if real_d and real_d not in _ALLOWED_WORK_DIRS:
40
+ _ALLOWED_WORK_DIRS.append(real_d)
30
41
 
31
42
 
32
43
  def _safe_path(path: str, must_exist: bool = False) -> str:
@@ -128,7 +139,12 @@ class ZenTaoNativeClient:
128
139
  safe_form = {k: (v if k not in ('password',) else '***') for k, v in form_data.items()}
129
140
  else:
130
141
  safe_form = None
131
- logger.info("禅道请求 m=%s&f=%s params=%s form=%s", module, action, safe_params, safe_form)
142
+ _struct_log(
143
+ logger, logging.INFO, "zentao_api_request",
144
+ component="zentao_api",
145
+ m=module, f=action, url_params=safe_params,
146
+ form_preview=safe_form,
147
+ )
132
148
 
133
149
  if not self.silent:
134
150
  self._log_request(url_params, form_data, files)
@@ -149,11 +165,22 @@ class ZenTaoNativeClient:
149
165
  resp_summary = json.dumps(result, ensure_ascii=False)
150
166
  if len(resp_summary) > 1000:
151
167
  resp_summary = resp_summary[:1000] + f"...(truncated, total {len(resp_summary)} chars)"
152
- logger.info("禅道响应 m=%s&f=%s http=%d body=%s", module, action, resp.status_code, resp_summary)
168
+ st = result.get("status")
169
+ info = result.get("info") or result.get("message") or ""
170
+ _struct_log(
171
+ logger, logging.INFO, "zentao_api_response",
172
+ component="zentao_api",
173
+ m=module, f=action, http_status=resp.status_code,
174
+ api_status=st, info_preview=str(info)[:400],
175
+ body_truncated=len(resp_summary) > 1000,
176
+ )
153
177
  return result
154
178
 
155
179
  except requests.exceptions.RequestException as e:
156
- logger.error("禅道请求异常 m=%s&f=%s error=%s", module, action, e)
180
+ _struct_log(
181
+ logger, logging.ERROR, "zentao_api_transport_error",
182
+ component="zentao_api", m=module, f=action, error=str(e),
183
+ )
157
184
  return {"status": 0, "message": "fail", "info": f"请求失败: {e}", "data": {}}
158
185
 
159
186
  def _prepare_form_data(self, form_data: Optional[Dict]) -> Optional[list]:
@@ -831,6 +858,18 @@ class ZenTaoNativeClient:
831
858
 
832
859
  # ==================== 业务封装层 ====================
833
860
 
861
+
862
+ class AmbiguousIdError(Exception):
863
+ """ID 同时匹配产品和项目,需要用户澄清"""
864
+ def __init__(self, numeric_id: str, product_name: str | None, project_name: str | None,
865
+ product_projects: list | None = None):
866
+ self.numeric_id = numeric_id
867
+ self.product_name = product_name
868
+ self.project_name = project_name
869
+ self.product_projects = product_projects or []
870
+ super().__init__(f"ID {numeric_id} 同时是产品({product_name})和项目({project_name})")
871
+
872
+
834
873
  class ZenTaoNexus:
835
874
  """
836
875
  ZenTao Nexus v2.0 — 业务封装层
@@ -879,6 +918,72 @@ class ZenTaoNexus:
879
918
  return pid
880
919
  return self.product_id
881
920
 
921
+ def smart_resolve_product_id(self, user_input_id: Optional[str] = None) -> Dict:
922
+ """
923
+ 智能解析用户给的数字 ID,判断它到底是 product 还是 project/execution。
924
+
925
+ DB 优先(1~2 条 SQL),API 兜底(2 次 HTTP)。
926
+
927
+ Returns:
928
+ {"product_id": str|None, ← 最终可用的产品 ID
929
+ "input_type": "product"|"project"|"unknown",
930
+ "project_id": str|None, ← 如果输入的是项目ID
931
+ "product_name": str|None,
932
+ "project_name": str|None,
933
+ "corrected": bool} ← True 表示用户给的不是产品,做了纠正
934
+ """
935
+ uid = str(user_input_id or "").strip()
936
+ if not uid or uid == "0":
937
+ fallback = self.resolve_product_id()
938
+ return {"product_id": fallback if fallback != "0" else None,
939
+ "input_type": "unknown", "project_id": None,
940
+ "product_name": None, "project_name": None, "corrected": False}
941
+
942
+ # ── 1) DB 快速路径 ──
943
+ try:
944
+ from zenbot.db_resolver import identify_id_type_from_db
945
+ db_result = identify_id_type_from_db(uid)
946
+ if db_result:
947
+ if db_result["type"] == "product":
948
+ _save_last_product_id(uid)
949
+ return {"product_id": uid, "input_type": "product",
950
+ "project_id": None, "product_name": db_result.get("product_name"),
951
+ "project_name": None, "corrected": False}
952
+ else:
953
+ real_pid = db_result.get("product_id")
954
+ if real_pid:
955
+ _save_last_product_id(real_pid)
956
+ _struct_log(logger, logging.INFO, "smart_resolve_corrected",
957
+ component="zentao_nexus",
958
+ user_input=uid, actual_type="project",
959
+ resolved_product_id=real_pid,
960
+ project_name=db_result.get("project_name"))
961
+ return {"product_id": real_pid, "input_type": "project",
962
+ "project_id": uid,
963
+ "product_name": db_result.get("product_name"),
964
+ "project_name": db_result.get("project_name"),
965
+ "corrected": True}
966
+ except Exception as e:
967
+ _struct_log(logger, logging.DEBUG, "smart_resolve_db_skip",
968
+ component="zentao_nexus", error=str(e))
969
+
970
+ # ── 2) API 兜底 ──
971
+ id_info = self._identify_id_type(uid)
972
+ if id_info["type"] == "product":
973
+ _save_last_product_id(uid)
974
+ return {"product_id": uid, "input_type": "product",
975
+ "project_id": None, "product_name": id_info.get("product_name"),
976
+ "project_name": None, "corrected": False}
977
+ elif id_info["type"] == "project":
978
+ return {"product_id": None, "input_type": "project",
979
+ "project_id": uid, "product_name": None,
980
+ "project_name": None, "corrected": True}
981
+ else:
982
+ _save_last_product_id(uid)
983
+ return {"product_id": uid, "input_type": "unknown",
984
+ "project_id": None, "product_name": None,
985
+ "project_name": None, "corrected": False}
986
+
882
987
  def set_product_id(self, product_id: str) -> Dict:
883
988
  pid = str(product_id).strip()
884
989
  if not pid or pid == "0":
@@ -1104,6 +1209,19 @@ class ZenTaoNexus:
1104
1209
  return self.client.task_view(task_id)
1105
1210
 
1106
1211
  def create_task(self, task_data: Dict) -> Dict:
1212
+ _TASK_ALIASES = {
1213
+ "product_id": "product",
1214
+ "project_id": "projectID",
1215
+ "execution_id": "projectID",
1216
+ "assigned_to": "assignedTo",
1217
+ "est_started": "estStarted",
1218
+ }
1219
+ for alias, canonical in _TASK_ALIASES.items():
1220
+ if alias in task_data and canonical not in task_data:
1221
+ task_data[canonical] = task_data.pop(alias)
1222
+ elif alias in task_data:
1223
+ task_data.pop(alias)
1224
+
1107
1225
  task_name = str(task_data.get('name', '') or '').strip()
1108
1226
  if task_data.get('assignedTo'):
1109
1227
  task_data['assignedTo'] = self.resolve_user_account(task_data['assignedTo']) or task_data['assignedTo']
@@ -1118,7 +1236,8 @@ class ZenTaoNexus:
1118
1236
 
1119
1237
  product_id = str(task_data.get('product') or self.product_id)
1120
1238
  requested_project_id = task_data.pop('project', task_data.pop('projectID', None))
1121
- project_id = self._resolve_project_id_for_product(product_id, requested_project_id)
1239
+ project_id, product_id = self._resolve_project_and_product(
1240
+ product_id, requested_project_id)
1122
1241
 
1123
1242
  # 1) 优先走稳定网关创建(可直接返回 taskID)
1124
1243
  gateway_payload = dict(task_data)
@@ -1218,27 +1337,135 @@ class ZenTaoNexus:
1218
1337
  """查询产品下可见项目列表。"""
1219
1338
  return self.client.product_project_pairs(product_id)
1220
1339
 
1221
- def _resolve_project_id_for_product(self, product_id, requested_project_id=None) -> str:
1340
+ def _identify_id_type(self, unknown_id: str) -> Dict:
1341
+ """
1342
+ 探测一个未知数字 ID 到底是 project(执行)、product(产品)、还是两者都是。
1343
+
1344
+ 策略:同时检查 product 和 project,若两者都存在则返回 "both"。
1345
+
1346
+ Returns:
1347
+ {"type": "product"|"project"|"both"|"unknown",
1348
+ "product_id": str|None, "project_id": str|None,
1349
+ "product_name": str|None, "project_name": str|None,
1350
+ "projects": list}
1351
+ """
1352
+ uid = str(unknown_id).strip()
1353
+ if not uid or uid == "0":
1354
+ return {"type": "unknown", "product_id": None, "project_id": None,
1355
+ "product_name": None, "project_name": None, "projects": []}
1356
+
1357
+ is_product = False
1358
+ product_name = None
1359
+ product_projects = []
1360
+
1361
+ is_project = False
1362
+ project_name = None
1363
+ project_product_id = None
1364
+ project_product_name = None
1365
+
1366
+ # ── 检查是否为 product ──
1367
+ product_result = self.client.product_view(uid)
1368
+ if product_result.get("status") in (1, "success"):
1369
+ data = product_result.get("data", {})
1370
+ if isinstance(data, dict) and data.get("id"):
1371
+ is_product = True
1372
+ product_name = data.get("name", "")
1373
+ product_projects = self._get_projects_for_product(uid)
1374
+
1375
+ if not is_product:
1376
+ proj_result = self.client.product_project_pairs(uid)
1377
+ if proj_result.get("status") == 1:
1378
+ projects = proj_result.get("data", {}).get("projects", [])
1379
+ if projects:
1380
+ is_product = True
1381
+ product_projects = projects
1382
+
1383
+ # ── 检查是否为 project(查 zt_project 表或通过 task browse 探测) ──
1384
+ try:
1385
+ from zenbot.db_resolver import check_project_exists_from_db
1386
+ proj_info = check_project_exists_from_db(uid)
1387
+ if proj_info:
1388
+ is_project = True
1389
+ project_name = proj_info.get("project_name")
1390
+ project_product_id = proj_info.get("product_id")
1391
+ project_product_name = proj_info.get("product_name")
1392
+ except Exception:
1393
+ pass
1394
+
1395
+ if is_product and is_project:
1396
+ return {"type": "both", "product_id": uid, "project_id": uid,
1397
+ "product_name": product_name, "project_name": project_name,
1398
+ "project_product_id": project_product_id,
1399
+ "project_product_name": project_product_name,
1400
+ "projects": product_projects}
1401
+ elif is_product:
1402
+ return {"type": "product", "product_id": uid,
1403
+ "project_id": product_projects[0]["id"] if product_projects else None,
1404
+ "product_name": product_name, "project_name": None,
1405
+ "projects": product_projects}
1406
+ elif is_project:
1407
+ return {"type": "project", "product_id": project_product_id,
1408
+ "project_id": uid,
1409
+ "product_name": project_product_name, "project_name": project_name,
1410
+ "projects": []}
1411
+ else:
1412
+ return {"type": "unknown", "product_id": None, "project_id": uid,
1413
+ "product_name": None, "project_name": None, "projects": []}
1414
+
1415
+ def _get_projects_for_product(self, product_id: str) -> list:
1416
+ """获取产品下的项目列表,返回 [{"id": "...", "name": "..."}]"""
1417
+ result = self.client.product_project_pairs(product_id)
1418
+ if result.get("status") == 1:
1419
+ return result.get("data", {}).get("projects", [])
1420
+ return []
1421
+
1422
+ def _resolve_project_and_product(self, product_id, requested_project_id=None):
1222
1423
  """
1223
- 解析任务创建所需 projectID:
1224
- 1) 优先使用调用方显式传入的 project/projectID
1225
- 2) 自动从产品下项目列表选择第一个可见项目
1226
- 3) 兜底返回 "0"(由上层响应提示权限/关联问题)
1424
+ 智能解析任务创建所需的 (project_id, product_id)。
1425
+
1426
+ 策略:
1427
+ 1) 用户显式传了 project_id → 探测真实类型
1428
+ - "project" → 直接信任
1429
+ - "both"(同时是产品和项目) → 抛 AmbiguousIdError,让上层追问用户
1430
+ - "product"(纯产品) → 修正 product_id,从该产品下取第一个项目
1431
+ 2) 没有显式传入 → 从 product_id 的关联项目列表取第一个
1432
+ 3) 兜底 project_id="0"
1433
+
1434
+ Returns: (project_id: str, product_id: str)
1435
+ Raises: AmbiguousIdError — 当 ID 同时是产品和项目时
1227
1436
  """
1228
1437
  if requested_project_id is not None:
1229
1438
  pid = str(requested_project_id).strip()
1230
1439
  if pid and pid != "0":
1231
- return pid
1440
+ id_info = self._identify_id_type(pid)
1441
+ _struct_log(logger, logging.INFO, "id_type_detection",
1442
+ component="zentao_nexus",
1443
+ input_id=pid, detected_type=id_info["type"],
1444
+ product_id=id_info.get("product_id"),
1445
+ project_id=id_info.get("project_id"),
1446
+ product_name=id_info.get("product_name"),
1447
+ project_name=id_info.get("project_name"))
1448
+
1449
+ if id_info["type"] == "both":
1450
+ raise AmbiguousIdError(
1451
+ numeric_id=pid,
1452
+ product_name=id_info.get("product_name"),
1453
+ project_name=id_info.get("project_name"),
1454
+ product_projects=id_info.get("projects", []),
1455
+ )
1456
+ elif id_info["type"] == "product":
1457
+ real_product_id = id_info["product_id"] or pid
1458
+ real_project_id = id_info["project_id"]
1459
+ if real_project_id:
1460
+ return real_project_id, real_product_id
1461
+ product_id = real_product_id
1462
+ else:
1463
+ return pid, product_id
1232
1464
 
1233
- projects_result = self.get_product_projects(product_id)
1234
- if projects_result.get("status") == 1:
1235
- projects = projects_result.get("data", {}).get("projects", [])
1236
- if projects:
1237
- pid = str(projects[0].get("id", "")).strip()
1238
- if pid:
1239
- return pid
1465
+ projects = self._get_projects_for_product(product_id)
1466
+ if projects:
1467
+ return projects[0]["id"], product_id
1240
1468
 
1241
- # 尝试从需求数据中推断项目(个别环境下 assignedProjectId/formProjectId 可用)
1242
1469
  stories = self.get_story_list(product_id)
1243
1470
  if stories.get("status") == 1 and isinstance(stories.get("data"), list):
1244
1471
  for story in stories["data"]:
@@ -1247,9 +1474,9 @@ class ZenTaoNexus:
1247
1474
  for key in ("assignedProjectId", "formProjectId", "project", "projectID"):
1248
1475
  val = str(story.get(key, "")).strip()
1249
1476
  if val and val != "0":
1250
- return val
1477
+ return val, product_id
1251
1478
 
1252
- return "0"
1479
+ return "0", product_id
1253
1480
 
1254
1481
  def assign_task(self, task_id, assigned_to, comment=None) -> Dict:
1255
1482
  assigned_to = self.resolve_user_account(assigned_to) or assigned_to
@@ -1534,12 +1761,13 @@ class ZenTaoNexus:
1534
1761
  if not batch_cases:
1535
1762
  return [{"status": 0, "message": "CSV 中无有效用例行"}]
1536
1763
 
1537
- result = self.client.testcase_import_batch(self.product_id, batch_cases)
1764
+ pid = self.resolve_product_id()
1765
+ result = self.client.testcase_import_batch(pid, batch_cases)
1538
1766
  ok = result.get('status') == 1
1539
1767
 
1540
1768
  if ok:
1541
1769
  latest = self.client.testcase_browse(
1542
- self.product_id, orderBy='id_desc',
1770
+ pid, orderBy='id_desc',
1543
1771
  recPerPage=str(len(batch_cases) + 5))
1544
1772
  latest_cases = latest.get("data", {}).get("cases", [])
1545
1773
  if isinstance(latest_cases, dict):