feat(skill): zentao-bug 新增 resolve 命令,支持在禅道解决 Bug
- resolve <id...>:等同网页「解决」表单,默认解决方案 fixed、解决版本 trunk(主干), 支持 --dry-run 预览;只处理激活状态,提交后回读校验 - 接口提交不会自动补默认值:解决日期与指派给先从解决表单 HTML 读取默认值 (服务器北京时间 / 创建人)再原样提交,避免用本机时区时间 - SKILL.md 补写回说明,并注明 login 需在独立终端执行(! 前缀无 TTY) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ python3 $Z view 632 # Bug 详情(Markdown)
|
||||
python3 .claude/skills/zentao-bug/zentao.py login --account <账号>
|
||||
```
|
||||
|
||||
必须在**独立的终端窗口**里执行——Claude Code 的 `! <命令>` 没有 TTY,密码提示会直接 EOFError。
|
||||
密码交互输入、不回显;默认存入 macOS 钥匙串,此后会话过期会自动重新登录(`--no-keychain` 则不保存)。
|
||||
**不要向用户索要密码,也不要替用户在命令行里带密码登录。**
|
||||
|
||||
@@ -46,12 +47,25 @@ python3 .claude/skills/zentao-bug/zentao.py login --account <账号>
|
||||
|
||||
## 写回禅道
|
||||
|
||||
暂未实现(写操作会改动线上 Bug 状态,需用户确认后再加)。修复完成后由用户在禅道手动"解决",
|
||||
可附上提交号与改动摘要供用户粘贴。
|
||||
```bash
|
||||
python3 $Z resolve 621 622 --comment 已解决 --dry-run # 先预览
|
||||
python3 $Z resolve 621 622 --comment 已解决 # 提交
|
||||
```
|
||||
|
||||
等同网页「解决」表单,默认:解决方案 `fixed`(已解决)、解决版本 `trunk`(主干);**解决日期与指派给取网页表单渲染出的默认值**
|
||||
(服务器当前时间 / 创建人,可用 `--date` / `--assign-to` 覆盖),其余字段不动;备注默认留空,团队习惯只写「已解决」,不写业务细节。
|
||||
只处理激活状态的单子,提交后回读校验状态。
|
||||
|
||||
- **写操作会改动线上状态,只在用户明确要求时执行**;只解决确实修好、且没有待确认疑问的单子。
|
||||
无缺陷 / 需后端修复 / 有疑问的保持激活,并向用户列出原因(无缺陷应选「设计如此」等其他方案,不要标「已解决」)
|
||||
- 接口提交**不会**自动补表单默认值,所以先 GET 解决表单 HTML 读出 `resolvedDate` 与 `assignedTo` 的选中项再原样提交;
|
||||
解决日期不能用本机时间:本机可能在美西时区,禅道服务器是北京时间(#621 曾因此写成本机时间)
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 认证:`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>`。
|
||||
- 接口:`/my-work-bug-<type>-0-id_desc-0-<perPage>-<page>.json`、`/bug-view-<id>.json`、`/file-read-<fileID>.<ext>`;
|
||||
解决:GET `/bug-resolve-<id>.json` 取版本/用户选项,POST `/bug-resolve-<id>.html?onlybody=yes` 表单
|
||||
(resolution/resolvedBuild/resolvedDate/assignedTo/status=resolved/comment)。
|
||||
- REST v1 的 `/products/<id>/bugs` 只返回激活中的 Bug,不能替代"我的 Bug"页面。
|
||||
- 凭据:Token 在 `~/.config/zentao-lite/config.json`(0600),密码只在钥匙串(服务名 `zentao-lite`)。`logout` 一并清除。
|
||||
|
||||
@@ -173,6 +173,27 @@ class Client:
|
||||
self._relogin()
|
||||
raise AuthError("无法获取数据")
|
||||
|
||||
def post_form(self, path, fields):
|
||||
"""提交页面表单(与网页点击「保存」相同);返回响应 HTML(禅道用 <script>alert/parent 跳转回执)。"""
|
||||
body = urllib.parse.urlencode(fields).encode()
|
||||
for _ in range(2):
|
||||
req = urllib.request.Request(self.url + path, data=body, headers={
|
||||
"Cookie": f"zentaosid={self.cfg['token']}", "Token": self.cfg["token"],
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Referer": self.url + path,
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
text = resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 401:
|
||||
raise
|
||||
text = "user-login"
|
||||
if "user-login" not in text:
|
||||
return text
|
||||
self._relogin()
|
||||
raise AuthError("无法提交表单")
|
||||
|
||||
def download(self, path, dest):
|
||||
for _ in range(2):
|
||||
try:
|
||||
@@ -490,6 +511,64 @@ def cmd_view(args):
|
||||
print(f"(已保存到 {out_dir / 'bug.md'},图片 {len(fetcher.files)} 张)")
|
||||
|
||||
|
||||
def form_defaults(client, bug_id):
|
||||
"""读取网页「解决」表单渲染出的默认值:解决日期(服务器时间,北京时间)与指派给(禅道默认选中创建人)。
|
||||
接口提交不会自动补默认值,必须先取表单再原样带上;不能用本机时间,本机可能在其他时区。"""
|
||||
with client._open(f"/bug-resolve-{bug_id}.html?onlybody=yes") as resp:
|
||||
page = resp.read().decode("utf-8", "replace")
|
||||
date = re.search(r"name='resolvedDate'[^>]*value='([^']+)'", page)
|
||||
select = re.search(r"<select name='assignedTo'.*?</select>", page, re.S)
|
||||
assigned = select and re.search(r"<option value='([^']*)' selected", select.group(0))
|
||||
if not date or not assigned or not assigned.group(1):
|
||||
die(f"#{bug_id} 未能从解决表单读取默认的解决日期 / 指派给,可用 --date / --assign-to 指定")
|
||||
return date.group(1), assigned.group(1)
|
||||
|
||||
|
||||
def cmd_resolve(args):
|
||||
"""解决 Bug:等同网页「解决」表单。默认解决方案=已解决、解决版本=主干(trunk)、
|
||||
解决日期与指派给取网页表单默认值(服务器时间 / 创建人);只处理激活状态的单子,提交后回读校验。"""
|
||||
client = Client(load_config())
|
||||
failed = 0
|
||||
for bug_id in args.ids:
|
||||
form = client.page(f"/bug-resolve-{bug_id}.json")
|
||||
bug = (form or {}).get("bug")
|
||||
if not bug:
|
||||
print(f"#{bug_id} 跳过:不存在或无权查看")
|
||||
failed += 1
|
||||
continue
|
||||
title = html.unescape(bug["title"])
|
||||
if bug["status"] != "active":
|
||||
print(f"#{bug_id} 跳过:当前状态为「{STATUS.get(bug['status'], bug['status'])}」,只解决激活的 Bug({title})")
|
||||
continue
|
||||
builds = form.get("builds") or {}
|
||||
if args.build not in builds:
|
||||
die(f"解决版本 {args.build} 不在可选列表中:{', '.join(k for k in builds if k)}")
|
||||
default_date, default_assigned = form_defaults(client, bug_id)
|
||||
assigned = args.assign_to or default_assigned
|
||||
fields = {
|
||||
"resolution": args.resolution, "duplicateBug": "", "buildExecution": "",
|
||||
"resolvedBuild": args.build, "buildName": "",
|
||||
"resolvedDate": args.date or default_date,
|
||||
"assignedTo": assigned, "status": "resolved", "comment": args.comment or "",
|
||||
}
|
||||
desc = (f"#{bug_id} {title} → {RESOLUTIONS.get(args.resolution, args.resolution)},"
|
||||
f"版本 {builds[args.build]},指派给 {(form.get('users') or {}).get(assigned, assigned)}")
|
||||
if args.dry_run:
|
||||
print(f"[dry-run] {desc}")
|
||||
continue
|
||||
resp = client.post_form(f"/bug-resolve-{bug_id}.html?onlybody=yes", fields)
|
||||
after = client.page(f"/bug-view-{bug_id}.json").get("bug") or {}
|
||||
if after.get("status") == "resolved":
|
||||
print(f"已解决 {desc}")
|
||||
else:
|
||||
alerts = re.findall(r"alert\((['\"])(.*?)\1\)", resp)
|
||||
print(f"#{bug_id} 失败:提交后状态仍为 {after.get('status')}"
|
||||
+ (f",禅道提示:{'; '.join(html.unescape(a[1]) for a in alerts)}" if alerts else ""))
|
||||
failed += 1
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="禅道 18.x Bug 读取工具")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -515,6 +594,16 @@ def main():
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_view)
|
||||
|
||||
p = sub.add_parser("resolve", help="解决 Bug(写操作,会改动禅道线上状态)")
|
||||
p.add_argument("ids", type=int, nargs="+")
|
||||
p.add_argument("--resolution", choices=tuple(RESOLUTIONS), default="fixed", help="默认 fixed(已解决)")
|
||||
p.add_argument("--build", default="trunk", help="解决版本,默认 trunk(主干)")
|
||||
p.add_argument("--assign-to", help="指派给(账号),默认取表单默认值(即创建人)")
|
||||
p.add_argument("--date", help="解决日期 YYYY-MM-DD HH:MM:SS,默认取服务器当前时间")
|
||||
p.add_argument("--comment", default="", help="备注,默认留空")
|
||||
p.add_argument("--dry-run", action="store_true", help="只打印将要做的修改,不提交")
|
||||
p.set_defaults(func=cmd_resolve)
|
||||
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
args.func(args)
|
||||
|
||||
Reference in New Issue
Block a user