初始提交:QuickTeam 工单接口导入工具

用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单:
单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。

- 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/
  workflow(阶段编排)/ store(SQLite 台账)/ report / CLI
- 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品
- 命令:plan / apply / report / status,真实写入需 --yes
- tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断
- docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码

实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS,
工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
This commit is contained in:
chenshoulin
2026-11-05 19:14:25 +08:00
commit 58850314cf
32 changed files with 4590 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
"""配置加载。
凭据解析优先级:环境变量 QT_PASSWORD > Windows DPAPI 文件 > 配置文件明文 > 交互输入。
配置文件默认 config/config.json(已在 .gitignore 中)。
"""
from __future__ import annotations
import getpass
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_CONFIG = "config/config.json"
@dataclass
class Config:
# 连接
base_url: str
username: str
password: str = field(default="", repr=False)
timeout: int = 60
# 业务
team_name: str = "当前项目"
work_dir: str = ""
sprint: str = ""
start_time: str = ""
end_time: str = ""
content_desc: str = "需求开发测试"
service_type: str = "DevelopService"
belong_team: str = ""
# 三个责任人;留空则用登录用户的真实姓名
evaluator: str = ""
technical_leader: str = ""
customer_leader: str = ""
# TFS
tfs_project: str = "Tools_Dev"
tfs_team: str = "DataTechnologies"
area_path: str = ""
iteration_path: str = ""
# 运行
workers: int = 2
db_path: str = "state/qtimport.db"
def resolved_roles(self, operator: str) -> tuple[str, str, str]:
"""三个责任人,缺省都落到操作人(与现有脚本一致)。"""
return (
self.evaluator or operator,
self.technical_leader or operator,
self.customer_leader or operator,
)
@classmethod
def load(cls, path: str | os.PathLike = DEFAULT_CONFIG, *, need_password: bool = True) -> "Config":
file_path = Path(path)
if not file_path.exists():
raise SystemExit(f"缺少配置文件:{file_path}(可从 config/config.example.json 复制)")
with open(file_path, encoding="utf-8-sig") as handle:
raw = json.load(handle)
allowed = {f for f in cls.__dataclass_fields__}
unknown = set(raw) - allowed
if unknown:
raise SystemExit(f"配置文件含未知字段:{sorted(unknown)}")
cfg = cls(**raw)
cfg.password = resolve_password(cfg.password, need=need_password)
if not cfg.base_url or not cfg.username:
raise SystemExit("配置缺少 base_url 或 username")
if need_password and not cfg.password:
raise SystemExit("未提供密码:设置 QT_PASSWORD 环境变量或在配置中填写")
return cfg
def resolve_password(configured: str, *, need: bool = True) -> str:
env = os.environ.get("QT_PASSWORD")
if env:
return env
if configured and configured != "REPLACE_ME":
return configured
if not need:
return ""
if not os.isatty(0): # 非交互(计划任务/CI)
return ""
return getpass.getpass("QuickTeam 密码:")
def load_raw(path: str | os.PathLike = DEFAULT_CONFIG) -> dict:
"""读原始 JSON(plan 阶段不需要密码时用)。"""
with open(Path(path), encoding="utf-8-sig") as handle:
return json.load(handle)