feat(skill): 新增 zentao-bug skill,读取禅道 18.x Bug 单并按项目规范修复
禅道 18.8 无 RESTful API 2.0,官方 zentao-cli/MCP 不可用。zentao.py 以 v1 tokens 换会话, 走页面 JSON 模式读取「我的 Bug」列表与 Bug 详情(描述/截图/评论/修改历史/附件), 截图下载到本地供 Agent 查看;Token 存 ~/.config(0600),密码仅存 macOS 钥匙串。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
57
.claude/skills/zentao-bug/SKILL.md
Normal file
57
.claude/skills/zentao-bug/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: zentao-bug
|
||||
description: 读取禅道(njlkairport.chandao.net,开源版 18.8)中的 Bug 单——我的 Bug 列表、单个 Bug 的标题/描述/截图/评论/修改历史/附件——并按 AirLogistics 项目规范定位代码完成修复。禅道 18.8 没有 API 2.0,官方 zentao-cli / MCP 不可用,必须用本 skill 的 zentao.py。当用户提到"禅道"、"bug 单"、"我的 bug"、"修 bug"、"处理 bug"、给出 Bug 编号(如 "#632"、"bug 632")或禅道链接(bug-view-xxx.html / my-work-bug.html)时使用。
|
||||
---
|
||||
|
||||
# 禅道 Bug 读取与修复
|
||||
|
||||
## 工具
|
||||
|
||||
```bash
|
||||
Z=.claude/skills/zentao-bug/zentao.py
|
||||
python3 $Z whoami # 当前账号 + 会话是否有效
|
||||
python3 $Z list # 指派给我的 Bug(= 网页「地盘 → 我的Bug」)
|
||||
python3 $Z list --status active # 只看激活的
|
||||
python3 $Z list --type resolvedBy # 由我解决 / openedBy / closedBy
|
||||
python3 $Z list --json # 结构化输出
|
||||
python3 $Z view 632 # Bug 详情(Markdown)
|
||||
```
|
||||
|
||||
`view` 输出并保存到 `~/.cache/zentao-lite/bugs/<id>/bug.md`:基本信息、重现步骤、附件、历史记录与评论(评论以 `>` 引用块呈现)。
|
||||
描述和评论中的截图下载到 `images/`,文中以绝对路径 `` 引用。
|
||||
|
||||
**截图是需求的主要载体**(团队习惯把原型/标注贴在描述和评论里,几乎不用附件),**必须逐张用 Read 查看**,不能只看文字。
|
||||
|
||||
## 登录(只能由用户本人执行)
|
||||
|
||||
退出码 2 表示未登录或会话过期。禅道会话默认空闲 24 分钟失效。让用户在自己的终端执行:
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/zentao-bug/zentao.py login --account <账号>
|
||||
```
|
||||
|
||||
密码交互输入、不回显;默认存入 macOS 钥匙串,此后会话过期会自动重新登录(`--no-keychain` 则不保存)。
|
||||
**不要向用户索要密码,也不要替用户在命令行里带密码登录。**
|
||||
|
||||
## 修复流程
|
||||
|
||||
1. **读单**:`view <id>`,Read 全部截图;评论里常有后续补充或改口,以最新评论为准。
|
||||
2. **定位页面**:Bug 标题/描述通常写的是菜单名(如"出港移库""进港仓库")。按 CLAUDE.md「页面定位规则」,
|
||||
以首页菜单实际跳转的 Activity 为准(平板 `app/.../HomeFragment.kt`,手机 `module_p` 的 `GnViewModel`/`GjViewModel`)。
|
||||
3. **判断端**:截图是手机竖屏(如 5.5 寸设计稿样式)还是平板横屏,决定改 `res/layout/` 还是 `res/layout-sw600dp/`;
|
||||
业务逻辑改动两端都要检查(CLAUDE.md「业务以平板为准」)。截图提到"原型"时对照 `documents/5.5寸手持端设计文件/`。
|
||||
4. **修改并验证**:遵守 CLAUDE.md 的 UI 规范;`./gradlew assembleDebug` 编译通过;能在模拟器验证的就验证并截图比对。
|
||||
5. **汇报**:按 Bug 逐条说明改了什么、改在哪里、如何验证、有无未覆盖项。多个 Bug 时每个 Bug 单独提交,
|
||||
提交信息带 `Bug #<id>`。不确定截图意图时先问用户,禁止臆测。
|
||||
|
||||
## 写回禅道
|
||||
|
||||
暂未实现(写操作会改动线上 Bug 状态,需用户确认后再加)。修复完成后由用户在禅道手动"解决",
|
||||
可附上提交号与改动摘要供用户粘贴。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 认证:`POST /api.php/v1/tokens` 换 Token;数据走页面 JSON 模式(`.html` → `.json`),Cookie `zentaosid=<Token>`。
|
||||
- 接口:`/my-work-bug-<type>-0-id_desc-0-<perPage>-<page>.json`、`/bug-view-<id>.json`、`/file-read-<fileID>.<ext>`。
|
||||
- REST v1 的 `/products/<id>/bugs` 只返回激活中的 Bug,不能替代"我的 Bug"页面。
|
||||
- 凭据:Token 在 `~/.config/zentao-lite/config.json`(0600),密码只在钥匙串(服务名 `zentao-lite`)。`logout` 一并清除。
|
||||
528
.claude/skills/zentao-bug/zentao.py
Normal file
528
.claude/skills/zentao-bug/zentao.py
Normal file
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
"""禅道 18.x Bug 读取工具(仅依赖 Python 标准库)。
|
||||
|
||||
禅道 18.8 没有 RESTful API 2.0,官方 zentao-cli 无法使用;本脚本组合两条 18.x 可用的通道:
|
||||
- 认证:POST /api.php/v1/tokens 换取 Token(即会话 ID)
|
||||
- 数据:页面 JSON 模式(把页面地址 .html 换成 .json),Cookie 带 zentaosid=<Token>
|
||||
- 我的 Bug:/my-work-bug-<type>-0-id_desc-0-<perPage>-<page>.json
|
||||
- Bug 详情:/bug-view-<id>.json(含描述、修改记录、评论)
|
||||
- 图片:/file-read-<fileID>.<ext>
|
||||
|
||||
凭据:Token 存 ~/.config/zentao-lite/config.json(0600);密码只存 macOS 钥匙串,
|
||||
用于会话过期(禅道默认 24 分钟空闲失效)后自动重新登录,任何文件里都不落明文密码。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("ZENTAO_LITE_HOME", Path.home() / ".config" / "zentao-lite"))
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DEFAULT_OUT = Path.home() / ".cache" / "zentao-lite" / "bugs"
|
||||
KEYCHAIN_SERVICE = "zentao-lite"
|
||||
DEFAULT_URL = "https://njlkairport.chandao.net"
|
||||
|
||||
STATUS = {"active": "激活", "resolved": "已解决", "closed": "已关闭"}
|
||||
TYPES = {
|
||||
"codeerror": "代码错误", "config": "配置相关", "install": "安装部署", "security": "安全相关",
|
||||
"performance": "性能问题", "standard": "标准规范", "automation": "测试脚本",
|
||||
"designdefect": "设计缺陷", "others": "其他",
|
||||
}
|
||||
RESOLUTIONS = {
|
||||
"fixed": "已解决", "bydesign": "设计如此", "duplicate": "重复Bug", "external": "外部原因",
|
||||
"notrepro": "无法重现", "postponed": "延期处理", "willnotfix": "不予解决", "tostory": "转为需求",
|
||||
}
|
||||
ACTIONS = {
|
||||
"opened": "创建", "edited": "编辑", "commented": "备注", "resolved": "解决", "activated": "激活",
|
||||
"closed": "关闭", "assigned": "指派", "confirmed": "确认", "bugconfirmed": "确认",
|
||||
"tostory": "转需求", "totask": "转任务", "linked2build": "关联版本", "linked2release": "关联发布",
|
||||
}
|
||||
FIELDS = {
|
||||
"steps": "重现步骤", "title": "标题", "assignedTo": "指派给", "status": "状态", "severity": "严重程度",
|
||||
"pri": "优先级", "type": "类型", "resolution": "解决方案", "resolvedBuild": "解决版本",
|
||||
"module": "模块", "deadline": "截止日期", "keywords": "关键词", "openedBuild": "影响版本",
|
||||
"resolvedBy": "解决者", "resolvedDate": "解决日期", "closedBy": "关闭者", "closedDate": "关闭日期",
|
||||
"confirmed": "是否确认", "mailto": "抄送给", "linkBug": "相关Bug", "activatedCount": "激活次数",
|
||||
}
|
||||
USER_FIELDS = {"assignedTo", "resolvedBy", "closedBy", "openedBy"}
|
||||
BUG_TYPES = ("assignedTo", "openedBy", "resolvedBy", "closedBy")
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
print(f"错误:{msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 配置与凭据
|
||||
|
||||
def load_config():
|
||||
if not CONFIG_FILE.exists():
|
||||
die("尚未登录,请先执行:python3 .claude/skills/zentao-bug/zentao.py login", 2)
|
||||
return json.loads(CONFIG_FILE.read_text())
|
||||
|
||||
|
||||
def save_config(cfg):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
tmp = CONFIG_FILE.with_suffix(".tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, CONFIG_FILE)
|
||||
|
||||
|
||||
def keychain_get(account):
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
r = subprocess.run(
|
||||
["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return r.stdout.rstrip("\n") if r.returncode == 0 else None
|
||||
|
||||
|
||||
def keychain_set(account, password):
|
||||
if sys.platform != "darwin":
|
||||
return False
|
||||
r = subprocess.run(
|
||||
["security", "add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", password],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def keychain_delete(account):
|
||||
if sys.platform == "darwin":
|
||||
subprocess.run(["security", "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account],
|
||||
capture_output=True)
|
||||
|
||||
|
||||
def request_token(url, account, password):
|
||||
body = json.dumps({"account": account, "password": password}).encode()
|
||||
req = urllib.request.Request(url + "/api.php/v1/tokens", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
data = json.load(e)
|
||||
except Exception:
|
||||
data = {"error": f"HTTP {e.code}"}
|
||||
if not data.get("token"):
|
||||
raise AuthError(data.get("error") or "登录失败")
|
||||
return data["token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 请求
|
||||
|
||||
class Client:
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self.url = cfg["url"].rstrip("/")
|
||||
self.relogged = False
|
||||
|
||||
def _relogin(self):
|
||||
if self.relogged:
|
||||
raise AuthError("重新登录后仍无权限")
|
||||
password = keychain_get(self.cfg["account"])
|
||||
if not password:
|
||||
raise AuthError("会话已过期,且钥匙串中没有保存密码,请重新执行 login")
|
||||
self.cfg["token"] = request_token(self.url, self.cfg["account"], password)
|
||||
save_config(self.cfg)
|
||||
self.relogged = True
|
||||
|
||||
def _open(self, path):
|
||||
req = urllib.request.Request(self.url + path, headers={
|
||||
"Cookie": f"zentaosid={self.cfg['token']}", "Token": self.cfg["token"],
|
||||
})
|
||||
try:
|
||||
return urllib.request.urlopen(req, timeout=60)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise AuthError("Token 无效")
|
||||
raise
|
||||
|
||||
def page(self, path):
|
||||
"""读取页面 JSON;会话失效时自动用钥匙串密码重登一次。"""
|
||||
for _ in range(2):
|
||||
try:
|
||||
with self._open(path) as resp:
|
||||
outer = json.load(resp)
|
||||
data = outer.get("data")
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
if outer.get("status") != "success" or (isinstance(data, dict) and "user-login" in str(data.get("locate", ""))):
|
||||
raise AuthError("会话已过期")
|
||||
return data
|
||||
except AuthError:
|
||||
self._relogin()
|
||||
raise AuthError("无法获取数据")
|
||||
|
||||
def download(self, path, dest):
|
||||
for _ in range(2):
|
||||
try:
|
||||
with self._open(path) as resp:
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
content = resp.read()
|
||||
if "text/html" in ctype or "application/json" in ctype:
|
||||
if b"user-login" in content:
|
||||
raise AuthError("会话已过期")
|
||||
raise RuntimeError(f"返回的不是文件内容({ctype})")
|
||||
dest.write_bytes(content)
|
||||
return
|
||||
except AuthError:
|
||||
self._relogin()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- HTML → Markdown
|
||||
|
||||
class Html2Md(HTMLParser):
|
||||
BLOCK = {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "table", "blockquote", "pre"}
|
||||
|
||||
def __init__(self, on_image):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.out = []
|
||||
self.lists = []
|
||||
self.on_image = on_image
|
||||
self.href = None
|
||||
self.skip = 0
|
||||
|
||||
def _nl(self, n=1):
|
||||
text = "".join(self.out)
|
||||
have = len(text) - len(text.rstrip("\n"))
|
||||
if text.strip():
|
||||
self.out.append("\n" * max(0, n - have))
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag in ("style", "script"):
|
||||
self.skip += 1
|
||||
elif tag in self.BLOCK:
|
||||
self._nl(2 if tag != "tr" else 1)
|
||||
if tag[0] == "h" and tag[1:].isdigit():
|
||||
self.out.append("**") # 标题降级为加粗,避免与外层 Markdown 标题层级混淆
|
||||
elif tag == "br":
|
||||
self.out.append("\n")
|
||||
elif tag in ("ul", "ol"):
|
||||
self._nl(1)
|
||||
self.lists.append([tag, 0])
|
||||
elif tag == "li":
|
||||
self._nl(1)
|
||||
indent = " " * max(0, len(self.lists) - 1)
|
||||
if self.lists and self.lists[-1][0] == "ol":
|
||||
self.lists[-1][1] += 1
|
||||
self.out.append(f"{indent}{self.lists[-1][1]}. ")
|
||||
else:
|
||||
self.out.append(f"{indent}- ")
|
||||
elif tag in ("strong", "b"):
|
||||
self.out.append("**")
|
||||
elif tag == "td" or tag == "th":
|
||||
self.out.append("| ")
|
||||
elif tag == "a":
|
||||
self.href = a.get("href")
|
||||
self.out.append("[")
|
||||
elif tag == "img":
|
||||
src = a.get("src") or a.get("data-src") or ""
|
||||
if src:
|
||||
self.out.append(self.on_image(src))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("style", "script"):
|
||||
self.skip = max(0, self.skip - 1)
|
||||
elif tag in self.BLOCK:
|
||||
if tag[0] == "h" and tag[1:].isdigit():
|
||||
self.out.append("**")
|
||||
self._nl(2 if tag != "tr" else 1)
|
||||
elif tag in ("ul", "ol"):
|
||||
if self.lists:
|
||||
self.lists.pop()
|
||||
self._nl(2)
|
||||
elif tag in ("strong", "b"):
|
||||
self.out.append("**")
|
||||
elif tag in ("td", "th"):
|
||||
self.out.append(" ")
|
||||
elif tag == "a":
|
||||
self.out.append(f"]({self.href})" if self.href else "]")
|
||||
self.href = None
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.skip:
|
||||
self.out.append(re.sub(r"[ \t\r\n]+", " ", data) if not data.isspace() else (" " if self.out and not self.out[-1].endswith(("\n", " ")) else ""))
|
||||
|
||||
def markdown(self):
|
||||
text = "".join(self.out).replace("****", "")
|
||||
text = "\n".join(line.rstrip() for line in text.splitlines())
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
|
||||
|
||||
class ImageFetcher:
|
||||
"""把富文本中的图片下载到本地,返回 Markdown 图片引用(绝对路径,可直接用 Read 查看)。"""
|
||||
|
||||
def __init__(self, client, img_dir):
|
||||
self.client = client
|
||||
self.dir = img_dir
|
||||
self.files = []
|
||||
self.errors = []
|
||||
|
||||
def __call__(self, src):
|
||||
src = html.unescape(src.strip())
|
||||
m = re.fullmatch(r"\{(\d+)\.(\w+)\}", src) # 数据库原始占位符 {1154.jpeg}
|
||||
if m:
|
||||
src = f"/file-read-{m.group(1)}.{m.group(2)}"
|
||||
try:
|
||||
dest = self._fetch(src)
|
||||
except Exception as e: # 单张图片失败不影响整体
|
||||
self.errors.append(f"{src}:{e}")
|
||||
return f""
|
||||
if dest not in self.files:
|
||||
self.files.append(dest)
|
||||
return f""
|
||||
|
||||
def _fetch(self, src):
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
if src.startswith("data:"):
|
||||
head, b64 = src.split(",", 1)
|
||||
ext = re.search(r"image/(\w+)", head)
|
||||
dest = self.dir / f"inline-{len(self.files) + 1}.{ext.group(1) if ext else 'png'}"
|
||||
dest.write_bytes(base64.b64decode(b64))
|
||||
return dest
|
||||
parsed = urllib.parse.urlparse(src)
|
||||
if parsed.netloc and parsed.netloc not in self.client.url:
|
||||
dest = self.dir / Path(parsed.path).name
|
||||
if not dest.exists():
|
||||
urllib.request.urlretrieve(src, dest)
|
||||
return dest
|
||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
||||
m = re.search(r"file-read-(\d+)(?:\.(\w+))?", path)
|
||||
name = f"{m.group(1)}.{m.group(2) or 'bin'}" if m else Path(parsed.path).name
|
||||
dest = self.dir / name
|
||||
if not dest.exists() or dest.stat().st_size == 0:
|
||||
self.client.download(path, dest)
|
||||
return dest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 命令
|
||||
|
||||
def cmd_login(args):
|
||||
url = (args.url or os.environ.get("ZENTAO_URL") or DEFAULT_URL).rstrip("/")
|
||||
account = args.account or os.environ.get("ZENTAO_ACCOUNT") or input("禅道账号:").strip()
|
||||
password = os.environ.get("ZENTAO_PASSWORD") or getpass.getpass("禅道密码(输入不回显):")
|
||||
try:
|
||||
token = request_token(url, account, password)
|
||||
except AuthError as e:
|
||||
die(f"登录失败:{e}")
|
||||
cfg = {"url": url, "account": account, "token": token}
|
||||
save_config(cfg)
|
||||
saved = False if args.no_keychain else keychain_set(account, password)
|
||||
print(f"登录成功:{account} @ {url}")
|
||||
print(f"Token 已保存到 {CONFIG_FILE}(权限 0600)")
|
||||
print("密码已存入 macOS 钥匙串,会话过期时自动重新登录" if saved
|
||||
else "未保存密码:会话过期(默认 24 分钟空闲)后需重新 login")
|
||||
|
||||
|
||||
def cmd_logout(args):
|
||||
if CONFIG_FILE.exists():
|
||||
keychain_delete(json.loads(CONFIG_FILE.read_text()).get("account", ""))
|
||||
CONFIG_FILE.unlink()
|
||||
print("已退出并清除本地 Token 与钥匙串密码")
|
||||
|
||||
|
||||
def cmd_whoami(args):
|
||||
client = Client(load_config())
|
||||
data = client.page("/my-work-bug-assignedTo-0-id_desc-0-1-1.json") # 顺带验证会话可用
|
||||
users = data.get("users", {})
|
||||
account = client.cfg["account"]
|
||||
print(f"{account}({users.get(account, '')}) @ {client.url},会话有效")
|
||||
|
||||
|
||||
def fetch_bug_list(client, bug_type, per_page=100):
|
||||
bugs, page, users = [], 1, {}
|
||||
while True:
|
||||
data = client.page(f"/my-work-bug-{bug_type}-0-id_desc-0-{per_page}-{page}.json")
|
||||
users.update(data.get("users") or {})
|
||||
bugs.extend(data.get("bugs") or [])
|
||||
pager = data.get("pager") or {}
|
||||
if page >= int(pager.get("pageTotal") or 1):
|
||||
return bugs, users
|
||||
page += 1
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
client = Client(load_config())
|
||||
bugs, users = fetch_bug_list(client, args.type)
|
||||
if args.status != "all":
|
||||
bugs = [b for b in bugs if b.get("status") == args.status]
|
||||
if args.json:
|
||||
print(json.dumps([{
|
||||
"id": int(b["id"]), "title": html.unescape(b["title"]), "status": b["status"],
|
||||
"severity": int(b["severity"]), "pri": int(b["pri"]), "type": b["type"],
|
||||
"product": b.get("productName"), "openedBy": b["openedBy"], "openedDate": b["openedDate"],
|
||||
"assignedTo": b["assignedTo"], "deadline": b.get("deadline"),
|
||||
} for b in bugs], ensure_ascii=False, indent=2))
|
||||
return
|
||||
title = {"assignedTo": "指派给我", "openedBy": "由我创建", "resolvedBy": "由我解决", "closedBy": "由我关闭"}[args.type]
|
||||
print(f"# 我的 Bug({title},状态:{args.status})共 {len(bugs)} 条\n")
|
||||
print("| ID | 严重 | 优先级 | 状态 | 类型 | 标题 | 创建人 | 创建时间 |")
|
||||
print("|---|---|---|---|---|---|---|---|")
|
||||
for b in bugs:
|
||||
t = html.unescape(b["title"]).replace("|", "\\|")
|
||||
print(f"| {b['id']} | {b['severity']} | {b['pri']} | {STATUS.get(b['status'], b['status'])} | "
|
||||
f"{TYPES.get(b['type'], b['type'])} | {t} | {users.get(b['openedBy'], b['openedBy'])} | {b['openedDate'][:16]} |")
|
||||
|
||||
|
||||
def cmd_view(args):
|
||||
client = Client(load_config())
|
||||
data = client.page(f"/bug-view-{args.id}.json")
|
||||
bug = data.get("bug")
|
||||
if not bug:
|
||||
die(f"Bug #{args.id} 不存在或无权查看")
|
||||
users = data.get("users") or {}
|
||||
who = lambda a: f"{users.get(a, a)}({a})" if a and users.get(a) and users.get(a) != a else (a or "-")
|
||||
|
||||
def show(field, value):
|
||||
if not value:
|
||||
return "空"
|
||||
if field in USER_FIELDS:
|
||||
return who(value)
|
||||
if field == "mailto":
|
||||
return "、".join(who(v) for v in value.split(",") if v) or "空"
|
||||
if field == "confirmed":
|
||||
return "是" if value == "1" else "否"
|
||||
return {"status": STATUS, "resolution": RESOLUTIONS, "type": TYPES}.get(field, {}).get(value, value)
|
||||
|
||||
out_dir = Path(args.out or DEFAULT_OUT) / str(args.id)
|
||||
fetcher = ImageFetcher(client, out_dir / "images")
|
||||
|
||||
def md(html_text):
|
||||
p = Html2Md(fetcher)
|
||||
p.feed(html_text or "")
|
||||
p.close()
|
||||
return p.markdown()
|
||||
|
||||
module = " / ".join(m.get("name", "") for m in (data.get("modulePath") or [])) or "/"
|
||||
product = (data.get("product") or {}).get("name") or (data.get("products") or {}).get(bug.get("product"), "")
|
||||
lines = [
|
||||
f"# Bug #{bug['id']} {html.unescape(bug['title'])}", "",
|
||||
f"- 链接:{client.url}/bug-view-{bug['id']}.html",
|
||||
f"- 产品 / 模块:{product} / {module}",
|
||||
f"- 状态:{STATUS.get(bug['status'], bug['status'])}"
|
||||
+ (f"({RESOLUTIONS.get(bug['resolution'], bug['resolution'])})" if bug.get("resolution") else "")
|
||||
+ f" 确认:{'是' if bug.get('confirmed') == '1' else '否'}",
|
||||
f"- 严重程度:{bug['severity']} 优先级:{bug['pri']} 类型:{TYPES.get(bug['type'], bug['type'])}",
|
||||
f"- 创建:{who(bug['openedBy'])} {bug['openedDate']} 影响版本:{bug.get('openedBuild', '')}",
|
||||
f"- 指派给:{who(bug['assignedTo'])} {bug.get('assignedDate', '')}",
|
||||
]
|
||||
if bug.get("resolvedBy"):
|
||||
lines.append(f"- 解决:{who(bug['resolvedBy'])} {bug.get('resolvedDate', '')} 解决版本:{bug.get('resolvedBuild', '')}")
|
||||
if bug.get("deadline") and not bug["deadline"].startswith("0000"):
|
||||
lines.append(f"- 截止日期:{bug['deadline']}")
|
||||
if bug.get("keywords"):
|
||||
lines.append(f"- 关键词:{bug['keywords']}")
|
||||
lines += ["", "## 重现步骤", "", md(bug.get("steps")) or "(空)"]
|
||||
|
||||
files = list((bug.get("files") or {}).values()) if isinstance(bug.get("files"), dict) else (bug.get("files") or [])
|
||||
if files:
|
||||
lines += ["", "## 附件", ""]
|
||||
att_dir = out_dir / "attachments"
|
||||
att_dir.mkdir(parents=True, exist_ok=True)
|
||||
for f in files:
|
||||
name = f"{f['id']}-{f.get('title', 'file')}"
|
||||
if not name.lower().endswith("." + f.get("extension", "").lower()):
|
||||
name += "." + f.get("extension", "bin")
|
||||
dest = att_dir / re.sub(r"[/\\]", "_", name)
|
||||
try:
|
||||
if not dest.exists():
|
||||
client.download(f"/file-read-{f['id']}.{f.get('extension', '')}", dest)
|
||||
lines.append(f"- [{f.get('title')}]({dest})({f.get('size', '?')} 字节)")
|
||||
except Exception as e:
|
||||
lines.append(f"- {f.get('title')}:下载失败({e})")
|
||||
|
||||
actions = sorted((data.get("actions") or {}).values(), key=lambda a: (a.get("date", ""), int(a.get("id", 0))))
|
||||
comment_count = sum(1 for a in actions if (a.get("comment") or "").strip())
|
||||
lines += ["", f"## 历史记录与评论({len(actions)} 条,含评论 {comment_count} 条)", ""]
|
||||
for a in actions:
|
||||
name = ACTIONS.get(a.get("action", ""), a.get("action", ""))
|
||||
extra = a.get("extra") or ""
|
||||
if a.get("action") == "resolved" and extra:
|
||||
extra = RESOLUTIONS.get(extra.split(",")[0], extra)
|
||||
elif a.get("action") == "assigned" and extra:
|
||||
extra = f"→ {who(extra)}"
|
||||
lines.append(f"### {a.get('date', '')} {who(a.get('actor'))} {name}{(' ' + extra) if extra else ''}")
|
||||
for h in a.get("history") or []:
|
||||
field = FIELDS.get(h.get("field"), h.get("field"))
|
||||
if h.get("field") == "steps":
|
||||
lines.append(f"- 修改了 {field}(以上方当前内容为准)")
|
||||
else:
|
||||
lines.append(f"- 修改了 {field}:{show(h.get('field'), h.get('old'))} → {show(h.get('field'), h.get('new'))}")
|
||||
if (a.get("comment") or "").strip():
|
||||
lines += ["", "**评论:**", ""] + [f"> {l}" if l else ">" for l in md(a["comment"]).splitlines()]
|
||||
lines.append("")
|
||||
|
||||
if fetcher.files:
|
||||
lines += ["## 本地图片", ""] + [f"- 图{i + 1}:{p}" for i, p in enumerate(fetcher.files)] + [""]
|
||||
if fetcher.errors:
|
||||
lines += ["## 图片下载失败", ""] + [f"- {e}" for e in fetcher.errors] + [""]
|
||||
|
||||
text = "\n".join(lines).rstrip() + "\n"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "bug.md").write_text(text)
|
||||
if args.json:
|
||||
bug["_images"] = [str(p) for p in fetcher.files]
|
||||
bug["_actions"] = actions
|
||||
print(json.dumps(bug, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(text)
|
||||
print(f"(已保存到 {out_dir / 'bug.md'},图片 {len(fetcher.files)} 张)")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="禅道 18.x Bug 读取工具")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("login", help="登录并保存 Token(密码存 macOS 钥匙串)")
|
||||
p.add_argument("--url", help=f"禅道地址,默认 {DEFAULT_URL}")
|
||||
p.add_argument("--account", help="账号")
|
||||
p.add_argument("--no-keychain", action="store_true", help="不在钥匙串保存密码")
|
||||
p.set_defaults(func=cmd_login)
|
||||
|
||||
sub.add_parser("logout", help="清除 Token 与钥匙串密码").set_defaults(func=cmd_logout)
|
||||
sub.add_parser("whoami", help="查看当前账号并验证会话").set_defaults(func=cmd_whoami)
|
||||
|
||||
p = sub.add_parser("list", help="我的 Bug 列表")
|
||||
p.add_argument("--type", choices=BUG_TYPES, default="assignedTo", help="默认 assignedTo(指派给我)")
|
||||
p.add_argument("--status", choices=("active", "resolved", "closed", "all"), default="all")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_list)
|
||||
|
||||
p = sub.add_parser("view", help="Bug 详情(描述、图片、历史、评论、附件)")
|
||||
p.add_argument("id", type=int)
|
||||
p.add_argument("--out", help=f"输出目录,默认 {DEFAULT_OUT}")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_view)
|
||||
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
args.func(args)
|
||||
except AuthError as e:
|
||||
die(f"{e}。请执行:python3 .claude/skills/zentao-bug/zentao.py login", 2)
|
||||
except urllib.error.URLError as e:
|
||||
die(f"无法访问禅道:{e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user