#!/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 post_form(self, path, fields): """提交页面表单(与网页点击「保存」相同);返回响应 HTML(禅道用