diff --git a/.claude/skills/zentao-bug/SKILL.md b/.claude/skills/zentao-bug/SKILL.md new file mode 100644 index 0000000..5abba50 --- /dev/null +++ b/.claude/skills/zentao-bug/SKILL.md @@ -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//bug.md`:基本信息、重现步骤、附件、历史记录与评论(评论以 `>` 引用块呈现)。 +描述和评论中的截图下载到 `images/`,文中以绝对路径 `![图N](/Users/.../images/1154.jpeg)` 引用。 + +**截图是需求的主要载体**(团队习惯把原型/标注贴在描述和评论里,几乎不用附件),**必须逐张用 Read 查看**,不能只看文字。 + +## 登录(只能由用户本人执行) + +退出码 2 表示未登录或会话过期。禅道会话默认空闲 24 分钟失效。让用户在自己的终端执行: + +```bash +python3 .claude/skills/zentao-bug/zentao.py login --account <账号> +``` + +密码交互输入、不回显;默认存入 macOS 钥匙串,此后会话过期会自动重新登录(`--no-keychain` 则不保存)。 +**不要向用户索要密码,也不要替用户在命令行里带密码登录。** + +## 修复流程 + +1. **读单**:`view `,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 #`。不确定截图意图时先问用户,禁止臆测。 + +## 写回禅道 + +暂未实现(写操作会改动线上 Bug 状态,需用户确认后再加)。修复完成后由用户在禅道手动"解决", +可附上提交号与改动摘要供用户粘贴。 + +## 实现说明 + +- 认证:`POST /api.php/v1/tokens` 换 Token;数据走页面 JSON 模式(`.html` → `.json`),Cookie `zentaosid=`。 +- 接口:`/my-work-bug--0-id_desc-0--.json`、`/bug-view-.json`、`/file-read-.`。 +- REST v1 的 `/products//bugs` 只返回激活中的 Bug,不能替代"我的 Bug"页面。 +- 凭据:Token 在 `~/.config/zentao-lite/config.json`(0600),密码只在钥匙串(服务名 `zentao-lite`)。`logout` 一并清除。 diff --git a/.claude/skills/zentao-bug/zentao.py b/.claude/skills/zentao-bug/zentao.py new file mode 100644 index 0000000..37a622a --- /dev/null +++ b/.claude/skills/zentao-bug/zentao.py @@ -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= + - 我的 Bug:/my-work-bug--0-id_desc-0--.json + - Bug 详情:/bug-view-.json(含描述、修改记录、评论) + - 图片:/file-read-. + +凭据: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"![图片下载失败]({src})" + if dest not in self.files: + self.files.append(dest) + return f"![图{self.files.index(dest) + 1}]({dest})" + + 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()