@bangdao-ai/zentao-mcp 2.0.0 → 2.0.2
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/package.json +1 -1
- package/src/core/zentao_nexus.py +229 -136
- package/src/mcp/server.py +2 -2
- package/src/mcp_server.py +2 -2
- package/src/zentao_nexus.py +193 -110
package/package.json
CHANGED
package/src/core/zentao_nexus.py
CHANGED
|
@@ -36,7 +36,7 @@ def _safe_path(path: str, must_exist: bool = False) -> str:
|
|
|
36
36
|
"""
|
|
37
37
|
_init_allowed_dirs()
|
|
38
38
|
real = os.path.realpath(os.path.expanduser(path))
|
|
39
|
-
if not any(real.startswith(d) for d in _ALLOWED_WORK_DIRS):
|
|
39
|
+
if not any(real == d or real.startswith(d + os.sep) for d in _ALLOWED_WORK_DIRS):
|
|
40
40
|
_security_logger.warning("路径安全拒绝: %s (resolved: %s)", path, real)
|
|
41
41
|
raise ValueError(f"路径不在允许的工作目录内: {path}")
|
|
42
42
|
if must_exist and not os.path.exists(real):
|
|
@@ -1071,14 +1071,21 @@ class ZenTaoNexus:
|
|
|
1071
1071
|
if not gateway_payload.get("mold"):
|
|
1072
1072
|
gateway_payload["mold"] = "20"
|
|
1073
1073
|
result = self.client.task_create_by_gateway(gateway_payload)
|
|
1074
|
-
if result.get("status") == 1
|
|
1074
|
+
if result.get("status") == 1:
|
|
1075
|
+
tid = result.get("taskID")
|
|
1076
|
+
if not tid and task_name:
|
|
1077
|
+
tid = self._find_recent_task_id_by_name(task_name)
|
|
1078
|
+
if tid:
|
|
1079
|
+
result["taskID"] = tid
|
|
1075
1080
|
result["projectID"] = str(project_id)
|
|
1076
1081
|
result["create_channel"] = "gateway"
|
|
1082
|
+
if not tid:
|
|
1083
|
+
result["warning"] = "任务已通过网关创建成功,但无法获取 taskID"
|
|
1077
1084
|
return result
|
|
1078
1085
|
|
|
1079
1086
|
gateway_error = result
|
|
1080
1087
|
|
|
1081
|
-
# 2) 网关失败时回退到原生 task.create
|
|
1088
|
+
# 2) 网关失败时回退到原生 task.create
|
|
1082
1089
|
native_result = self.client.task_create(project_id, task_data)
|
|
1083
1090
|
if native_result.get("status") == 1:
|
|
1084
1091
|
data = native_result.get("data", {})
|
|
@@ -1087,25 +1094,18 @@ class ZenTaoNexus:
|
|
|
1087
1094
|
tid = self._find_recent_task_id_by_name(task_name)
|
|
1088
1095
|
if tid:
|
|
1089
1096
|
native_result["taskID"] = tid
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
}
|
|
1101
|
-
else:
|
|
1102
|
-
result = dict(native_result) if isinstance(native_result, dict) else {
|
|
1103
|
-
"status": 0, "message": "fail", "info": str(native_result), "data": {}
|
|
1104
|
-
}
|
|
1105
|
-
result["gateway_error"] = gateway_error
|
|
1097
|
+
native_result["projectID"] = str(project_id)
|
|
1098
|
+
native_result["create_channel"] = "native"
|
|
1099
|
+
if not tid:
|
|
1100
|
+
native_result["warning"] = "任务已通过原生接口创建成功,但无法获取 taskID"
|
|
1101
|
+
return native_result
|
|
1102
|
+
|
|
1103
|
+
result = dict(native_result) if isinstance(native_result, dict) else {
|
|
1104
|
+
"status": 0, "message": "fail", "info": str(native_result), "data": {}
|
|
1105
|
+
}
|
|
1106
|
+
result["gateway_error"] = gateway_error
|
|
1106
1107
|
|
|
1107
1108
|
if str(project_id) == "0":
|
|
1108
|
-
# 便于排查:当无法自动选出项目时返回候选项
|
|
1109
1109
|
candidates = self.get_product_projects(product_id)
|
|
1110
1110
|
if candidates.get("status") == 1:
|
|
1111
1111
|
result["project_candidates"] = candidates.get("data", {}).get("projects", [])
|
|
@@ -1333,20 +1333,29 @@ class ZenTaoNexus:
|
|
|
1333
1333
|
case_id = self._find_recent_case_id_by_name(case_title)
|
|
1334
1334
|
if case_id:
|
|
1335
1335
|
result["caseID"] = case_id
|
|
1336
|
-
|
|
1336
|
+
result["create_channel"] = "native"
|
|
1337
|
+
if not case_id:
|
|
1338
|
+
result["warning"] = "用例已通过原生接口创建成功,但无法获取 caseID"
|
|
1339
|
+
return result
|
|
1337
1340
|
|
|
1338
|
-
#
|
|
1341
|
+
# 原生创建失败时,回退到网关创建
|
|
1339
1342
|
gateway_payload = dict(case_data)
|
|
1340
1343
|
gateway_result = self.client.testcase_create_by_gateway(gateway_payload)
|
|
1341
|
-
if gateway_result.get("status") == 1
|
|
1344
|
+
if gateway_result.get("status") == 1:
|
|
1342
1345
|
gateway_result["create_channel"] = "gateway"
|
|
1346
|
+
if not gateway_result.get("caseID") and case_title:
|
|
1347
|
+
cid = self._find_recent_case_id_by_name(case_title)
|
|
1348
|
+
if cid:
|
|
1349
|
+
gateway_result["caseID"] = cid
|
|
1350
|
+
if not gateway_result.get("caseID"):
|
|
1351
|
+
gateway_result["warning"] = "用例已通过网关创建成功,但无法获取 caseID"
|
|
1343
1352
|
return gateway_result
|
|
1344
1353
|
|
|
1345
|
-
return
|
|
1354
|
+
return {
|
|
1346
1355
|
"status": 0,
|
|
1347
1356
|
"message": "fail",
|
|
1348
|
-
"info": "
|
|
1349
|
-
"
|
|
1357
|
+
"info": "原生和网关均创建失败",
|
|
1358
|
+
"native_error": result,
|
|
1350
1359
|
"gateway_error": gateway_result,
|
|
1351
1360
|
}
|
|
1352
1361
|
|
|
@@ -1518,9 +1527,20 @@ class ZenTaoNexus:
|
|
|
1518
1527
|
}
|
|
1519
1528
|
|
|
1520
1529
|
def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
|
|
1521
|
-
"""
|
|
1530
|
+
"""
|
|
1531
|
+
导出 Bug 完整详情到本地文件夹,结构:
|
|
1532
|
+
bug_{bug_id}/
|
|
1533
|
+
├── bug_{bug_id}.md # 完整 Markdown 文档
|
|
1534
|
+
├── images/ # 描述 + 备注中的图片
|
|
1535
|
+
│ ├── step_1.png
|
|
1536
|
+
│ └── comment_1.png
|
|
1537
|
+
└── attachments/ # Bug 附件
|
|
1538
|
+
├── 需求文档.pdf
|
|
1539
|
+
└── 截图.png
|
|
1540
|
+
"""
|
|
1522
1541
|
if output_dir:
|
|
1523
1542
|
output_dir = _safe_path(output_dir)
|
|
1543
|
+
|
|
1524
1544
|
detail = self.client.bug_view_full(bug_id)
|
|
1525
1545
|
if detail.get("status") != 1:
|
|
1526
1546
|
return detail
|
|
@@ -1530,7 +1550,6 @@ class ZenTaoNexus:
|
|
|
1530
1550
|
users = detail.get("users", {})
|
|
1531
1551
|
|
|
1532
1552
|
def _name(field, mapping=None):
|
|
1533
|
-
"""取友好名称:优先 xxxName 字段,否则用映射表,最后用原始值"""
|
|
1534
1553
|
name_val = bug.get(f"{field}Name")
|
|
1535
1554
|
raw = str(bug.get(field, '') or '')
|
|
1536
1555
|
if name_val and name_val != 'None':
|
|
@@ -1538,55 +1557,133 @@ class ZenTaoNexus:
|
|
|
1538
1557
|
if mapping and raw in mapping:
|
|
1539
1558
|
return mapping[raw]
|
|
1540
1559
|
return raw
|
|
1541
|
-
|
|
1560
|
+
|
|
1561
|
+
# ── 创建目录结构 ──
|
|
1562
|
+
folder_name = f"bug_{bug_id}"
|
|
1542
1563
|
base_dir = os.path.join(output_dir, folder_name) if output_dir else folder_name
|
|
1543
|
-
os.
|
|
1564
|
+
images_dir = os.path.join(base_dir, "images")
|
|
1565
|
+
attach_dir = os.path.join(base_dir, "attachments")
|
|
1566
|
+
os.makedirs(images_dir, exist_ok=True)
|
|
1567
|
+
os.makedirs(attach_dir, exist_ok=True)
|
|
1544
1568
|
|
|
1545
1569
|
downloaded_files = []
|
|
1546
1570
|
downloaded_images = []
|
|
1547
1571
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1572
|
+
img_pattern = re.compile(
|
|
1573
|
+
r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
|
|
1574
|
+
re.IGNORECASE,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
def _guess_ext(url: str, fallback: str = ".png") -> str:
|
|
1578
|
+
for pat in (r't=(png|jpe?g|gif|bmp|webp)',
|
|
1579
|
+
r'\.(png|jpe?g|gif|bmp|webp)(?:[?#]|$)'):
|
|
1580
|
+
m = re.search(pat, url, re.IGNORECASE)
|
|
1581
|
+
if m:
|
|
1582
|
+
e = m.group(1).lower()
|
|
1583
|
+
return f".{'jpg' if e == 'jpeg' else e}"
|
|
1584
|
+
return fallback
|
|
1585
|
+
|
|
1586
|
+
def _download_image(url: str, name: str, category: str) -> bool:
|
|
1587
|
+
save_path = os.path.join(images_dir, name)
|
|
1588
|
+
r = self.client.file_download(url, save_path)
|
|
1589
|
+
ok = r.get("status") == 1
|
|
1590
|
+
downloaded_images.append({
|
|
1591
|
+
"name": name, "type": category,
|
|
1592
|
+
"success": ok, "size": r.get("size", 0),
|
|
1593
|
+
})
|
|
1594
|
+
return ok
|
|
1595
|
+
|
|
1596
|
+
# ── 1. 下载附件 ──
|
|
1597
|
+
raw_files = bug.get("files") or {}
|
|
1598
|
+
file_list = (list(raw_files.values()) if isinstance(raw_files, dict)
|
|
1599
|
+
else raw_files if isinstance(raw_files, list) else [])
|
|
1600
|
+
for fi in file_list:
|
|
1601
|
+
if not isinstance(fi, dict):
|
|
1553
1602
|
continue
|
|
1554
|
-
title =
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1603
|
+
title = fi.get("title", "unknown")
|
|
1604
|
+
url = fi.get("downloadUrl", "")
|
|
1605
|
+
if not url:
|
|
1606
|
+
ident = fi.get("secretID") or fi.get("id") or ""
|
|
1607
|
+
if ident:
|
|
1608
|
+
url = (f"{self.client.base_url}/zentaopms/www/index.php"
|
|
1609
|
+
f"?m=file&f=download&t=json&fileID={ident}")
|
|
1560
1610
|
if not url:
|
|
1611
|
+
downloaded_files.append({
|
|
1612
|
+
"name": title, "saved": "", "success": False,
|
|
1613
|
+
"size": 0, "reason": "no_download_url",
|
|
1614
|
+
})
|
|
1561
1615
|
continue
|
|
1562
1616
|
safe_name = re.sub(r'[<>:"/\\|?*]', '_', title)
|
|
1563
|
-
save_path = os.path.join(
|
|
1617
|
+
save_path = os.path.join(attach_dir, safe_name)
|
|
1564
1618
|
r = self.client.file_download(url, save_path)
|
|
1565
1619
|
downloaded_files.append({
|
|
1566
|
-
"name": title, "saved": safe_name,
|
|
1620
|
+
"name": title, "saved": f"attachments/{safe_name}",
|
|
1567
1621
|
"success": r.get("status") == 1,
|
|
1568
1622
|
"size": r.get("size", 0),
|
|
1569
1623
|
})
|
|
1570
1624
|
|
|
1571
|
-
#
|
|
1572
|
-
steps_html = bug.get("steps"
|
|
1573
|
-
|
|
1574
|
-
img_index = 0
|
|
1625
|
+
# ── 2. 下载描述(steps)中的图片 ──
|
|
1626
|
+
steps_html = (bug.get("steps") or "").replace("&", "&")
|
|
1627
|
+
step_img_idx = 0
|
|
1575
1628
|
for match in img_pattern.finditer(steps_html):
|
|
1576
1629
|
img_url = match.group(1)
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
img_name
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1630
|
+
if img_url.startswith("data:"):
|
|
1631
|
+
continue
|
|
1632
|
+
step_img_idx += 1
|
|
1633
|
+
ext = _guess_ext(img_url)
|
|
1634
|
+
img_name = f"step_{step_img_idx}{ext}"
|
|
1635
|
+
if _download_image(img_url, img_name, "step"):
|
|
1636
|
+
steps_html = steps_html.replace(img_url, f"./images/{img_name}")
|
|
1637
|
+
|
|
1638
|
+
# ── 3. 处理备注 & 下载备注中的图片 ──
|
|
1639
|
+
_ACTION_MAP = {
|
|
1640
|
+
"opened": "创建", "edited": "编辑", "commented": "备注",
|
|
1641
|
+
"resolved": "解决", "closed": "关闭", "activated": "激活",
|
|
1642
|
+
"confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
|
|
1643
|
+
}
|
|
1644
|
+
action_list = (list(actions.values()) if isinstance(actions, dict)
|
|
1645
|
+
else actions if isinstance(actions, list) else [])
|
|
1646
|
+
|
|
1647
|
+
comment_img_idx = 0
|
|
1648
|
+
comments_data = []
|
|
1649
|
+
|
|
1650
|
+
for act in action_list:
|
|
1651
|
+
if not isinstance(act, dict):
|
|
1652
|
+
continue
|
|
1653
|
+
action_type = act.get("action", "")
|
|
1654
|
+
actor_id = act.get("actor", "")
|
|
1655
|
+
actor_name = users.get(actor_id, actor_id) if users else actor_id
|
|
1656
|
+
date = act.get("date", "")
|
|
1657
|
+
comment_html = (act.get("comment") or "").strip()
|
|
1658
|
+
processed_comment = ""
|
|
1659
|
+
|
|
1660
|
+
if comment_html:
|
|
1661
|
+
comment_html = comment_html.replace("&", "&").replace(" ", " ")
|
|
1662
|
+
for img_match in img_pattern.finditer(comment_html):
|
|
1663
|
+
c_url = img_match.group(1)
|
|
1664
|
+
if c_url.startswith("data:"):
|
|
1665
|
+
continue
|
|
1666
|
+
if not c_url.startswith("http"):
|
|
1667
|
+
c_url = f"{self.client.base_url}{c_url}"
|
|
1668
|
+
comment_img_idx += 1
|
|
1669
|
+
ext = _guess_ext(c_url)
|
|
1670
|
+
c_img_name = f"comment_{comment_img_idx}{ext}"
|
|
1671
|
+
if _download_image(c_url, c_img_name, "comment"):
|
|
1672
|
+
comment_html = comment_html.replace(
|
|
1673
|
+
img_match.group(0),
|
|
1674
|
+
f'<img src="./images/{c_img_name}" alt="" />')
|
|
1675
|
+
processed_comment = self._html_to_markdown(comment_html)
|
|
1676
|
+
|
|
1677
|
+
comments_data.append({
|
|
1678
|
+
"date": date,
|
|
1679
|
+
"actor": actor_name,
|
|
1680
|
+
"action": action_type,
|
|
1681
|
+
"action_label": _ACTION_MAP.get(action_type, action_type),
|
|
1682
|
+
"comment": processed_comment,
|
|
1683
|
+
"history": act.get("history") or act.get("histories") or [],
|
|
1684
|
+
})
|
|
1685
|
+
|
|
1686
|
+
# ── 4. 生成完整 Markdown ──
|
|
1590
1687
|
md_lines = [
|
|
1591
1688
|
f"# Bug #{bug_id}: {bug.get('title', '')}",
|
|
1592
1689
|
"",
|
|
@@ -1596,6 +1693,7 @@ class ZenTaoNexus:
|
|
|
1596
1693
|
"|------|------|",
|
|
1597
1694
|
f"| ID | {bug_id} |",
|
|
1598
1695
|
f"| 产品 | {_name('product')} |",
|
|
1696
|
+
f"| 模块 | {_name('module')} |",
|
|
1599
1697
|
f"| 状态 | {_name('status', self._STATUS_MAP)} |",
|
|
1600
1698
|
f"| 严重程度 | {_name('severity', self._SEVERITY_MAP)} |",
|
|
1601
1699
|
f"| 优先级 | P{bug.get('pri', '')} |",
|
|
@@ -1603,130 +1701,125 @@ class ZenTaoNexus:
|
|
|
1603
1701
|
f"| 指派给 | {_name('assignedTo')} |",
|
|
1604
1702
|
f"| 创建人 | {_name('openedBy')} |",
|
|
1605
1703
|
f"| 创建时间 | {bug.get('openedDate', '')} |",
|
|
1606
|
-
f"| 截止日期 | {bug.get('deadline', '')} |",
|
|
1607
1704
|
f"| 解决人 | {_name('resolvedBy') or '-'} |",
|
|
1705
|
+
f"| 解决时间 | {bug.get('resolvedDate', '') or '-'} |",
|
|
1608
1706
|
f"| 解决方案 | {_name('resolution', self._RESOLUTION_MAP) or '-'} |",
|
|
1609
|
-
f"|
|
|
1707
|
+
f"| 关闭人 | {_name('closedBy') or '-'} |",
|
|
1708
|
+
f"| 关闭时间 | {bug.get('closedDate', '') or '-'} |",
|
|
1709
|
+
f"| 截止日期 | {bug.get('deadline', '') or '-'} |",
|
|
1710
|
+
f"| 关键词 | {bug.get('keywords', '') or '-'} |",
|
|
1711
|
+
f"| 关联需求 | {bug.get('story', '') or '-'} |",
|
|
1712
|
+
f"| 关联任务 | {bug.get('task', '') or '-'} |",
|
|
1610
1713
|
"",
|
|
1611
1714
|
"## 复现步骤",
|
|
1612
1715
|
"",
|
|
1716
|
+
self._html_to_markdown(steps_html),
|
|
1717
|
+
"",
|
|
1613
1718
|
]
|
|
1614
1719
|
|
|
1615
|
-
|
|
1616
|
-
md_lines.append(steps_md)
|
|
1617
|
-
md_lines.append("")
|
|
1618
|
-
|
|
1720
|
+
# ── 附件列表 ──
|
|
1619
1721
|
if downloaded_files:
|
|
1620
1722
|
md_lines.append("## 附件")
|
|
1621
1723
|
md_lines.append("")
|
|
1622
1724
|
for f in downloaded_files:
|
|
1725
|
+
path_ref = f"./{f['saved']}" if f["saved"] else ""
|
|
1623
1726
|
if f["success"]:
|
|
1624
1727
|
is_img = any(f["saved"].lower().endswith(e)
|
|
1625
|
-
for e in ('.png','.jpg','.jpeg','.gif','.bmp','.webp'))
|
|
1728
|
+
for e in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
|
|
1626
1729
|
if is_img:
|
|
1627
|
-
md_lines.append(f"![{f['name']}](
|
|
1730
|
+
md_lines.append(f"![{f['name']}]({path_ref})")
|
|
1628
1731
|
else:
|
|
1629
|
-
|
|
1732
|
+
size_kb = round(f["size"] / 1024, 1) if f["size"] else 0
|
|
1733
|
+
md_lines.append(f"- [{f['name']}]({path_ref}) ({size_kb} KB)")
|
|
1630
1734
|
else:
|
|
1631
|
-
md_lines.append(f"- {f['name']}
|
|
1735
|
+
md_lines.append(f"- {f['name']}(下载失败: {f.get('reason', 'unknown')})")
|
|
1632
1736
|
md_lines.append("")
|
|
1633
1737
|
|
|
1634
|
-
#
|
|
1635
|
-
|
|
1636
|
-
"
|
|
1637
|
-
"resolved": "解决", "closed": "关闭", "activated": "激活",
|
|
1638
|
-
"confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
|
|
1639
|
-
}
|
|
1640
|
-
action_list = list(actions.values()) if isinstance(actions, dict) else actions
|
|
1641
|
-
has_comments = any(
|
|
1642
|
-
a.get("comment", "").strip() for a in action_list if isinstance(a, dict)
|
|
1643
|
-
)
|
|
1644
|
-
if action_list:
|
|
1645
|
-
md_lines.append("## 操作历史")
|
|
1738
|
+
# ── 操作历史 & 备注 ──
|
|
1739
|
+
if comments_data:
|
|
1740
|
+
md_lines.append("## 操作历史 & 备注")
|
|
1646
1741
|
md_lines.append("")
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
if not isinstance(act, dict):
|
|
1650
|
-
continue
|
|
1651
|
-
action_type = act.get("action", "")
|
|
1652
|
-
action_label = _ACTION_MAP.get(action_type, action_type)
|
|
1653
|
-
actor_id = act.get("actor", "")
|
|
1654
|
-
actor_name = users.get(actor_id, actor_id) if users else actor_id
|
|
1655
|
-
date = act.get("date", "")
|
|
1656
|
-
comment_html = (act.get("comment") or "").strip()
|
|
1657
|
-
|
|
1658
|
-
md_lines.append(f"### {date} — {actor_name} [{action_label}]")
|
|
1742
|
+
for c in comments_data:
|
|
1743
|
+
md_lines.append(f"### {c['date']} — {c['actor']} [{c['action_label']}]")
|
|
1659
1744
|
md_lines.append("")
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
comment_html = comment_html.replace("&", "&").replace(" ", " ")
|
|
1663
|
-
for img_match in img_pattern.finditer(comment_html):
|
|
1664
|
-
img_url = img_match.group(1)
|
|
1665
|
-
if not img_url.startswith('http'):
|
|
1666
|
-
img_url = f"{self.client.base_url}{img_url}"
|
|
1667
|
-
ext = ".png"
|
|
1668
|
-
ext_m = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
|
|
1669
|
-
if ext_m:
|
|
1670
|
-
ext = f".{ext_m.group(1).lower()}"
|
|
1671
|
-
comment_img_idx += 1
|
|
1672
|
-
c_img_name = f"comment_img_{comment_img_idx}{ext}"
|
|
1673
|
-
c_save = os.path.join(base_dir, c_img_name)
|
|
1674
|
-
r = self.client.file_download(img_url, c_save)
|
|
1675
|
-
if r.get("status") == 1:
|
|
1676
|
-
comment_html = comment_html.replace(
|
|
1677
|
-
img_match.group(0),
|
|
1678
|
-
f'<img src="./{c_img_name}" alt="" />')
|
|
1679
|
-
downloaded_images.append(c_img_name)
|
|
1680
|
-
|
|
1681
|
-
comment_md = self._html_to_markdown(comment_html)
|
|
1682
|
-
md_lines.append(comment_md)
|
|
1745
|
+
if c["comment"]:
|
|
1746
|
+
md_lines.append(c["comment"])
|
|
1683
1747
|
md_lines.append("")
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
if history:
|
|
1748
|
+
history = c["history"]
|
|
1749
|
+
if isinstance(history, list):
|
|
1687
1750
|
for h in history:
|
|
1751
|
+
if not isinstance(h, dict):
|
|
1752
|
+
continue
|
|
1688
1753
|
field = h.get("field", "")
|
|
1689
|
-
old_val = h.get("old", "")
|
|
1690
|
-
new_val = h.get("new", "")
|
|
1754
|
+
old_val = str(h.get("old", ""))
|
|
1755
|
+
new_val = str(h.get("new", ""))
|
|
1691
1756
|
if old_val or new_val:
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
md_lines.append(f"- **{field}**: `{
|
|
1695
|
-
|
|
1757
|
+
old_s = (old_val[:100] + '…') if len(old_val) > 100 else old_val
|
|
1758
|
+
new_s = (new_val[:100] + '…') if len(new_val) > 100 else new_val
|
|
1759
|
+
md_lines.append(f"- **{field}**: `{old_s}` → `{new_s}`")
|
|
1760
|
+
if history:
|
|
1761
|
+
md_lines.append("")
|
|
1696
1762
|
|
|
1697
1763
|
md_content = "\n".join(md_lines)
|
|
1698
|
-
md_path = os.path.join(base_dir, f"{bug_id}
|
|
1764
|
+
md_path = os.path.join(base_dir, f"bug_{bug_id}.md")
|
|
1699
1765
|
with open(md_path, 'w', encoding='utf-8') as fw:
|
|
1700
1766
|
fw.write(md_content)
|
|
1701
1767
|
|
|
1768
|
+
abs_folder = os.path.abspath(base_dir)
|
|
1769
|
+
abs_md = os.path.abspath(md_path)
|
|
1770
|
+
|
|
1702
1771
|
return {
|
|
1703
1772
|
"status": 1,
|
|
1704
1773
|
"message": "success",
|
|
1705
|
-
"
|
|
1774
|
+
"data": {
|
|
1775
|
+
"bug": bug,
|
|
1776
|
+
"comments": comments_data,
|
|
1777
|
+
"users": users,
|
|
1778
|
+
},
|
|
1706
1779
|
"export": {
|
|
1707
|
-
"folder":
|
|
1708
|
-
"markdown":
|
|
1780
|
+
"folder": abs_folder,
|
|
1781
|
+
"markdown": abs_md,
|
|
1709
1782
|
"attachments": downloaded_files,
|
|
1710
|
-
"
|
|
1711
|
-
|
|
1783
|
+
"images": downloaded_images,
|
|
1784
|
+
"summary": {
|
|
1785
|
+
"total_attachments": len(downloaded_files),
|
|
1786
|
+
"downloaded_attachments": sum(1 for f in downloaded_files if f["success"]),
|
|
1787
|
+
"total_images": len(downloaded_images),
|
|
1788
|
+
"downloaded_images": sum(1 for i in downloaded_images if i["success"]),
|
|
1789
|
+
"total_comments": sum(1 for c in comments_data if c["comment"]),
|
|
1790
|
+
},
|
|
1791
|
+
},
|
|
1712
1792
|
}
|
|
1713
1793
|
|
|
1714
1794
|
@staticmethod
|
|
1715
1795
|
def _html_to_markdown(html: str) -> str:
|
|
1716
|
-
"""
|
|
1796
|
+
"""HTML → Markdown 转换"""
|
|
1717
1797
|
if not html:
|
|
1718
1798
|
return ""
|
|
1719
1799
|
text = html
|
|
1720
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*/?>',
|
|
1800
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*/?>',
|
|
1721
1801
|
r'', text)
|
|
1722
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
|
|
1802
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
|
|
1723
1803
|
r'', text)
|
|
1804
|
+
for i in range(6, 0, -1):
|
|
1805
|
+
text = re.sub(rf'<h{i}[^>]*>(.*?)</h{i}>', r'\n' + '#' * i + r' \1\n', text, flags=re.DOTALL)
|
|
1806
|
+
text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r'[\2](\1)', text, flags=re.DOTALL)
|
|
1724
1807
|
text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text)
|
|
1725
1808
|
text = re.sub(r'<b>(.*?)</b>', r'**\1**', text)
|
|
1726
1809
|
text = re.sub(r'<em>(.*?)</em>', r'*\1*', text)
|
|
1810
|
+
text = re.sub(r'<code>(.*?)</code>', r'`\1`', text)
|
|
1811
|
+
text = re.sub(r'<pre[^>]*>(.*?)</pre>', r'\n```\n\1\n```\n', text, flags=re.DOTALL)
|
|
1812
|
+
text = re.sub(r'<li[^>]*>(.*?)</li>', r'- \1\n', text, flags=re.DOTALL)
|
|
1813
|
+
text = re.sub(r'</?[ou]l[^>]*>', '\n', text)
|
|
1727
1814
|
text = re.sub(r'<br\s*/?>', '\n', text)
|
|
1728
|
-
text = re.sub(r'<p
|
|
1815
|
+
text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
|
|
1816
|
+
text = re.sub(r'<hr\s*/?>', '\n---\n', text)
|
|
1817
|
+
text = re.sub(r'</?(?:div|span|table|thead|tbody|tr|td|th|blockquote)[^>]*>', '\n', text)
|
|
1729
1818
|
text = re.sub(r'<[^>]+>', '', text)
|
|
1819
|
+
text = re.sub(r'<', '<', text)
|
|
1820
|
+
text = re.sub(r'>', '>', text)
|
|
1821
|
+
text = re.sub(r'"', '"', text)
|
|
1822
|
+
text = re.sub(r' ', ' ', text)
|
|
1730
1823
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
1731
1824
|
return text.strip()
|
|
1732
1825
|
|
package/src/mcp/server.py
CHANGED
|
@@ -93,12 +93,12 @@ class ZenTaoMCPServer:
|
|
|
93
93
|
),
|
|
94
94
|
Tool(
|
|
95
95
|
name="get_bug_detail",
|
|
96
|
-
description="获取Bug
|
|
96
|
+
description="获取Bug完整详情并导出到 bug_{id}/ 文件夹(含Markdown、描述图片、附件、备注图片,完整返回bug数据+备注+下载清单)",
|
|
97
97
|
inputSchema={
|
|
98
98
|
"type": "object",
|
|
99
99
|
"properties": {
|
|
100
100
|
"bug_id": {"type": "string", "description": "Bug ID"},
|
|
101
|
-
"output_dir": {"type": "string", "description": "
|
|
101
|
+
"output_dir": {"type": "string", "description": "导出根目录(可选,默认当前目录,实际文件存于 bug_{id}/ 子目录)"},
|
|
102
102
|
},
|
|
103
103
|
"required": ["bug_id"],
|
|
104
104
|
}
|
package/src/mcp_server.py
CHANGED
|
@@ -88,12 +88,12 @@ class ZenTaoMCPServer:
|
|
|
88
88
|
),
|
|
89
89
|
Tool(
|
|
90
90
|
name="get_bug_detail",
|
|
91
|
-
description="获取Bug
|
|
91
|
+
description="获取Bug完整详情并导出到 bug_{id}/ 文件夹(含Markdown、描述图片、附件、备注图片,完整返回bug数据+备注+下载清单)",
|
|
92
92
|
inputSchema={
|
|
93
93
|
"type": "object",
|
|
94
94
|
"properties": {
|
|
95
95
|
"bug_id": {"type": "string", "description": "Bug ID"},
|
|
96
|
-
"output_dir": {"type": "string", "description": "
|
|
96
|
+
"output_dir": {"type": "string", "description": "导出根目录(可选,默认当前目录,实际文件存于 bug_{id}/ 子目录)"},
|
|
97
97
|
},
|
|
98
98
|
"required": ["bug_id"],
|
|
99
99
|
}
|
package/src/zentao_nexus.py
CHANGED
|
@@ -1346,7 +1346,17 @@ class ZenTaoNexus:
|
|
|
1346
1346
|
}
|
|
1347
1347
|
|
|
1348
1348
|
def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
|
|
1349
|
-
"""
|
|
1349
|
+
"""
|
|
1350
|
+
导出 Bug 完整详情到本地文件夹,结构:
|
|
1351
|
+
bug_{bug_id}/
|
|
1352
|
+
├── bug_{bug_id}.md # 完整 Markdown 文档
|
|
1353
|
+
├── images/ # 描述 + 备注中的图片
|
|
1354
|
+
│ ├── step_1.png
|
|
1355
|
+
│ └── comment_1.png
|
|
1356
|
+
└── attachments/ # Bug 附件
|
|
1357
|
+
├── 需求文档.pdf
|
|
1358
|
+
└── 截图.png
|
|
1359
|
+
"""
|
|
1350
1360
|
detail = self.client.bug_view_full(bug_id)
|
|
1351
1361
|
if detail.get("status") != 1:
|
|
1352
1362
|
return detail
|
|
@@ -1356,7 +1366,6 @@ class ZenTaoNexus:
|
|
|
1356
1366
|
users = detail.get("users", {})
|
|
1357
1367
|
|
|
1358
1368
|
def _name(field, mapping=None):
|
|
1359
|
-
"""取友好名称:优先 xxxName 字段,否则用映射表,最后用原始值"""
|
|
1360
1369
|
name_val = bug.get(f"{field}Name")
|
|
1361
1370
|
raw = str(bug.get(field, '') or '')
|
|
1362
1371
|
if name_val and name_val != 'None':
|
|
@@ -1364,55 +1373,133 @@ class ZenTaoNexus:
|
|
|
1364
1373
|
if mapping and raw in mapping:
|
|
1365
1374
|
return mapping[raw]
|
|
1366
1375
|
return raw
|
|
1367
|
-
|
|
1376
|
+
|
|
1377
|
+
# ── 创建目录结构 ──
|
|
1378
|
+
folder_name = f"bug_{bug_id}"
|
|
1368
1379
|
base_dir = os.path.join(output_dir, folder_name) if output_dir else folder_name
|
|
1369
|
-
os.
|
|
1380
|
+
images_dir = os.path.join(base_dir, "images")
|
|
1381
|
+
attach_dir = os.path.join(base_dir, "attachments")
|
|
1382
|
+
os.makedirs(images_dir, exist_ok=True)
|
|
1383
|
+
os.makedirs(attach_dir, exist_ok=True)
|
|
1370
1384
|
|
|
1371
1385
|
downloaded_files = []
|
|
1372
1386
|
downloaded_images = []
|
|
1373
1387
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1388
|
+
img_pattern = re.compile(
|
|
1389
|
+
r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
|
|
1390
|
+
re.IGNORECASE,
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
def _guess_ext(url: str, fallback: str = ".png") -> str:
|
|
1394
|
+
for pat in (r't=(png|jpe?g|gif|bmp|webp)',
|
|
1395
|
+
r'\.(png|jpe?g|gif|bmp|webp)(?:[?#]|$)'):
|
|
1396
|
+
m = re.search(pat, url, re.IGNORECASE)
|
|
1397
|
+
if m:
|
|
1398
|
+
e = m.group(1).lower()
|
|
1399
|
+
return f".{'jpg' if e == 'jpeg' else e}"
|
|
1400
|
+
return fallback
|
|
1401
|
+
|
|
1402
|
+
def _download_image(url: str, name: str, category: str) -> bool:
|
|
1403
|
+
save_path = os.path.join(images_dir, name)
|
|
1404
|
+
r = self.client.file_download(url, save_path)
|
|
1405
|
+
ok = r.get("status") == 1
|
|
1406
|
+
downloaded_images.append({
|
|
1407
|
+
"name": name, "type": category,
|
|
1408
|
+
"success": ok, "size": r.get("size", 0),
|
|
1409
|
+
})
|
|
1410
|
+
return ok
|
|
1411
|
+
|
|
1412
|
+
# ── 1. 下载附件 ──
|
|
1413
|
+
raw_files = bug.get("files") or {}
|
|
1414
|
+
file_list = (list(raw_files.values()) if isinstance(raw_files, dict)
|
|
1415
|
+
else raw_files if isinstance(raw_files, list) else [])
|
|
1416
|
+
for fi in file_list:
|
|
1417
|
+
if not isinstance(fi, dict):
|
|
1379
1418
|
continue
|
|
1380
|
-
title =
|
|
1381
|
-
|
|
1382
|
-
url = file_info.get("downloadUrl", "")
|
|
1383
|
-
if not url and secret_id:
|
|
1384
|
-
url = (f"{self.client.base_url}/zentaopms/www/index.php"
|
|
1385
|
-
f"?m=file&f=download&t=json&fileID={secret_id}")
|
|
1419
|
+
title = fi.get("title", "unknown")
|
|
1420
|
+
url = fi.get("downloadUrl", "")
|
|
1386
1421
|
if not url:
|
|
1422
|
+
ident = fi.get("secretID") or fi.get("id") or ""
|
|
1423
|
+
if ident:
|
|
1424
|
+
url = (f"{self.client.base_url}/zentaopms/www/index.php"
|
|
1425
|
+
f"?m=file&f=download&t=json&fileID={ident}")
|
|
1426
|
+
if not url:
|
|
1427
|
+
downloaded_files.append({
|
|
1428
|
+
"name": title, "saved": "", "success": False,
|
|
1429
|
+
"size": 0, "reason": "no_download_url",
|
|
1430
|
+
})
|
|
1387
1431
|
continue
|
|
1388
1432
|
safe_name = re.sub(r'[<>:"/\\|?*]', '_', title)
|
|
1389
|
-
save_path = os.path.join(
|
|
1433
|
+
save_path = os.path.join(attach_dir, safe_name)
|
|
1390
1434
|
r = self.client.file_download(url, save_path)
|
|
1391
1435
|
downloaded_files.append({
|
|
1392
|
-
"name": title, "saved": safe_name,
|
|
1436
|
+
"name": title, "saved": f"attachments/{safe_name}",
|
|
1393
1437
|
"success": r.get("status") == 1,
|
|
1394
1438
|
"size": r.get("size", 0),
|
|
1395
1439
|
})
|
|
1396
1440
|
|
|
1397
|
-
#
|
|
1398
|
-
steps_html = bug.get("steps"
|
|
1399
|
-
|
|
1400
|
-
img_index = 0
|
|
1441
|
+
# ── 2. 下载描述(steps)中的图片 ──
|
|
1442
|
+
steps_html = (bug.get("steps") or "").replace("&", "&")
|
|
1443
|
+
step_img_idx = 0
|
|
1401
1444
|
for match in img_pattern.finditer(steps_html):
|
|
1402
1445
|
img_url = match.group(1)
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
img_name
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1446
|
+
if img_url.startswith("data:"):
|
|
1447
|
+
continue
|
|
1448
|
+
step_img_idx += 1
|
|
1449
|
+
ext = _guess_ext(img_url)
|
|
1450
|
+
img_name = f"step_{step_img_idx}{ext}"
|
|
1451
|
+
if _download_image(img_url, img_name, "step"):
|
|
1452
|
+
steps_html = steps_html.replace(img_url, f"./images/{img_name}")
|
|
1453
|
+
|
|
1454
|
+
# ── 3. 处理备注 & 下载备注中的图片 ──
|
|
1455
|
+
_ACTION_MAP = {
|
|
1456
|
+
"opened": "创建", "edited": "编辑", "commented": "备注",
|
|
1457
|
+
"resolved": "解决", "closed": "关闭", "activated": "激活",
|
|
1458
|
+
"confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
|
|
1459
|
+
}
|
|
1460
|
+
action_list = (list(actions.values()) if isinstance(actions, dict)
|
|
1461
|
+
else actions if isinstance(actions, list) else [])
|
|
1462
|
+
|
|
1463
|
+
comment_img_idx = 0
|
|
1464
|
+
comments_data = []
|
|
1465
|
+
|
|
1466
|
+
for act in action_list:
|
|
1467
|
+
if not isinstance(act, dict):
|
|
1468
|
+
continue
|
|
1469
|
+
action_type = act.get("action", "")
|
|
1470
|
+
actor_id = act.get("actor", "")
|
|
1471
|
+
actor_name = users.get(actor_id, actor_id) if users else actor_id
|
|
1472
|
+
date = act.get("date", "")
|
|
1473
|
+
comment_html = (act.get("comment") or "").strip()
|
|
1474
|
+
processed_comment = ""
|
|
1475
|
+
|
|
1476
|
+
if comment_html:
|
|
1477
|
+
comment_html = comment_html.replace("&", "&").replace(" ", " ")
|
|
1478
|
+
for img_match in img_pattern.finditer(comment_html):
|
|
1479
|
+
c_url = img_match.group(1)
|
|
1480
|
+
if c_url.startswith("data:"):
|
|
1481
|
+
continue
|
|
1482
|
+
if not c_url.startswith("http"):
|
|
1483
|
+
c_url = f"{self.client.base_url}{c_url}"
|
|
1484
|
+
comment_img_idx += 1
|
|
1485
|
+
ext = _guess_ext(c_url)
|
|
1486
|
+
c_img_name = f"comment_{comment_img_idx}{ext}"
|
|
1487
|
+
if _download_image(c_url, c_img_name, "comment"):
|
|
1488
|
+
comment_html = comment_html.replace(
|
|
1489
|
+
img_match.group(0),
|
|
1490
|
+
f'<img src="./images/{c_img_name}" alt="" />')
|
|
1491
|
+
processed_comment = self._html_to_markdown(comment_html)
|
|
1492
|
+
|
|
1493
|
+
comments_data.append({
|
|
1494
|
+
"date": date,
|
|
1495
|
+
"actor": actor_name,
|
|
1496
|
+
"action": action_type,
|
|
1497
|
+
"action_label": _ACTION_MAP.get(action_type, action_type),
|
|
1498
|
+
"comment": processed_comment,
|
|
1499
|
+
"history": act.get("history") or act.get("histories") or [],
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
# ── 4. 生成完整 Markdown ──
|
|
1416
1503
|
md_lines = [
|
|
1417
1504
|
f"# Bug #{bug_id}: {bug.get('title', '')}",
|
|
1418
1505
|
"",
|
|
@@ -1422,6 +1509,7 @@ class ZenTaoNexus:
|
|
|
1422
1509
|
"|------|------|",
|
|
1423
1510
|
f"| ID | {bug_id} |",
|
|
1424
1511
|
f"| 产品 | {_name('product')} |",
|
|
1512
|
+
f"| 模块 | {_name('module')} |",
|
|
1425
1513
|
f"| 状态 | {_name('status', self._STATUS_MAP)} |",
|
|
1426
1514
|
f"| 严重程度 | {_name('severity', self._SEVERITY_MAP)} |",
|
|
1427
1515
|
f"| 优先级 | P{bug.get('pri', '')} |",
|
|
@@ -1429,130 +1517,125 @@ class ZenTaoNexus:
|
|
|
1429
1517
|
f"| 指派给 | {_name('assignedTo')} |",
|
|
1430
1518
|
f"| 创建人 | {_name('openedBy')} |",
|
|
1431
1519
|
f"| 创建时间 | {bug.get('openedDate', '')} |",
|
|
1432
|
-
f"| 截止日期 | {bug.get('deadline', '')} |",
|
|
1433
1520
|
f"| 解决人 | {_name('resolvedBy') or '-'} |",
|
|
1521
|
+
f"| 解决时间 | {bug.get('resolvedDate', '') or '-'} |",
|
|
1434
1522
|
f"| 解决方案 | {_name('resolution', self._RESOLUTION_MAP) or '-'} |",
|
|
1435
|
-
f"|
|
|
1523
|
+
f"| 关闭人 | {_name('closedBy') or '-'} |",
|
|
1524
|
+
f"| 关闭时间 | {bug.get('closedDate', '') or '-'} |",
|
|
1525
|
+
f"| 截止日期 | {bug.get('deadline', '') or '-'} |",
|
|
1526
|
+
f"| 关键词 | {bug.get('keywords', '') or '-'} |",
|
|
1527
|
+
f"| 关联需求 | {bug.get('story', '') or '-'} |",
|
|
1528
|
+
f"| 关联任务 | {bug.get('task', '') or '-'} |",
|
|
1436
1529
|
"",
|
|
1437
1530
|
"## 复现步骤",
|
|
1438
1531
|
"",
|
|
1532
|
+
self._html_to_markdown(steps_html),
|
|
1533
|
+
"",
|
|
1439
1534
|
]
|
|
1440
1535
|
|
|
1441
|
-
|
|
1442
|
-
md_lines.append(steps_md)
|
|
1443
|
-
md_lines.append("")
|
|
1444
|
-
|
|
1536
|
+
# ── 附件列表 ──
|
|
1445
1537
|
if downloaded_files:
|
|
1446
1538
|
md_lines.append("## 附件")
|
|
1447
1539
|
md_lines.append("")
|
|
1448
1540
|
for f in downloaded_files:
|
|
1541
|
+
path_ref = f"./{f['saved']}" if f["saved"] else ""
|
|
1449
1542
|
if f["success"]:
|
|
1450
1543
|
is_img = any(f["saved"].lower().endswith(e)
|
|
1451
|
-
for e in ('.png','.jpg','.jpeg','.gif','.bmp','.webp'))
|
|
1544
|
+
for e in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
|
|
1452
1545
|
if is_img:
|
|
1453
|
-
md_lines.append(f"![{f['name']}](
|
|
1546
|
+
md_lines.append(f"![{f['name']}]({path_ref})")
|
|
1454
1547
|
else:
|
|
1455
|
-
|
|
1548
|
+
size_kb = round(f["size"] / 1024, 1) if f["size"] else 0
|
|
1549
|
+
md_lines.append(f"- [{f['name']}]({path_ref}) ({size_kb} KB)")
|
|
1456
1550
|
else:
|
|
1457
|
-
md_lines.append(f"- {f['name']}
|
|
1551
|
+
md_lines.append(f"- {f['name']}(下载失败: {f.get('reason', 'unknown')})")
|
|
1458
1552
|
md_lines.append("")
|
|
1459
1553
|
|
|
1460
|
-
#
|
|
1461
|
-
|
|
1462
|
-
"
|
|
1463
|
-
"resolved": "解决", "closed": "关闭", "activated": "激活",
|
|
1464
|
-
"confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
|
|
1465
|
-
}
|
|
1466
|
-
action_list = list(actions.values()) if isinstance(actions, dict) else actions
|
|
1467
|
-
has_comments = any(
|
|
1468
|
-
a.get("comment", "").strip() for a in action_list if isinstance(a, dict)
|
|
1469
|
-
)
|
|
1470
|
-
if action_list:
|
|
1471
|
-
md_lines.append("## 操作历史")
|
|
1554
|
+
# ── 操作历史 & 备注 ──
|
|
1555
|
+
if comments_data:
|
|
1556
|
+
md_lines.append("## 操作历史 & 备注")
|
|
1472
1557
|
md_lines.append("")
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if not isinstance(act, dict):
|
|
1476
|
-
continue
|
|
1477
|
-
action_type = act.get("action", "")
|
|
1478
|
-
action_label = _ACTION_MAP.get(action_type, action_type)
|
|
1479
|
-
actor_id = act.get("actor", "")
|
|
1480
|
-
actor_name = users.get(actor_id, actor_id) if users else actor_id
|
|
1481
|
-
date = act.get("date", "")
|
|
1482
|
-
comment_html = (act.get("comment") or "").strip()
|
|
1483
|
-
|
|
1484
|
-
md_lines.append(f"### {date} — {actor_name} [{action_label}]")
|
|
1558
|
+
for c in comments_data:
|
|
1559
|
+
md_lines.append(f"### {c['date']} — {c['actor']} [{c['action_label']}]")
|
|
1485
1560
|
md_lines.append("")
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
comment_html = comment_html.replace("&", "&").replace(" ", " ")
|
|
1489
|
-
for img_match in img_pattern.finditer(comment_html):
|
|
1490
|
-
img_url = img_match.group(1)
|
|
1491
|
-
if not img_url.startswith('http'):
|
|
1492
|
-
img_url = f"{self.client.base_url}{img_url}"
|
|
1493
|
-
ext = ".png"
|
|
1494
|
-
ext_m = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
|
|
1495
|
-
if ext_m:
|
|
1496
|
-
ext = f".{ext_m.group(1).lower()}"
|
|
1497
|
-
comment_img_idx += 1
|
|
1498
|
-
c_img_name = f"comment_img_{comment_img_idx}{ext}"
|
|
1499
|
-
c_save = os.path.join(base_dir, c_img_name)
|
|
1500
|
-
r = self.client.file_download(img_url, c_save)
|
|
1501
|
-
if r.get("status") == 1:
|
|
1502
|
-
comment_html = comment_html.replace(
|
|
1503
|
-
img_match.group(0),
|
|
1504
|
-
f'<img src="./{c_img_name}" alt="" />')
|
|
1505
|
-
downloaded_images.append(c_img_name)
|
|
1506
|
-
|
|
1507
|
-
comment_md = self._html_to_markdown(comment_html)
|
|
1508
|
-
md_lines.append(comment_md)
|
|
1561
|
+
if c["comment"]:
|
|
1562
|
+
md_lines.append(c["comment"])
|
|
1509
1563
|
md_lines.append("")
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
if history:
|
|
1564
|
+
history = c["history"]
|
|
1565
|
+
if isinstance(history, list):
|
|
1513
1566
|
for h in history:
|
|
1567
|
+
if not isinstance(h, dict):
|
|
1568
|
+
continue
|
|
1514
1569
|
field = h.get("field", "")
|
|
1515
|
-
old_val = h.get("old", "")
|
|
1516
|
-
new_val = h.get("new", "")
|
|
1570
|
+
old_val = str(h.get("old", ""))
|
|
1571
|
+
new_val = str(h.get("new", ""))
|
|
1517
1572
|
if old_val or new_val:
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
md_lines.append(f"- **{field}**: `{
|
|
1521
|
-
|
|
1573
|
+
old_s = (old_val[:100] + '…') if len(old_val) > 100 else old_val
|
|
1574
|
+
new_s = (new_val[:100] + '…') if len(new_val) > 100 else new_val
|
|
1575
|
+
md_lines.append(f"- **{field}**: `{old_s}` → `{new_s}`")
|
|
1576
|
+
if history:
|
|
1577
|
+
md_lines.append("")
|
|
1522
1578
|
|
|
1523
1579
|
md_content = "\n".join(md_lines)
|
|
1524
|
-
md_path = os.path.join(base_dir, f"{bug_id}
|
|
1580
|
+
md_path = os.path.join(base_dir, f"bug_{bug_id}.md")
|
|
1525
1581
|
with open(md_path, 'w', encoding='utf-8') as fw:
|
|
1526
1582
|
fw.write(md_content)
|
|
1527
1583
|
|
|
1584
|
+
abs_folder = os.path.abspath(base_dir)
|
|
1585
|
+
abs_md = os.path.abspath(md_path)
|
|
1586
|
+
|
|
1528
1587
|
return {
|
|
1529
1588
|
"status": 1,
|
|
1530
1589
|
"message": "success",
|
|
1531
|
-
"
|
|
1590
|
+
"data": {
|
|
1591
|
+
"bug": bug,
|
|
1592
|
+
"comments": comments_data,
|
|
1593
|
+
"users": users,
|
|
1594
|
+
},
|
|
1532
1595
|
"export": {
|
|
1533
|
-
"folder":
|
|
1534
|
-
"markdown":
|
|
1596
|
+
"folder": abs_folder,
|
|
1597
|
+
"markdown": abs_md,
|
|
1535
1598
|
"attachments": downloaded_files,
|
|
1536
|
-
"
|
|
1537
|
-
|
|
1599
|
+
"images": downloaded_images,
|
|
1600
|
+
"summary": {
|
|
1601
|
+
"total_attachments": len(downloaded_files),
|
|
1602
|
+
"downloaded_attachments": sum(1 for f in downloaded_files if f["success"]),
|
|
1603
|
+
"total_images": len(downloaded_images),
|
|
1604
|
+
"downloaded_images": sum(1 for i in downloaded_images if i["success"]),
|
|
1605
|
+
"total_comments": sum(1 for c in comments_data if c["comment"]),
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1538
1608
|
}
|
|
1539
1609
|
|
|
1540
1610
|
@staticmethod
|
|
1541
1611
|
def _html_to_markdown(html: str) -> str:
|
|
1542
|
-
"""
|
|
1612
|
+
"""HTML → Markdown 转换"""
|
|
1543
1613
|
if not html:
|
|
1544
1614
|
return ""
|
|
1545
1615
|
text = html
|
|
1546
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*/?>',
|
|
1616
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*/?>',
|
|
1547
1617
|
r'', text)
|
|
1548
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
|
|
1618
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
|
|
1549
1619
|
r'', text)
|
|
1620
|
+
for i in range(6, 0, -1):
|
|
1621
|
+
text = re.sub(rf'<h{i}[^>]*>(.*?)</h{i}>', r'\n' + '#' * i + r' \1\n', text, flags=re.DOTALL)
|
|
1622
|
+
text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r'[\2](\1)', text, flags=re.DOTALL)
|
|
1550
1623
|
text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text)
|
|
1551
1624
|
text = re.sub(r'<b>(.*?)</b>', r'**\1**', text)
|
|
1552
1625
|
text = re.sub(r'<em>(.*?)</em>', r'*\1*', text)
|
|
1626
|
+
text = re.sub(r'<code>(.*?)</code>', r'`\1`', text)
|
|
1627
|
+
text = re.sub(r'<pre[^>]*>(.*?)</pre>', r'\n```\n\1\n```\n', text, flags=re.DOTALL)
|
|
1628
|
+
text = re.sub(r'<li[^>]*>(.*?)</li>', r'- \1\n', text, flags=re.DOTALL)
|
|
1629
|
+
text = re.sub(r'</?[ou]l[^>]*>', '\n', text)
|
|
1553
1630
|
text = re.sub(r'<br\s*/?>', '\n', text)
|
|
1554
|
-
text = re.sub(r'<p
|
|
1631
|
+
text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
|
|
1632
|
+
text = re.sub(r'<hr\s*/?>', '\n---\n', text)
|
|
1633
|
+
text = re.sub(r'</?(?:div|span|table|thead|tbody|tr|td|th|blockquote)[^>]*>', '\n', text)
|
|
1555
1634
|
text = re.sub(r'<[^>]+>', '', text)
|
|
1635
|
+
text = re.sub(r'<', '<', text)
|
|
1636
|
+
text = re.sub(r'>', '>', text)
|
|
1637
|
+
text = re.sub(r'"', '"', text)
|
|
1638
|
+
text = re.sub(r' ', ' ', text)
|
|
1556
1639
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
1557
1640
|
return text.strip()
|
|
1558
1641
|
|