468 lines
19 KiB
Python
468 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TFS 2013 工作项批量导入工具
|
||
|
||
目标环境(来自截图):
|
||
服务器 : http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection
|
||
团队项目 : Tools_Dev
|
||
团队 : DataTechnologies
|
||
迭代路径 : 2026年Sprint\\2026年Sprint10 (URL: .../_backlogs/Iteration/2026%E5%B9%B4Sprint/2026%E5%B9%B4Sprint10)
|
||
|
||
数据源: data/work_items.csv(Title/State/AssignedTo/FinancialProject/Completed/LogDate/CreatedDate/ClosedDate)
|
||
字段映射: config.json -> field_refs;留空的自定义字段按显示名自动解析。
|
||
|
||
用法:
|
||
python tfs_import.py --dry-run # 只校验数据与字段,不写入
|
||
python tfs_import.py # 正式导入
|
||
python tfs_import.py --link-existing # 已导入的项补挂父链接(不新建)
|
||
python tfs_import.py --input other.csv # 指定数据文件
|
||
python tfs_import.py --skip-existing-titles # 跳过已存在同标题工作项
|
||
python tfs_import.py --list-fields # 列出服务器全部字段(显示名/引用名)
|
||
|
||
认证(config.json auth.mode):
|
||
ntlm : 域\\用户 + 密码(TFS 2013 典型配置)
|
||
basic : 服务器开了基本身份验证时使用
|
||
默认凭据: username 留空则尝试当前 Windows 凭据(仅 basic/ntlm 均需显式账号,TFS 2013 一般用 ntlm)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
print("缺少依赖 requests,请先: pip install -r requirements.txt", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
API_VER = "1.0"
|
||
DATE_FMT_IN = ["%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M"]
|
||
|
||
|
||
# ---------------------------------------------------------------- 配置与数据
|
||
|
||
def load_config(path: Path) -> dict:
|
||
with path.open("r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_rows(path: Path) -> list[dict]:
|
||
# 依次尝试 utf-8-sig / gbk,兼容新旧 CSV 编码
|
||
rows: list[dict] | None = None
|
||
last_err: Exception | None = None
|
||
for enc in ("utf-8-sig", "gbk"):
|
||
try:
|
||
with path.open("r", encoding=enc, newline="") as f:
|
||
reader = csv.DictReader(f)
|
||
rows = [r for r in reader if (r.get("Title") or "").strip()]
|
||
break
|
||
except UnicodeDecodeError as e:
|
||
last_err = e
|
||
if rows is None:
|
||
raise SystemExit(f"无法读取 CSV {path}(utf-8/gbk 均失败): {last_err}")
|
||
return rows
|
||
|
||
|
||
def parse_dt(value: str) -> str | None:
|
||
"""把 CSV 里的时间统一成 TFS 接受的 ISO 格式;空值返回 None。"""
|
||
value = (value or "").strip()
|
||
if not value:
|
||
return None
|
||
for fmt in DATE_FMT_IN:
|
||
try:
|
||
return datetime.strptime(value, fmt).strftime("%Y-%m-%dT%H:%M:%S")
|
||
except ValueError:
|
||
continue
|
||
raise ValueError(f"无法解析时间: {value!r}")
|
||
|
||
|
||
# ---------------------------------------------------------------- HTTP 会话
|
||
|
||
def build_session(cfg: dict) -> requests.Session:
|
||
auth = cfg.get("auth", {})
|
||
mode = (auth.get("mode") or "ntlm").lower()
|
||
sess = requests.Session()
|
||
sess.verify = False
|
||
sess.headers.update({"Accept": "application/json"})
|
||
|
||
user = auth.get("username") or ""
|
||
pwd = auth.get("password") or ""
|
||
domain = auth.get("domain") or ""
|
||
|
||
if mode == "ntlm":
|
||
from requests_ntlm import HttpNtlmAuth
|
||
if not user or not pwd:
|
||
raise SystemExit("ntlm 模式需要 config.json 里配置 auth.username / auth.password")
|
||
login = f"{domain}\\{user}" if domain else user
|
||
sess.auth = HttpNtlmAuth(login, pwd)
|
||
elif mode == "basic":
|
||
if not user or not pwd:
|
||
raise SystemExit("basic 模式需要 config.json 里配置 auth.username / auth.password")
|
||
sess.auth = (f"{domain}\\{user}" if domain else user, pwd)
|
||
else:
|
||
raise SystemExit(f"未知 auth.mode: {mode}")
|
||
return sess
|
||
|
||
|
||
def tfs_send(sess: requests.Session, method: str, url: str, *, retries: int = 3,
|
||
**kwargs) -> requests.Response:
|
||
"""带退避重试的请求:502/503/504 网关错误或网络异常时最多重试 retries 次。"""
|
||
kwargs.setdefault("timeout", 60)
|
||
resp: requests.Response | None = None
|
||
for attempt in range(1, retries + 1):
|
||
try:
|
||
resp = sess.request(method, url, **kwargs)
|
||
except requests.RequestException as e:
|
||
if attempt == retries:
|
||
raise
|
||
wait = 3 * attempt
|
||
print(f"[重试] {method.upper()} {url} 网络异常: {e};{wait}s 后第 {attempt + 1} 次尝试")
|
||
time.sleep(wait)
|
||
continue
|
||
if resp.status_code in (502, 503, 504) and attempt < retries:
|
||
wait = 3 * attempt
|
||
print(f"[重试] {method.upper()} {url} -> {resp.status_code};{wait}s 后第 {attempt + 1} 次尝试")
|
||
time.sleep(wait)
|
||
continue
|
||
return resp
|
||
raise RuntimeError(f"请求重试 {retries} 次后仍失败: {method.upper()} {url}")
|
||
|
||
|
||
def tfs_get(sess: requests.Session, url: str, params: dict | None = None) -> dict:
|
||
resp = tfs_send(sess, "get", url, params=params)
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(f"GET {url} -> {resp.status_code}: {resp.text[:500]}")
|
||
return resp.json()
|
||
|
||
|
||
def tfs_wiql(sess: requests.Session, url: str, wiql: str) -> dict:
|
||
resp = tfs_send(sess, "post", url, params={"api-version": API_VER}, json={"query": wiql})
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(f"WIQL 查询失败 {resp.status_code}: {resp.text[:500]}")
|
||
return resp.json()
|
||
|
||
|
||
# ---------------------------------------------------------------- 字段解析
|
||
|
||
def resolve_fields(sess: requests.Session, base: str, cfg: dict) -> dict:
|
||
"""返回 {语义名: 引用名}。优先用 config.field_refs 显式值,其次按显示名查服务器字段表。"""
|
||
refs = dict(cfg.get("field_refs") or {})
|
||
by_display = cfg.get("resolve_fields_by_display_name") or {}
|
||
need_lookup = [k for k, v in by_display.items() if not refs.get(k)]
|
||
if not need_lookup:
|
||
return refs
|
||
|
||
data = tfs_get(sess, f"{base}/_apis/wit/fields", params={"api-version": API_VER})
|
||
catalog = data.get("value", [])
|
||
lower_map = {}
|
||
for f in catalog:
|
||
lower_map.setdefault(f.get("name", "").strip().lower(), f.get("referenceName"))
|
||
|
||
for key in need_lookup:
|
||
display = by_display.get(key) or key
|
||
ref = lower_map.get(display.strip().lower())
|
||
if not ref:
|
||
# 宽松匹配: 显示名以配置词开头(如 Completed -> Completed Work)
|
||
for f in catalog:
|
||
name = f.get("name", "").strip().lower()
|
||
if name.startswith(display.strip().lower()):
|
||
ref = f.get("referenceName")
|
||
print(f"[字段] {key}: {display!r} 模糊匹配到 {f.get('name')!r} -> {ref}")
|
||
break
|
||
if not ref:
|
||
avail = ", ".join(sorted({f.get("name", "") for f in catalog if f.get("name")}))[:800]
|
||
raise SystemExit(
|
||
f"字段 {key} 的显示名 {display!r} 在服务器上找不到,请在 config.json 的 "
|
||
f"field_refs 里显式配置引用名。\n服务器字段(截断): {avail}"
|
||
)
|
||
refs[key] = ref
|
||
print(f"[字段] {key}: {display!r} -> {ref}")
|
||
return refs
|
||
|
||
|
||
# ---------------------------------------------------------------- 重复检测
|
||
|
||
def existing_titles(sess: requests.Session, base: str, project: str, iteration: str,
|
||
type_name: str) -> set[str]:
|
||
wiql = (
|
||
"SELECT [System.Title] FROM WorkItems "
|
||
f"WHERE [System.TeamProject] = '{project}' "
|
||
f"AND [System.WorkItemType] = '{type_name}' "
|
||
f"AND [System.IterationPath] = '{iteration}'"
|
||
)
|
||
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
|
||
ids = [w["id"] for w in data.get("workItems", [])]
|
||
titles: set[str] = set()
|
||
for i in range(0, len(ids), 50):
|
||
chunk = ids[i:i + 50]
|
||
data = tfs_get(
|
||
sess,
|
||
f"{base}/_apis/wit/workitems",
|
||
params={"ids": ",".join(map(str, chunk)), "fields": "System.Title", "api-version": API_VER},
|
||
)
|
||
for item in data.get("value", []):
|
||
titles.add(item.get("fields", {}).get("System.Title", ""))
|
||
return titles
|
||
|
||
|
||
# ---------------------------------------------------------------- 构造与提交
|
||
|
||
def build_patch(row: dict, refs: dict, cfg: dict, iteration_full: str, area: str) -> list[dict]:
|
||
ops: list[dict] = []
|
||
|
||
def add(ref: str | None, value):
|
||
if not ref or value is None or value == "":
|
||
return
|
||
ops.append({"op": "add", "path": f"/fields/{ref}", "value": value})
|
||
|
||
add(refs["title"], row["Title"].strip())
|
||
add(refs["state"], (row.get("State") or cfg.get("default_state") or "To Do").strip())
|
||
add(refs["assigned_to"], (row.get("AssignedTo") or "").strip())
|
||
add(refs["iteration_path"], iteration_full)
|
||
if area:
|
||
add(refs["area_path"], area)
|
||
|
||
add(refs.get("financial_project"), (row.get("FinancialProject") or "").strip())
|
||
|
||
completed = (row.get("Completed") or "").strip()
|
||
if completed:
|
||
add(refs.get("completed"), float(completed) if "." in completed else int(completed))
|
||
|
||
log_date = parse_dt(row.get("LogDate"))
|
||
if log_date:
|
||
add(refs.get("log_date"), log_date)
|
||
|
||
# CreatedDate / ClosedDate 是系统字段,常规接口不可写;bypassRules=true 时部分模板可写。
|
||
created = parse_dt(row.get("CreatedDate"))
|
||
if created:
|
||
add(refs.get("created_date"), created)
|
||
closed = parse_dt(row.get("ClosedDate"))
|
||
if closed:
|
||
add(refs.get("closed_date"), closed)
|
||
|
||
return ops
|
||
|
||
|
||
def create_work_item(sess: requests.Session, base: str, project: str, type_name: str,
|
||
ops: list[dict], bypass_rules: bool) -> dict:
|
||
url = f"{base}/{project}/_apis/wit/workitems/${requests.utils.quote(type_name)}"
|
||
params = {"api-version": API_VER}
|
||
if bypass_rules:
|
||
params["bypassRules"] = "true"
|
||
headers = {"Content-Type": "application/json-patch+json"}
|
||
resp = tfs_send(sess, "patch", url, params=params, headers=headers,
|
||
data=json.dumps(ops, ensure_ascii=False))
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(f"PATCH {url} -> {resp.status_code}: {resp.text[:800]}")
|
||
return resp.json()
|
||
|
||
|
||
# ---------------------------------------------------------------- 父项挂接
|
||
|
||
def query_work_item_ids(sess: requests.Session, base: str, project: str,
|
||
type_name: str, iteration: str) -> list[int]:
|
||
wiql = (
|
||
"SELECT [System.Id] FROM WorkItems "
|
||
f"WHERE [System.TeamProject] = '{project}' "
|
||
f"AND [System.WorkItemType] = '{type_name}' "
|
||
f"AND [System.IterationPath] = '{iteration}'"
|
||
)
|
||
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
|
||
return [w["id"] for w in data.get("workItems", [])]
|
||
|
||
|
||
def find_parent_id(sess: requests.Session, base: str, project: str, cfg: dict) -> int | None:
|
||
"""解析父工作项:优先 config.parent_id,其次按 parent_title 全项目精确匹配标题。"""
|
||
pid = str(cfg.get("parent_id") or "").strip()
|
||
if pid:
|
||
return int(pid)
|
||
title = (cfg.get("parent_title") or "").strip()
|
||
if not title:
|
||
return None
|
||
safe_title = title.replace("'", "''")
|
||
wiql = (
|
||
"SELECT [System.Id] FROM WorkItems "
|
||
f"WHERE [System.TeamProject] = '{project}' "
|
||
f"AND [System.Title] = '{safe_title}' "
|
||
"AND [System.WorkItemType] <> 'Task'"
|
||
)
|
||
data = tfs_wiql(sess, f"{base}/{project}/_apis/wit/wiql", wiql)
|
||
items = data.get("workItems", [])
|
||
if not items:
|
||
raise SystemExit(
|
||
f"按标题找不到父工作项: {title!r}\n"
|
||
f"请把 config.json 的 parent_title 改成积压工作里已有的条目标题,"
|
||
f"或直接填 parent_id。"
|
||
)
|
||
if len(items) > 1:
|
||
print(f"[父项] 警告: 标题 {title!r} 匹配到 {len(items)} 条,取最新创建的一条")
|
||
# 取 CreatedDate 最大(最新)的一条
|
||
return max(int(w["id"]) for w in items)
|
||
|
||
|
||
def task_has_parent(item: dict) -> bool:
|
||
for rel in item.get("relations") or []:
|
||
if rel.get("rel") == "System.LinkTypes.Hierarchy-Reverse":
|
||
return True
|
||
return False
|
||
|
||
|
||
def link_parent(sess: requests.Session, base: str, task_id: int, parent_id: int) -> None:
|
||
ops = [{
|
||
"op": "add",
|
||
"path": "/relations/-",
|
||
"value": {
|
||
"rel": "System.LinkTypes.Hierarchy-Reverse",
|
||
"url": f"{base}/_apis/wit/workitems/{parent_id}",
|
||
},
|
||
}]
|
||
url = f"{base}/_apis/wit/workitems/{task_id}"
|
||
resp = tfs_send(
|
||
sess, "patch", url,
|
||
params={"api-version": API_VER},
|
||
headers={"Content-Type": "application/json-patch+json"},
|
||
data=json.dumps(ops),
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(f"挂接 #{task_id} -> #{parent_id} 失败 {resp.status_code}: {resp.text[:500]}")
|
||
|
||
|
||
def link_existing_tasks(sess: requests.Session, base: str, project: str, cfg: dict,
|
||
iteration_full: str, parent_id: int) -> int:
|
||
"""把已导入的同类型/同迭代 Task 补挂到父项下;已有父的跳过。"""
|
||
type_name = cfg["work_item_type"]
|
||
ids = query_work_item_ids(sess, base, project, type_name, iteration_full)
|
||
print(f"[补挂] 找到 {type_name} {len(ids)} 条,父项 #{parent_id}")
|
||
linked = skipped = failed = 0
|
||
for tid in ids:
|
||
try:
|
||
data = tfs_get(sess, f"{base}/_apis/wit/workitems/{tid}", params={"api-version": API_VER})
|
||
if task_has_parent(data):
|
||
skipped += 1
|
||
continue
|
||
link_parent(sess, base, tid, parent_id)
|
||
linked += 1
|
||
print(f"[补挂] OK #{tid} -> #{parent_id}")
|
||
except Exception as e:
|
||
failed += 1
|
||
print(f"[补挂] FAIL #{tid} -> {e}")
|
||
print(f"[补挂] 汇总: 成功 {linked} / 已有父跳过 {skipped} / 失败 {failed}")
|
||
return 0 if not failed else 1
|
||
|
||
|
||
# ---------------------------------------------------------------- 主流程
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="TFS 2013 工作项批量导入")
|
||
ap.add_argument("--config", default=str(BASE_DIR / "config.json"))
|
||
ap.add_argument("--input", default=str(BASE_DIR / "data" / "work_items.csv"))
|
||
ap.add_argument("--dry-run", action="store_true", help="只校验不写入")
|
||
ap.add_argument("--skip-existing-titles", action="store_true", help="跳过服务器已存在的同标题项")
|
||
ap.add_argument("--list-fields", action="store_true", help="列出服务器字段表后退出")
|
||
ap.add_argument("--link-existing", action="store_true",
|
||
help="把已导入的同类型/同迭代工作项补挂到父项下(不新建)")
|
||
ap.add_argument("--limit", type=int, default=0, help="只处理前 N 条(调试用)")
|
||
args = ap.parse_args()
|
||
|
||
cfg = load_config(Path(args.config))
|
||
base = cfg["tfs_url"].rstrip("/")
|
||
project = cfg["project"]
|
||
type_name = cfg["work_item_type"]
|
||
iteration_cfg = cfg.get("iteration_path") or ""
|
||
iteration_full = f"{project}\\{iteration_cfg}" if iteration_cfg and not iteration_cfg.startswith(project) else iteration_cfg
|
||
area = cfg.get("area_path") or ""
|
||
bypass_rules = bool(cfg.get("bypass_rules", True))
|
||
|
||
# --link-existing 不读数据文件,直接进补挂流程
|
||
if args.link_existing:
|
||
sess = build_session(cfg)
|
||
parent_id = find_parent_id(sess, base, project, cfg)
|
||
if not parent_id:
|
||
raise SystemExit("--link-existing 需要配置 parent_title 或 parent_id")
|
||
print(f"[父项] #{parent_id}")
|
||
print(f"[迭代] {iteration_full}")
|
||
return link_existing_tasks(sess, base, project, cfg, iteration_full, parent_id)
|
||
|
||
rows = load_rows(Path(args.input))
|
||
if args.limit:
|
||
rows = rows[: args.limit]
|
||
print(f"[数据] 共 {len(rows)} 行待导入: {args.input}")
|
||
|
||
sess = build_session(cfg)
|
||
|
||
if args.list_fields:
|
||
data = tfs_get(sess, f"{base}/_apis/wit/fields", params={"api-version": API_VER})
|
||
for f in sorted(data.get("value", []), key=lambda x: x.get("name", "")):
|
||
print(f"{f.get('referenceName', ''):<55} {f.get('name', '')}")
|
||
return 0
|
||
|
||
refs = resolve_fields(sess, base, cfg)
|
||
print(f"[迭代] {iteration_full}")
|
||
|
||
parent_id = find_parent_id(sess, base, project, cfg)
|
||
if parent_id:
|
||
print(f"[父项] #{parent_id}")
|
||
else:
|
||
print("[父项] 未配置 parent_title/parent_id,导入的项不挂父链接")
|
||
|
||
known: set[str] = set()
|
||
if args.skip_existing_titles or args.dry_run:
|
||
try:
|
||
known = existing_titles(sess, base, project, iteration_full, type_name)
|
||
print(f"[去重] 服务器同类型/同迭代已有 {len(known)} 条")
|
||
except Exception as e:
|
||
print(f"[去重] 查询失败(继续): {e}")
|
||
|
||
ok = fail = skipped = 0
|
||
report_path = BASE_DIR / "data" / f"import_report_{time.strftime('%Y%m%d_%H%M%S')}.csv"
|
||
with report_path.open("w", encoding="utf-8-sig", newline="") as rf:
|
||
writer = csv.writer(rf)
|
||
writer.writerow(["行号", "Title", "结果", "WorkItemId", "错误"])
|
||
|
||
for idx, row in enumerate(rows, 1):
|
||
title = row["Title"].strip()
|
||
try:
|
||
if args.skip_existing_titles and title in known:
|
||
skipped += 1
|
||
writer.writerow([idx, title, "SKIP", "", "标题已存在"])
|
||
print(f"[{idx}/{len(rows)}] SKIP {title}")
|
||
continue
|
||
|
||
ops = build_patch(row, refs, cfg, iteration_full, area)
|
||
|
||
if args.dry_run:
|
||
ok += 1
|
||
writer.writerow([idx, title, "DRY", "", json.dumps(ops, ensure_ascii=False)[:200]])
|
||
print(f"[{idx}/{len(rows)}] DRY {title} ({len(ops)} ops)")
|
||
continue
|
||
|
||
result = create_work_item(sess, base, project, type_name, ops, bypass_rules)
|
||
wid = result.get("id")
|
||
if parent_id and wid:
|
||
try:
|
||
link_parent(sess, base, int(wid), parent_id)
|
||
print(f"[挂接] #{wid} -> 父项 #{parent_id}")
|
||
except Exception as e:
|
||
print(f"[挂接] FAIL #{wid} -> {e}")
|
||
ok += 1
|
||
writer.writerow([idx, title, "OK", wid, ""])
|
||
print(f"[{idx}/{len(rows)}] OK #{wid} {title}")
|
||
except Exception as e:
|
||
fail += 1
|
||
writer.writerow([idx, title, "FAIL", "", str(e)[:500]])
|
||
print(f"[{idx}/{len(rows)}] FAIL {title} -> {e}")
|
||
|
||
print(f"\n[汇总] 成功 {ok} / 跳过 {skipped} / 失败 {fail};报告: {report_path}")
|
||
return 1 if fail else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|