@bangdao-ai/zentao-mcp 2.0.1 → 2.0.3
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 +1 -1
- package/package.json +1 -1
- package/src/core/zentao_nexus.py +196 -112
- package/src/mcp/server.py +2 -2
- package/src/mcp_server.py +2 -2
- package/src/zentao_nexus.py +195 -112
package/.env.example
CHANGED
package/package.json
CHANGED
package/src/core/zentao_nexus.py
CHANGED
|
@@ -794,11 +794,11 @@ class ZenTaoNexus:
|
|
|
794
794
|
self.product_id = os.getenv("ZENTAO_PRODUCT_ID") or ""
|
|
795
795
|
|
|
796
796
|
code = code or os.getenv("ZENTAO_OPENED_BY")
|
|
797
|
-
key = key or os.getenv("ZENTAO_KEY")
|
|
797
|
+
key = key or os.getenv("ZENTAO_KEY") or os.getenv("KEY")
|
|
798
798
|
if not code:
|
|
799
799
|
raise ValueError("未设置 code 参数或环境变量 ZENTAO_OPENED_BY")
|
|
800
800
|
if not key:
|
|
801
|
-
raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY")
|
|
801
|
+
raise ValueError("未设置 key 参数或环境变量 ZENTAO_KEY / KEY")
|
|
802
802
|
|
|
803
803
|
self.opened_by = code
|
|
804
804
|
use_test = os.getenv("ZENTAO_USE_TEST_ENV", "").lower() in ('1', 'true', 'yes')
|
|
@@ -1527,9 +1527,20 @@ class ZenTaoNexus:
|
|
|
1527
1527
|
}
|
|
1528
1528
|
|
|
1529
1529
|
def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
|
|
1530
|
-
"""
|
|
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
|
+
"""
|
|
1531
1541
|
if output_dir:
|
|
1532
1542
|
output_dir = _safe_path(output_dir)
|
|
1543
|
+
|
|
1533
1544
|
detail = self.client.bug_view_full(bug_id)
|
|
1534
1545
|
if detail.get("status") != 1:
|
|
1535
1546
|
return detail
|
|
@@ -1539,7 +1550,6 @@ class ZenTaoNexus:
|
|
|
1539
1550
|
users = detail.get("users", {})
|
|
1540
1551
|
|
|
1541
1552
|
def _name(field, mapping=None):
|
|
1542
|
-
"""取友好名称:优先 xxxName 字段,否则用映射表,最后用原始值"""
|
|
1543
1553
|
name_val = bug.get(f"{field}Name")
|
|
1544
1554
|
raw = str(bug.get(field, '') or '')
|
|
1545
1555
|
if name_val and name_val != 'None':
|
|
@@ -1547,55 +1557,133 @@ class ZenTaoNexus:
|
|
|
1547
1557
|
if mapping and raw in mapping:
|
|
1548
1558
|
return mapping[raw]
|
|
1549
1559
|
return raw
|
|
1550
|
-
|
|
1560
|
+
|
|
1561
|
+
# ── 创建目录结构 ──
|
|
1562
|
+
folder_name = f"bug_{bug_id}"
|
|
1551
1563
|
base_dir = os.path.join(output_dir, folder_name) if output_dir else folder_name
|
|
1552
|
-
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)
|
|
1553
1568
|
|
|
1554
1569
|
downloaded_files = []
|
|
1555
1570
|
downloaded_images = []
|
|
1556
1571
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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):
|
|
1562
1602
|
continue
|
|
1563
|
-
title =
|
|
1564
|
-
|
|
1565
|
-
url = file_info.get("downloadUrl", "")
|
|
1566
|
-
if not url and secret_id:
|
|
1567
|
-
url = (f"{self.client.base_url}/zentaopms/www/index.php"
|
|
1568
|
-
f"?m=file&f=download&t=json&fileID={secret_id}")
|
|
1603
|
+
title = fi.get("title", "unknown")
|
|
1604
|
+
url = fi.get("downloadUrl", "")
|
|
1569
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}")
|
|
1610
|
+
if not url:
|
|
1611
|
+
downloaded_files.append({
|
|
1612
|
+
"name": title, "saved": "", "success": False,
|
|
1613
|
+
"size": 0, "reason": "no_download_url",
|
|
1614
|
+
})
|
|
1570
1615
|
continue
|
|
1571
1616
|
safe_name = re.sub(r'[<>:"/\\|?*]', '_', title)
|
|
1572
|
-
save_path = os.path.join(
|
|
1617
|
+
save_path = os.path.join(attach_dir, safe_name)
|
|
1573
1618
|
r = self.client.file_download(url, save_path)
|
|
1574
1619
|
downloaded_files.append({
|
|
1575
|
-
"name": title, "saved": safe_name,
|
|
1620
|
+
"name": title, "saved": f"attachments/{safe_name}",
|
|
1576
1621
|
"success": r.get("status") == 1,
|
|
1577
1622
|
"size": r.get("size", 0),
|
|
1578
1623
|
})
|
|
1579
1624
|
|
|
1580
|
-
#
|
|
1581
|
-
steps_html = bug.get("steps"
|
|
1582
|
-
|
|
1583
|
-
img_index = 0
|
|
1625
|
+
# ── 2. 下载描述(steps)中的图片 ──
|
|
1626
|
+
steps_html = (bug.get("steps") or "").replace("&", "&")
|
|
1627
|
+
step_img_idx = 0
|
|
1584
1628
|
for match in img_pattern.finditer(steps_html):
|
|
1585
1629
|
img_url = match.group(1)
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
img_name
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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 ──
|
|
1599
1687
|
md_lines = [
|
|
1600
1688
|
f"# Bug #{bug_id}: {bug.get('title', '')}",
|
|
1601
1689
|
"",
|
|
@@ -1605,6 +1693,7 @@ class ZenTaoNexus:
|
|
|
1605
1693
|
"|------|------|",
|
|
1606
1694
|
f"| ID | {bug_id} |",
|
|
1607
1695
|
f"| 产品 | {_name('product')} |",
|
|
1696
|
+
f"| 模块 | {_name('module')} |",
|
|
1608
1697
|
f"| 状态 | {_name('status', self._STATUS_MAP)} |",
|
|
1609
1698
|
f"| 严重程度 | {_name('severity', self._SEVERITY_MAP)} |",
|
|
1610
1699
|
f"| 优先级 | P{bug.get('pri', '')} |",
|
|
@@ -1612,130 +1701,125 @@ class ZenTaoNexus:
|
|
|
1612
1701
|
f"| 指派给 | {_name('assignedTo')} |",
|
|
1613
1702
|
f"| 创建人 | {_name('openedBy')} |",
|
|
1614
1703
|
f"| 创建时间 | {bug.get('openedDate', '')} |",
|
|
1615
|
-
f"| 截止日期 | {bug.get('deadline', '')} |",
|
|
1616
1704
|
f"| 解决人 | {_name('resolvedBy') or '-'} |",
|
|
1705
|
+
f"| 解决时间 | {bug.get('resolvedDate', '') or '-'} |",
|
|
1617
1706
|
f"| 解决方案 | {_name('resolution', self._RESOLUTION_MAP) or '-'} |",
|
|
1618
|
-
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 '-'} |",
|
|
1619
1713
|
"",
|
|
1620
1714
|
"## 复现步骤",
|
|
1621
1715
|
"",
|
|
1716
|
+
self._html_to_markdown(steps_html),
|
|
1717
|
+
"",
|
|
1622
1718
|
]
|
|
1623
1719
|
|
|
1624
|
-
|
|
1625
|
-
md_lines.append(steps_md)
|
|
1626
|
-
md_lines.append("")
|
|
1627
|
-
|
|
1720
|
+
# ── 附件列表 ──
|
|
1628
1721
|
if downloaded_files:
|
|
1629
1722
|
md_lines.append("## 附件")
|
|
1630
1723
|
md_lines.append("")
|
|
1631
1724
|
for f in downloaded_files:
|
|
1725
|
+
path_ref = f"./{f['saved']}" if f["saved"] else ""
|
|
1632
1726
|
if f["success"]:
|
|
1633
1727
|
is_img = any(f["saved"].lower().endswith(e)
|
|
1634
|
-
for e in ('.png','.jpg','.jpeg','.gif','.bmp','.webp'))
|
|
1728
|
+
for e in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
|
|
1635
1729
|
if is_img:
|
|
1636
|
-
md_lines.append(f"![{f['name']}](
|
|
1730
|
+
md_lines.append(f"![{f['name']}]({path_ref})")
|
|
1637
1731
|
else:
|
|
1638
|
-
|
|
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)")
|
|
1639
1734
|
else:
|
|
1640
|
-
md_lines.append(f"- {f['name']}
|
|
1735
|
+
md_lines.append(f"- {f['name']}(下载失败: {f.get('reason', 'unknown')})")
|
|
1641
1736
|
md_lines.append("")
|
|
1642
1737
|
|
|
1643
|
-
#
|
|
1644
|
-
|
|
1645
|
-
"
|
|
1646
|
-
"resolved": "解决", "closed": "关闭", "activated": "激活",
|
|
1647
|
-
"confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
|
|
1648
|
-
}
|
|
1649
|
-
action_list = list(actions.values()) if isinstance(actions, dict) else actions
|
|
1650
|
-
has_comments = any(
|
|
1651
|
-
a.get("comment", "").strip() for a in action_list if isinstance(a, dict)
|
|
1652
|
-
)
|
|
1653
|
-
if action_list:
|
|
1654
|
-
md_lines.append("## 操作历史")
|
|
1738
|
+
# ── 操作历史 & 备注 ──
|
|
1739
|
+
if comments_data:
|
|
1740
|
+
md_lines.append("## 操作历史 & 备注")
|
|
1655
1741
|
md_lines.append("")
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
if not isinstance(act, dict):
|
|
1659
|
-
continue
|
|
1660
|
-
action_type = act.get("action", "")
|
|
1661
|
-
action_label = _ACTION_MAP.get(action_type, action_type)
|
|
1662
|
-
actor_id = act.get("actor", "")
|
|
1663
|
-
actor_name = users.get(actor_id, actor_id) if users else actor_id
|
|
1664
|
-
date = act.get("date", "")
|
|
1665
|
-
comment_html = (act.get("comment") or "").strip()
|
|
1666
|
-
|
|
1667
|
-
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']}]")
|
|
1668
1744
|
md_lines.append("")
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
comment_html = comment_html.replace("&", "&").replace(" ", " ")
|
|
1672
|
-
for img_match in img_pattern.finditer(comment_html):
|
|
1673
|
-
img_url = img_match.group(1)
|
|
1674
|
-
if not img_url.startswith('http'):
|
|
1675
|
-
img_url = f"{self.client.base_url}{img_url}"
|
|
1676
|
-
ext = ".png"
|
|
1677
|
-
ext_m = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
|
|
1678
|
-
if ext_m:
|
|
1679
|
-
ext = f".{ext_m.group(1).lower()}"
|
|
1680
|
-
comment_img_idx += 1
|
|
1681
|
-
c_img_name = f"comment_img_{comment_img_idx}{ext}"
|
|
1682
|
-
c_save = os.path.join(base_dir, c_img_name)
|
|
1683
|
-
r = self.client.file_download(img_url, c_save)
|
|
1684
|
-
if r.get("status") == 1:
|
|
1685
|
-
comment_html = comment_html.replace(
|
|
1686
|
-
img_match.group(0),
|
|
1687
|
-
f'<img src="./{c_img_name}" alt="" />')
|
|
1688
|
-
downloaded_images.append(c_img_name)
|
|
1689
|
-
|
|
1690
|
-
comment_md = self._html_to_markdown(comment_html)
|
|
1691
|
-
md_lines.append(comment_md)
|
|
1745
|
+
if c["comment"]:
|
|
1746
|
+
md_lines.append(c["comment"])
|
|
1692
1747
|
md_lines.append("")
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if history:
|
|
1748
|
+
history = c["history"]
|
|
1749
|
+
if isinstance(history, list):
|
|
1696
1750
|
for h in history:
|
|
1751
|
+
if not isinstance(h, dict):
|
|
1752
|
+
continue
|
|
1697
1753
|
field = h.get("field", "")
|
|
1698
|
-
old_val = h.get("old", "")
|
|
1699
|
-
new_val = h.get("new", "")
|
|
1754
|
+
old_val = str(h.get("old", ""))
|
|
1755
|
+
new_val = str(h.get("new", ""))
|
|
1700
1756
|
if old_val or new_val:
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
md_lines.append(f"- **{field}**: `{
|
|
1704
|
-
|
|
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("")
|
|
1705
1762
|
|
|
1706
1763
|
md_content = "\n".join(md_lines)
|
|
1707
|
-
md_path = os.path.join(base_dir, f"{bug_id}
|
|
1764
|
+
md_path = os.path.join(base_dir, f"bug_{bug_id}.md")
|
|
1708
1765
|
with open(md_path, 'w', encoding='utf-8') as fw:
|
|
1709
1766
|
fw.write(md_content)
|
|
1710
1767
|
|
|
1768
|
+
abs_folder = os.path.abspath(base_dir)
|
|
1769
|
+
abs_md = os.path.abspath(md_path)
|
|
1770
|
+
|
|
1711
1771
|
return {
|
|
1712
1772
|
"status": 1,
|
|
1713
1773
|
"message": "success",
|
|
1714
|
-
"
|
|
1774
|
+
"data": {
|
|
1775
|
+
"bug": bug,
|
|
1776
|
+
"comments": comments_data,
|
|
1777
|
+
"users": users,
|
|
1778
|
+
},
|
|
1715
1779
|
"export": {
|
|
1716
|
-
"folder":
|
|
1717
|
-
"markdown":
|
|
1780
|
+
"folder": abs_folder,
|
|
1781
|
+
"markdown": abs_md,
|
|
1718
1782
|
"attachments": downloaded_files,
|
|
1719
|
-
"
|
|
1720
|
-
|
|
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
|
+
},
|
|
1721
1792
|
}
|
|
1722
1793
|
|
|
1723
1794
|
@staticmethod
|
|
1724
1795
|
def _html_to_markdown(html: str) -> str:
|
|
1725
|
-
"""
|
|
1796
|
+
"""HTML → Markdown 转换"""
|
|
1726
1797
|
if not html:
|
|
1727
1798
|
return ""
|
|
1728
1799
|
text = html
|
|
1729
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*/?>',
|
|
1800
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*/?>',
|
|
1730
1801
|
r'', text)
|
|
1731
|
-
text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
|
|
1802
|
+
text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
|
|
1732
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)
|
|
1733
1807
|
text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text)
|
|
1734
1808
|
text = re.sub(r'<b>(.*?)</b>', r'**\1**', text)
|
|
1735
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)
|
|
1736
1814
|
text = re.sub(r'<br\s*/?>', '\n', text)
|
|
1737
|
-
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)
|
|
1738
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)
|
|
1739
1823
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
1740
1824
|
return text.strip()
|
|
1741
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
|
@@ -723,11 +723,11 @@ class ZenTaoNexus:
|
|
|
723
723
|
self.opened_by = os.getenv("ZENTAO_OPENED_BY") or "69610"
|
|
724
724
|
|
|
725
725
|
code = os.getenv("ZENTAO_OPENED_BY")
|
|
726
|
-
key = os.getenv("ZENTAO_KEY")
|
|
726
|
+
key = os.getenv("ZENTAO_KEY") or os.getenv("KEY")
|
|
727
727
|
if not code:
|
|
728
728
|
raise ValueError("未设置环境变量 ZENTAO_OPENED_BY")
|
|
729
729
|
if not key:
|
|
730
|
-
raise ValueError("未设置环境变量 ZENTAO_KEY")
|
|
730
|
+
raise ValueError("未设置环境变量 ZENTAO_KEY / KEY")
|
|
731
731
|
|
|
732
732
|
use_test = os.getenv("ZENTAO_USE_TEST_ENV", "").lower() in ('1', 'true', 'yes')
|
|
733
733
|
self.client = ZenTaoNativeClient(code=code, key=key, use_test_env=use_test, silent=True)
|
|
@@ -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
|
|