Files
TFSImport/tfs_import.py
T

468 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())