@bangdao-ai/zentao-mcp 2.0.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/zentao-mcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "禅道项目管理AI中枢 v2.0 — 35+MCP工具覆盖需求·任务·Bug·用例·发布·我的地盘全生命周期,原生API模式,Bug智能分类引擎+CSV批量用例导入+截图上传,一句话驱动禅道",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1527,9 +1527,20 @@ class ZenTaoNexus:
1527
1527
  }
1528
1528
 
1529
1529
  def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
1530
- """导出 Bug 详情到本地文件夹:{bug_id}_bug/{bug_id}_bug.md + 附件 + 图片 + 备注"""
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
- folder_name = f"{bug_id}_bug"
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.makedirs(base_dir, exist_ok=True)
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
- # 下载附件(files 可能是 dict{id: info} 或 list[info])
1558
- raw_files = bug.get("files", {})
1559
- file_list = list(raw_files.values()) if isinstance(raw_files, dict) else raw_files
1560
- for file_info in file_list:
1561
- if isinstance(file_info, str):
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 = file_info.get("title", "unknown")
1564
- secret_id = file_info.get("secretID", "")
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(base_dir, safe_name)
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
- # steps HTML 提取并下载图片,替换为本地路径
1581
- steps_html = bug.get("steps", "").replace("&amp;", "&")
1582
- img_pattern = re.compile(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>', re.IGNORECASE)
1583
- img_index = 0
1625
+ # ── 2. 下载描述(steps)中的图片 ──
1626
+ steps_html = (bug.get("steps") or "").replace("&amp;", "&")
1627
+ step_img_idx = 0
1584
1628
  for match in img_pattern.finditer(steps_html):
1585
1629
  img_url = match.group(1)
1586
- ext = ".png"
1587
- ext_match = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1588
- if ext_match:
1589
- ext = f".{ext_match.group(1).lower()}"
1590
- img_index += 1
1591
- img_name = f"step_img_{img_index}{ext}"
1592
- save_path = os.path.join(base_dir, img_name)
1593
- r = self.client.file_download(img_url, save_path)
1594
- if r.get("status") == 1:
1595
- steps_html = steps_html.replace(img_url, f"./{img_name}")
1596
- downloaded_images.append(img_name)
1597
-
1598
- # 生成 Markdown
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("&amp;", "&").replace("&nbsp;", " ")
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"| 关键词 | {bug.get('keywords', '')} |",
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
- steps_md = self._html_to_markdown(steps_html)
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']}](./{f['saved']})")
1730
+ md_lines.append(f"![{f['name']}]({path_ref})")
1637
1731
  else:
1638
- md_lines.append(f"- [{f['name']}](./{f['saved']})")
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
- _ACTION_MAP = {
1645
- "opened": "创建", "edited": "编辑", "commented": "备注",
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
- comment_img_idx = 0
1657
- for act in action_list:
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
- if comment_html:
1671
- comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
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
- history = act.get("history", [])
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
- old_short = (old_val[:80] + '...') if len(old_val) > 80 else old_val
1702
- new_short = (new_val[:80] + '...') if len(new_val) > 80 else new_val
1703
- md_lines.append(f"- **{field}**: `{old_short}` → `{new_short}`")
1704
- md_lines.append("")
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}_bug.md")
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
- "bug": bug,
1774
+ "data": {
1775
+ "bug": bug,
1776
+ "comments": comments_data,
1777
+ "users": users,
1778
+ },
1715
1779
  "export": {
1716
- "folder": os.path.abspath(base_dir),
1717
- "markdown": os.path.abspath(md_path),
1780
+ "folder": abs_folder,
1781
+ "markdown": abs_md,
1718
1782
  "attachments": downloaded_files,
1719
- "step_images": downloaded_images,
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
- """简易 HTML→Markdown:处理 p、strong、img、br 标签"""
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'![\2](\1)', text)
1731
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
1802
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1732
1803
  r'![](\1)', 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>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
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'&lt;', '<', text)
1820
+ text = re.sub(r'&gt;', '>', text)
1821
+ text = re.sub(r'&quot;', '"', text)
1822
+ text = re.sub(r'&nbsp;', ' ', 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详情并自动导出到本地文件夹(含Markdown文档、附件、步骤截图)",
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详情并自动导出到本地文件夹(含Markdown文档、附件、步骤截图)",
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
  }
@@ -1346,7 +1346,17 @@ class ZenTaoNexus:
1346
1346
  }
1347
1347
 
1348
1348
  def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
1349
- """导出 Bug 详情到本地文件夹:{bug_id}_bug/{bug_id}_bug.md + 附件 + 图片 + 备注"""
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
- folder_name = f"{bug_id}_bug"
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.makedirs(base_dir, exist_ok=True)
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
- # 下载附件(files 可能是 dict{id: info} 或 list[info])
1375
- raw_files = bug.get("files", {})
1376
- file_list = list(raw_files.values()) if isinstance(raw_files, dict) else raw_files
1377
- for file_info in file_list:
1378
- if isinstance(file_info, str):
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 = file_info.get("title", "unknown")
1381
- secret_id = file_info.get("secretID", "")
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(base_dir, safe_name)
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
- # steps HTML 提取并下载图片,替换为本地路径
1398
- steps_html = bug.get("steps", "").replace("&amp;", "&")
1399
- img_pattern = re.compile(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>', re.IGNORECASE)
1400
- img_index = 0
1441
+ # ── 2. 下载描述(steps)中的图片 ──
1442
+ steps_html = (bug.get("steps") or "").replace("&amp;", "&")
1443
+ step_img_idx = 0
1401
1444
  for match in img_pattern.finditer(steps_html):
1402
1445
  img_url = match.group(1)
1403
- ext = ".png"
1404
- ext_match = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1405
- if ext_match:
1406
- ext = f".{ext_match.group(1).lower()}"
1407
- img_index += 1
1408
- img_name = f"step_img_{img_index}{ext}"
1409
- save_path = os.path.join(base_dir, img_name)
1410
- r = self.client.file_download(img_url, save_path)
1411
- if r.get("status") == 1:
1412
- steps_html = steps_html.replace(img_url, f"./{img_name}")
1413
- downloaded_images.append(img_name)
1414
-
1415
- # 生成 Markdown
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("&amp;", "&").replace("&nbsp;", " ")
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"| 关键词 | {bug.get('keywords', '')} |",
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
- steps_md = self._html_to_markdown(steps_html)
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']}](./{f['saved']})")
1546
+ md_lines.append(f"![{f['name']}]({path_ref})")
1454
1547
  else:
1455
- md_lines.append(f"- [{f['name']}](./{f['saved']})")
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
- _ACTION_MAP = {
1462
- "opened": "创建", "edited": "编辑", "commented": "备注",
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
- comment_img_idx = 0
1474
- for act in action_list:
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
- if comment_html:
1488
- comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
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
- history = act.get("history", [])
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
- old_short = (old_val[:80] + '...') if len(old_val) > 80 else old_val
1519
- new_short = (new_val[:80] + '...') if len(new_val) > 80 else new_val
1520
- md_lines.append(f"- **{field}**: `{old_short}` → `{new_short}`")
1521
- md_lines.append("")
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}_bug.md")
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
- "bug": bug,
1590
+ "data": {
1591
+ "bug": bug,
1592
+ "comments": comments_data,
1593
+ "users": users,
1594
+ },
1532
1595
  "export": {
1533
- "folder": os.path.abspath(base_dir),
1534
- "markdown": os.path.abspath(md_path),
1596
+ "folder": abs_folder,
1597
+ "markdown": abs_md,
1535
1598
  "attachments": downloaded_files,
1536
- "step_images": downloaded_images,
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
- """简易 HTML→Markdown:处理 p、strong、img、br 标签"""
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'![\2](\1)', text)
1548
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
1618
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1549
1619
  r'![](\1)', 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>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
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'&lt;', '<', text)
1636
+ text = re.sub(r'&gt;', '>', text)
1637
+ text = re.sub(r'&quot;', '"', text)
1638
+ text = re.sub(r'&nbsp;', ' ', text)
1556
1639
  text = re.sub(r'\n{3,}', '\n\n', text)
1557
1640
  return text.strip()
1558
1641