From 8d1af0d11cf3e449140d65ada876dcfc9d74027e Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 1 Nov 2026 11:21:20 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20TFS=2020?= =?UTF-8?q?13=20=E5=B7=A5=E4=BD=9C=E9=A1=B9=E6=89=B9=E9=87=8F=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TfsExcelImporter: WinForms 桌面版,走 TFS Client OM(本环境 wit REST 路由 404,OM 是唯一可行通道) - tfs_import.py: REST 版命令行导入器,供 wit 接口可用的 TFS 实例使用 - templates: Excel 导入模板及其 OOXML 源部件 - README: 两套工具定位、配置、环境坑点与验证方式 - 真实台账与本机工作笔记不入库,仅提交 work_items.example.csv 说明列结构 --- .gitattributes | 6 + .gitignore | 25 + README.md | 132 ++++ TfsExcelImporter.sln | 16 + TfsExcelImporter/Config.cs | 79 +++ TfsExcelImporter/ExcelService.cs | 226 +++++++ TfsExcelImporter/MainForm.cs | 677 ++++++++++++++++++++ TfsExcelImporter/Program.cs | 16 + TfsExcelImporter/TfsClient.cs | 511 +++++++++++++++ TfsExcelImporter/TfsExcelImporter.csproj | 31 + TfsExcelImporter/config.json | 20 + config.json | 36 ++ data/work_items.example.csv | 3 + requirements.txt | 2 + templates/TFS导入模板.xlsx | Bin 0 -> 4865 bytes templates/_parts/[Content_Types].xml | 8 + templates/_parts/_rels/.rels | 4 + templates/_parts/xl/_rels/workbook.xml.rels | 5 + templates/_parts/xl/styles.xml | 8 + templates/_parts/xl/workbook.xml | 6 + templates/_parts/xl/worksheets/sheet1.xml | 41 ++ templates/build_template.ps1 | 104 +++ tfs_import.py | 467 ++++++++++++++ 23 files changed, 2423 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TfsExcelImporter.sln create mode 100644 TfsExcelImporter/Config.cs create mode 100644 TfsExcelImporter/ExcelService.cs create mode 100644 TfsExcelImporter/MainForm.cs create mode 100644 TfsExcelImporter/Program.cs create mode 100644 TfsExcelImporter/TfsClient.cs create mode 100644 TfsExcelImporter/TfsExcelImporter.csproj create mode 100644 TfsExcelImporter/config.json create mode 100644 config.json create mode 100644 data/work_items.example.csv create mode 100644 requirements.txt create mode 100644 templates/TFS导入模板.xlsx create mode 100644 templates/_parts/[Content_Types].xml create mode 100644 templates/_parts/_rels/.rels create mode 100644 templates/_parts/xl/_rels/workbook.xml.rels create mode 100644 templates/_parts/xl/styles.xml create mode 100644 templates/_parts/xl/workbook.xml create mode 100644 templates/_parts/xl/worksheets/sheet1.xml create mode 100644 templates/build_template.ps1 create mode 100644 tfs_import.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..aa34aeb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto + +# xlsx 及其 OOXML 源部件:按字节原样入库,避免 CRLF 转换污染模板 +*.xlsx binary +templates/_parts/** text eol=lf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57dcc3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# .NET 构建产物 +bin/ +obj/ +*.user +*.suo + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# 导入报告 +data/import_report_*.csv + +# 真实台账(含人员姓名/项目/工时)与本机工作笔记不入库 +data/work_items.csv +.workbuddy/ + +# 编辑器与系统 +.idea/ +.vs/ +Thumbs.db +Desktop.ini +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba056ad --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# TFSImport + +TFS 2013 工作项批量导入工具集,用于把 Excel/CSV 里的工时台账(标题、状态、指派人、完成量、日期等)批量写成团队项目里的工作项,并挂到指定的父项(Product Backlog Item / 占位 Backlog Item)下,使其在「积压工作」页和任务板可见。 + +目标环境: + +| 项 | 值 | +| --- | --- | +| 服务器 | `http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection` | +| 团队项目 | `Tools_Dev` | +| 团队 | `DataTechnologies` | +| 迭代 | `2026年Sprint\2026年Sprint10` | +| 认证 | 域账号 NTLM(内网直连,不走代理) | + +## 两套工具,先用哪个 + +| | [TfsExcelImporter](TfsExcelImporter)(C# WinForms) | [tfs_import.py](tfs_import.py)(Python CLI) | +| --- | --- | --- | +| 通信协议 | TFS Client OM(VS/Team Explorer 同款内部 SOAP) | REST `/_apis/wit/*` | +| 适用 | **本环境可用**。该 TFS 2013 实例只有 `/_apis/projects` 通,`wit/fields`、`wit/classificationnodes`、`wit/wiql` 全部 404 | 服务器开放了 wit REST 路由时使用 | +| 数据源 | `.xlsx`(内置模板) | `data/work_items.csv`(GBK/UTF-8 均可,需自备) | +| 界面 | 图形界面,预览后导入 | 命令行,`--dry-run` 先校验 | + +Python 版在本环境会因 REST 路由 404 而失败,保留用于其它 TFS 实例或接口可用时的批量脚本化导入。 + +## 目录结构 + +``` +TFSImport/ +├── tfs_import.py # Python 命令行导入器(REST) +├── config.json # Python 版配置 +├── requirements.txt # requests / requests-ntlm +├── data/ +│ └── work_items.example.csv # 列结构与示例行(UTF-8 BOM);真实台账不入库 +├── templates/ +│ ├── TFS导入模板.xlsx # Excel 模板成品 +│ ├── build_template.ps1 # 由 _parts 里的 XML 打包生成 xlsx +│ └── _parts/ # xlsx 的 OOXML 源文件 +└── TfsExcelImporter/ # C# WinForms 桌面版(主用) + ├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.* + ├── MainForm.cs # 界面与导入/修 Area 流程 + ├── TfsClient.cs # TFS Client OM 封装 + ├── ExcelService.cs # 读 Excel、生成模板、时间/数值归一 + ├── Config.cs # exe 同目录 config.json 读写 + └── config.json # 桌面版配置 +``` + +## 快速开始 + +### C# 桌面版(主用) + +```powershell +dotnet build TfsExcelImporter.sln -c Release +# 或用 Visual Studio 打开 TfsExcelImporter.sln(目标框架 net48,无需装 VS 也能 dotnet build) +TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe +``` + +操作流程: + +1. 填「TFS 连接」区:URL、项目、工作项类型、认证方式与域账号;内网保持「使用系统代理」**不勾选**。 +2. 点「加载迭代列表」,选择目标迭代。 +3. 点「生成Excel模板」拿到表头,或「浏览...」选择已有 `.xlsx`;点「预览数据」核对。 +4. 需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项)。 +5. 点「开始导入 TFS」,日志区逐行输出 `OK/SKIP/FAIL` 与工作项 ID。 +6. 历史数据 Area 不对时用「按迭代修Area」批量纠正。 + +### Python 版 + +```powershell +pip install -r requirements.txt +python tfs_import.py --dry-run # 只校验数据与字段映射,不写入 +python tfs_import.py --list-fields # 列出服务器全部字段(显示名/引用名) +python tfs_import.py # 正式导入 +python tfs_import.py --link-existing # 已导入的项补挂父链接(不新建) +python tfs_import.py --skip-existing-titles # 跳过服务器已有的同标题项 +python tfs_import.py --input other.csv --limit 5 +``` + +每次运行在 `data/import_report_<时间戳>.csv` 生成逐行结果报告(行号 / Title / 结果 / WorkItemId / 错误)。 + +## 配置说明 + +配置的语义、留空字段如何解析见 [tfs_import.py:49](tfs_import.py#L49) 与 [Config.cs](TfsExcelImporter/Config.cs),关键字段: + +| 键 | 说明 | +| --- | --- | +| `tfs_url` / `project` / `work_item_type` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) | +| `iteration_path` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 | +| `auth.mode` | `ntlm`(域账号,典型)/ `basic` / `default`(当前 Windows 凭据,仅桌面版) | +| `field_refs` | 语义名 → 字段引用名;自定义字段留空时按 `resolve_fields_by_display_name` 的显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) | +| `parent_title` / `parent_id` | 父工作项,二选一;按标题匹配不到会直接报错并提示改用 `parent_id` | +| `bypass_rules` | 是否尝试写入 `CreatedDate`/`ClosedDate` 等系统日期 | +| `skip_existing_titles` | 同项目/同类型/同迭代下按标题去重 | + +> 配置里含明文密码,请勿提交到共享仓库;`config.json` 保持空值,凭据本地填写。 + +## Excel / CSV 列 + +`Title`、`State`、`Assigned To`、`FinancialProject`、`Completed`、`LogDate`、`Created Date`、`Closed Date`、`PBI Id`、`Notes` + +真实台账含人员与项目信息,不入库;本地准备数据文件: + +```powershell +copy data\work_items.example.csv data\work_items.csv # Python 版默认读这个路径,已在 .gitignore 里 +``` + +- 表头按名匹配,顺序不限,`AssignedTo` / `Assigned To` 等价;缺列自动按模板顺序兜底。 +- 空 `Title` 的行直接跳过;`State` 为空时用 `default_state`;未识别的列(如 `Group`)忽略。 +- 日期支持 `yyyy-MM-dd[ / ][ HH:mm[:ss]]` 等格式,Excel 日期序列号(如 `46300`)会被还原成日期。 +- CSV 从 Excel 另存时注意编码,脚本按 `utf-8-sig` → `gbk` 依次尝试。 + +## 环境坑点 + +这些是实测结论,改代码前先看: + +- **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。 +- **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版默认把 `WebRequest.DefaultWebProxy` 置空直连;确实要走代理时勾「使用系统代理」并带上 Windows 凭据过代理认证。 +- **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。 +- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时尝试写入,校验或保存失败自动降级为普通保存,保证行不丢。 +- **孤立 Task 在积压工作页不可见**:TFS 2013 的 backlog/任务板按「父项 → 子任务」组织,没挂父链接的 Task 只能在查询里看到,所以导入必须带 `parent_title`/`parent_id`。 +- **DNS 解析失败 / 超时**:多为 VPN 或网络断开,不是代码问题;桌面版会对这类异常附加排查提示。 + +## 验证 + +改动 Python 版后至少跑: + +```powershell +python -m py_compile tfs_import.py +python tfs_import.py --dry-run +``` + +桌面版改完跑一次 `--limit` 级别的小批量导入(先删服务器已有同标题项或开启跳过),并在积压工作页展开父项确认子任务可见。 diff --git a/TfsExcelImporter.sln b/TfsExcelImporter.sln new file mode 100644 index 0000000..eeba466 --- /dev/null +++ b/TfsExcelImporter.sln @@ -0,0 +1,16 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TfsExcelImporter", "TfsExcelImporter\TfsExcelImporter.csproj", "{3B7A2C10-9D4E-4F6B-8A21-5C0D7E9F1A44}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3B7A2C10-9D4E-4F6B-8A21-5C0D7E9F1A44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B7A2C10-9D4E-4F6B-8A21-5C0D7E9F1A44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B7A2C10-9D4E-4F6B-8A21-5C0D7E9F1A44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B7A2C10-9D4E-4F6B-8A21-5C0D7E9F1A44}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/TfsExcelImporter/Config.cs b/TfsExcelImporter/Config.cs new file mode 100644 index 0000000..89f9eb9 --- /dev/null +++ b/TfsExcelImporter/Config.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Newtonsoft.Json; + +namespace TfsExcelImporter +{ + /// + /// 程序配置。保存于 exe 同目录 config.json,界面修改后自动写回。 + /// + public class AppConfig + { + public string TfsUrl { get; set; } = "http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection"; + public string Project { get; set; } = "Tools_Dev"; + public string WorkItemType { get; set; } = "Task"; + + // 目标迭代:完整迭代路径,如 Tools_Dev\2026年Sprint\2026年Sprint10 + public string IterationPath { get; set; } = ""; + // 可选:区域路径 + public string AreaPath { get; set; } = ""; + + // ntlm | basic | default(当前 Windows 凭据) + public string AuthMode { get; set; } = "ntlm"; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string Domain { get; set; } = ""; + + // 是否走 IE 系统代理。内网 TFS 请保持 false(直连); + // 代理/网关常对 HttpClient 请求返回 502(浏览器例外名单直连,.NET 不一定继承) + public bool UseSystemProxy { get; set; } = false; + + public bool BypassRules { get; set; } = true; + public bool SkipExistingTitles { get; set; } = true; + public string DefaultState { get; set; } = "To Do"; + + // 自定义字段引用名;留空则按 DisplayName 自动解析 + public string FinancialProjectRef { get; set; } = ""; + public string CompletedRef { get; set; } = ""; + public string LogDateRef { get; set; } = ""; + + public string LastExcelPath { get; set; } = ""; + + // 父 Product Backlog Item:所有 Task 默认挂接的 PBI Id(如 219975);Excel 每行 PBI Id 列可覆盖 + public string ParentPbiId { get; set; } = ""; + + private static string ConfigPath + { + get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"); } + } + + public static AppConfig Load() + { + try + { + if (File.Exists(ConfigPath)) + { + var cfg = JsonConvert.DeserializeObject(File.ReadAllText(ConfigPath)); + if (cfg != null) return cfg; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine("读取 config.json 失败: " + ex.Message); + } + return new AppConfig(); + } + + public void Save() + { + try + { + File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine("写入 config.json 失败: " + ex.Message); + } + } + } +} diff --git a/TfsExcelImporter/ExcelService.cs b/TfsExcelImporter/ExcelService.cs new file mode 100644 index 0000000..ce46e81 --- /dev/null +++ b/TfsExcelImporter/ExcelService.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using ClosedXML.Excel; + +namespace TfsExcelImporter +{ + /// 一行待导入的工作项数据(列顺序与 Excel 模板一致)。 + public class WorkItemRow + { + public string Title { get; set; } + public string State { get; set; } + public string AssignedTo { get; set; } + public string FinancialProject { get; set; } + public string Completed { get; set; } + public string LogDate { get; set; } + public string CreatedDate { get; set; } + public string ClosedDate { get; set; } + public string PbiId { get; set; } + public string Notes { get; set; } + } + + /// + /// Excel 读取与模板生成。 + /// 模板列: Title | State | Assigned To | FinancialProject | Completed | LogDate | Created Date | Closed Date | PBI Id | Notes + /// + public static class ExcelService + { + public static readonly string[] Headers = + { + "Title", "State", "Assigned To", "FinancialProject", + "Completed", "LogDate", "Created Date", "Closed Date", "PBI Id", "Notes" + }; + + private static readonly string[] DateFormats = + { + "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", + "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm", + "yyyy-MM-dd", "yyyy/MM/dd" + }; + + // --------------------------------------------------------- 生成模板 + + public static void GenerateTemplate(string path) + { + using (var wb = new XLWorkbook()) + { + var ws = wb.Worksheets.Add("工作项"); + + for (int i = 0; i < Headers.Length; i++) + { + var cell = ws.Cell(1, i + 1); + cell.Value = Headers[i]; + cell.Style.Font.Bold = true; + cell.Style.Fill.BackgroundColor = XLColor.LightGray; + } + + // 示例行(To Do / Done 各一,帮助理解格式) + var sample1 = new WorkItemRow + { + Title = "20261012 - 示例:货载报告解析并上传凭证受托报告相关内容", + State = "To Do", + AssignedTo = "李晓柯", + FinancialProject = "信托 - 上海信托", + Completed = "5", + LogDate = "2026-10-12", + CreatedDate = "2026-10-30 11:14:41", + ClosedDate = "", + Notes = "示例行,导入前请删除" + }; + var sample2 = new WorkItemRow + { + Title = "20261009 示例:受托报告bug修复", + State = "Done", + AssignedTo = "陈卫宇", + FinancialProject = "信托 - 上海信托", + Completed = "8", + LogDate = "2026-10-09", + CreatedDate = "2026-10-10 18:20:00", + ClosedDate = "2026-10-30 18:00:00", + Notes = "示例行,导入前请删除" + }; + WriteRow(ws, 2, sample1); + WriteRow(ws, 3, sample2); + + ws.Columns().AdjustToContents(); + ws.SheetView.FreezeRows(1); + wb.SaveAs(path); + } + } + + private static void WriteRow(IXLWorksheet ws, int r, WorkItemRow row) + { + ws.Cell(r, 1).Value = row.Title ?? ""; + ws.Cell(r, 2).Value = row.State ?? ""; + ws.Cell(r, 3).Value = row.AssignedTo ?? ""; + ws.Cell(r, 4).Value = row.FinancialProject ?? ""; + ws.Cell(r, 5).Value = row.Completed ?? ""; + ws.Cell(r, 6).Value = row.LogDate ?? ""; + ws.Cell(r, 7).Value = row.CreatedDate ?? ""; + ws.Cell(r, 8).Value = row.ClosedDate ?? ""; + ws.Cell(r, 9).Value = row.PbiId ?? ""; + ws.Cell(r, 10).Value = row.Notes ?? ""; + } + + // --------------------------------------------------------- 读取数据 + + /// 读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。 + public static List ReadRows(string path) + { + var rows = new List(); + using (var wb = new XLWorkbook(path)) + { + var ws = wb.Worksheets.First(); + var range = ws.RangeUsed(); + if (range == null) return rows; + + // 按表头名定位列(顺序不限),找不到的列按 Headers 顺序兜底 + var headerMap = MapHeaders(ws, range); + + for (int r = range.FirstRow().RowNumber() + 1; r <= range.LastRow().RowNumber(); r++) + { + var title = CellText(ws, r, headerMap, "Title"); + if (string.IsNullOrWhiteSpace(title)) continue; + rows.Add(new WorkItemRow + { + Title = title.Trim(), + State = CellText(ws, r, headerMap, "State"), + AssignedTo = CellText(ws, r, headerMap, "Assigned To"), + FinancialProject = CellText(ws, r, headerMap, "FinancialProject"), + Completed = CellText(ws, r, headerMap, "Completed"), + LogDate = CellText(ws, r, headerMap, "LogDate"), + CreatedDate = CellText(ws, r, headerMap, "Created Date"), + ClosedDate = CellText(ws, r, headerMap, "Closed Date"), + PbiId = CellText(ws, r, headerMap, "PBI Id"), + Notes = CellText(ws, r, headerMap, "Notes") + }); + } + } + return rows; + } + + private static Dictionary MapHeaders(IXLWorksheet ws, IXLRange range) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var firstRow = range.FirstRow().RowNumber(); + int lastCol = range.LastColumn().ColumnNumber(); + for (int c = 1; c <= lastCol; c++) + { + var text = ws.Cell(firstRow, c).GetString().Trim(); + if (text.Length == 0) continue; + // 归一化:去掉空格/下划线,兼容 "Assigned To" / "AssignedTo" / "Created Date" 等 + var key = text.Replace(" ", "").Replace("_", ""); + if (!map.ContainsKey(key)) map[key] = c; + } + // 兜底:模板标准列名 + foreach (var h in Headers) + { + var key = h.Replace(" ", ""); + if (!map.ContainsKey(key)) + { + int idx = Array.IndexOf(Headers, h) + 1; + if (idx <= lastCol) map[key] = idx; + } + } + return map; + } + + private static string CellText(IXLWorksheet ws, int row, Dictionary map, string header) + { + int col; + if (!map.TryGetValue(header.Replace(" ", ""), out col)) return ""; + var cell = ws.Cell(row, col); + if (cell.IsEmpty()) return ""; + if (cell.DataType == XLDataType.DateTime) + return cell.GetDateTime().ToString("yyyy-MM-dd HH:mm:ss"); + if (cell.DataType == XLDataType.Number) + { + double d; + if (double.TryParse(cell.GetString(), out d)) + { + // 处理 Excel 日期序列号(如 46300.0) + if (d > 20000 && d < 80000 && Math.Abs(d - Math.Floor(d)) < 0.001) + { + try { return DateTime.FromOADate(d).ToString("yyyy-MM-dd HH:mm:ss"); } + catch { } + } + return d.ToString("0.####"); + } + } + return cell.GetString().Trim(); + } + + // --------------------------------------------------------- 时间转换 + + /// 把单元格文本转成 TFS 接受的 ISO 时间;空值返回 null;解析失败抛异常。 + public static string ToIsoDateTime(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + value = value.Trim(); + foreach (var fmt in DateFormats) + { + DateTime dt; + if (DateTime.TryParseExact(value, fmt, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out dt)) + return dt.ToString("yyyy-MM-ddTHH:mm:ss"); + } + DateTime any; + if (DateTime.TryParse(value, out any)) + return any.ToString("yyyy-MM-ddTHH:mm:ss"); + throw new FormatException("无法解析时间: " + value); + } + + /// Completed 数值:整数去小数点,非法值返回 null。 + public static object ToNumber(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + double d; + if (!double.TryParse(value.Trim(), out d)) + throw new FormatException("无法解析数值: " + value); + return Math.Abs(d - Math.Floor(d)) < 0.0001 ? (object)(int)Math.Floor(d) : d; + } + } +} diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs new file mode 100644 index 0000000..0d8c87d --- /dev/null +++ b/TfsExcelImporter/MainForm.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace TfsExcelImporter +{ + public class MainForm : Form + { + // ------------------------------------------------------------- 控件 + private readonly TextBox _txtUrl = new TextBox(); + private readonly TextBox _txtProject = new TextBox(); + private readonly TextBox _txtType = new TextBox(); + private readonly ComboBox _cmbAuth = new ComboBox(); + private readonly TextBox _txtUser = new TextBox(); + private readonly TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true }; + private readonly TextBox _txtDomain = new TextBox(); + private readonly CheckBox _chkProxy = new CheckBox(); + + private readonly ComboBox _cmbIteration = new ComboBox(); + private readonly Button _btnLoadIterations = new Button(); + private readonly TextBox _txtExcel = new TextBox(); + private readonly Button _btnBrowse = new Button(); + private readonly Button _btnTemplate = new Button(); + private readonly CheckBox _chkSkip = new CheckBox(); + private readonly CheckBox _chkBypass = new CheckBox(); + private readonly TextBox _txtParentPbi = new TextBox(); + + private readonly DataGridView _grid = new DataGridView(); + private readonly Button _btnPreview = new Button(); + private readonly Button _btnImport = new Button(); + private readonly Button _btnFixArea = new Button(); + private readonly Button _btnLinkParent = new Button(); + private readonly TextBox _txtLog = new TextBox(); + private readonly Label _lblStatus = new Label(); + + private AppConfig _cfg; + private List _rows = new List(); + + public MainForm() + { + _cfg = AppConfig.Load(); + BuildUi(); + LoadConfigToUi(); + } + + // ------------------------------------------------------------- UI 构建 + private void BuildUi() + { + Text = "TFS 2013 工作项 Excel 导入工具"; + ClientSize = new Size(1060, 720); + MinimumSize = new Size(960, 640); + StartPosition = FormStartPosition.CenterScreen; + Font = new Font("Microsoft YaHei UI", 9F); + + int y = 12, left = 12, lblW = 84; + + // --- 服务器配置区 --- + var grpServer = new GroupBox + { + Text = "TFS 连接", + Location = new Point(left, y), + Size = new Size(1036, 120), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right + }; + Controls.Add(grpServer); + + AddLabel(grpServer, "服务器URL:", 12, 24, lblW); + _txtUrl.Location = new Point(100, 22); _txtUrl.Size = new Size(460, 27); + _txtUrl.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + grpServer.Controls.Add(_txtUrl); + + _chkProxy.Text = "使用系统代理"; + _chkProxy.Location = new Point(564, 24); _chkProxy.Size = new Size(110, 24); + _chkProxy.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _chkProxy.Checked = false; // 内网 TFS 默认直连 + grpServer.Controls.Add(_chkProxy); + + AddLabel(grpServer, "团队项目:", 680, 24, lblW); + _txtProject.Location = new Point(768, 22); _txtProject.Size = new Size(150, 27); + _txtProject.Anchor = AnchorStyles.Top | AnchorStyles.Right; + grpServer.Controls.Add(_txtProject); + + AddLabel(grpServer, "工作项类型:", 12, 58, lblW); + _txtType.Location = new Point(100, 56); _txtType.Size = new Size(220, 27); + grpServer.Controls.Add(_txtType); + + AddLabel(grpServer, "认证方式:", 340, 58, lblW); + _cmbAuth.Location = new Point(428, 56); _cmbAuth.Size = new Size(110, 27); + _cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList; + _cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" }); + _cmbAuth.SelectedIndexChanged += (s, e) => UpdateAuthUi(); + grpServer.Controls.Add(_cmbAuth); + + AddLabel(grpServer, "用户:", 556, 58, 44); + _txtUser.Location = new Point(604, 56); _txtUser.Size = new Size(110, 27); + grpServer.Controls.Add(_txtUser); + + AddLabel(grpServer, "密码:", 726, 58, 44); + _txtPwd.Location = new Point(774, 56); _txtPwd.Size = new Size(110, 27); + grpServer.Controls.Add(_txtPwd); + + AddLabel(grpServer, "域:", 896, 58, 28); + _txtDomain.Location = new Point(928, 56); _txtDomain.Size = new Size(94, 27); + _txtDomain.Anchor = AnchorStyles.Top | AnchorStyles.Right; + grpServer.Controls.Add(_txtDomain); + + y += 128; + + // --- 导入选区 --- + var grpImport = new GroupBox + { + Text = "导入选区", + Location = new Point(left, y), + Size = new Size(1036, 120), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right + }; + Controls.Add(grpImport); + + AddLabel(grpImport, "目标Iteration:", 12, 26, 96); + _cmbIteration.Location = new Point(112, 23); _cmbIteration.Size = new Size(560, 27); + _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填 + _cmbIteration.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + grpImport.Controls.Add(_cmbIteration); + + _btnLoadIterations.Text = "加载迭代列表"; + _btnLoadIterations.Location = new Point(682, 22); _btnLoadIterations.Size = new Size(110, 29); + _btnLoadIterations.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); + grpImport.Controls.Add(_btnLoadIterations); + + AddLabel(grpImport, "Excel 文件:", 12, 62, 96); + _txtExcel.Location = new Point(112, 59); _txtExcel.Size = new Size(560, 27); + _txtExcel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + grpImport.Controls.Add(_txtExcel); + + _btnBrowse.Text = "浏览..."; + _btnBrowse.Location = new Point(682, 58); _btnBrowse.Size = new Size(70, 29); + _btnBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _btnBrowse.Click += (s, e) => BrowseExcel(); + grpImport.Controls.Add(_btnBrowse); + + _btnTemplate.Text = "生成Excel模板"; + _btnTemplate.Location = new Point(760, 58); _btnTemplate.Size = new Size(110, 29); + _btnTemplate.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _btnTemplate.Click += (s, e) => GenerateTemplate(); + grpImport.Controls.Add(_btnTemplate); + + _chkSkip.Text = "跳过已存在标题"; + _chkSkip.Location = new Point(886, 26); _chkSkip.Size = new Size(130, 24); + _chkSkip.Anchor = AnchorStyles.Top | AnchorStyles.Right; + grpImport.Controls.Add(_chkSkip); + + _chkBypass.Text = "bypassRules(可写系统日期)"; + _chkBypass.Location = new Point(886, 60); _chkBypass.Size = new Size(150, 24); + _chkBypass.Anchor = AnchorStyles.Top | AnchorStyles.Right; + grpImport.Controls.Add(_chkBypass); + + // 父 PBI:全部 Task 默认挂接的 Product Backlog Item(Excel 每行 PBI Id 列可覆盖) + AddLabel(grpImport, "父PBI Id:", 12, 92, 96); + _txtParentPbi.Location = new Point(112, 89); _txtParentPbi.Size = new Size(200, 27); + grpImport.Controls.Add(_txtParentPbi); + var lblPbiHint = new Label + { + Text = "Task 将挂接为该 Product Backlog Item 的子项;Excel 加 PBI Id 列可逐行指定不同父项", + Location = new Point(320, 92), Size = new Size(560, 22), + TextAlign = ContentAlignment.MiddleLeft, + ForeColor = SystemColors.GrayText + }; + grpImport.Controls.Add(lblPbiHint); + + y += 128; + + // --- 预览表格 --- + _grid.Location = new Point(left, y); + _grid.Size = new Size(1036, 300); + _grid.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom; + _grid.ReadOnly = true; + _grid.AllowUserToAddRows = false; + _grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells; + _grid.RowHeadersVisible = false; + _grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + Controls.Add(_grid); + y += 308; + + // --- 操作按钮 --- + _btnPreview.Text = "预览数据"; + _btnPreview.Location = new Point(left, y); _btnPreview.Size = new Size(110, 32); + _btnPreview.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + _btnPreview.Click += (s, e) => PreviewData(); + Controls.Add(_btnPreview); + + _btnImport.Text = "开始导入 TFS"; + _btnImport.Location = new Point(left + 122, y); _btnImport.Size = new Size(130, 32); + _btnImport.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + _btnImport.BackColor = Color.FromArgb(0, 120, 215); + _btnImport.ForeColor = Color.White; + _btnImport.Click += async (s, e) => await ImportAsync(); + Controls.Add(_btnImport); + + _btnFixArea.Text = "按迭代修Area"; + _btnFixArea.Location = new Point(left + 264, y); _btnFixArea.Size = new Size(130, 32); + _btnFixArea.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + _btnFixArea.Click += async (s, e) => await FixAreaAsync(); + Controls.Add(_btnFixArea); + + _btnLinkParent.Text = "补挂父项"; + _btnLinkParent.Location = new Point(left + 406, y); _btnLinkParent.Size = new Size(130, 32); + _btnLinkParent.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + _btnLinkParent.Click += async (s, e) => await LinkParentAsync(); + Controls.Add(_btnLinkParent); + + _lblStatus.Text = "就绪"; + _lblStatus.Location = new Point(left + 548, y + 6); _lblStatus.Size = new Size(400, 22); + _lblStatus.Anchor = AnchorStyles.Bottom | AnchorStyles.Left; + Controls.Add(_lblStatus); + y += 40; + + // --- 日志 --- + _txtLog.Location = new Point(left, y); + _txtLog.Size = new Size(1036, 120); + _txtLog.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + _txtLog.Multiline = true; + _txtLog.ScrollBars = ScrollBars.Vertical; + _txtLog.ReadOnly = true; + Controls.Add(_txtLog); + } + + private static void AddLabel(Control parent, string text, int x, int y, int w) + { + var lbl = new Label { Text = text, Location = new Point(x, y), Size = new Size(w, 22), TextAlign = ContentAlignment.MiddleLeft }; + parent.Controls.Add(lbl); + } + + private void UpdateAuthUi() + { + bool needCreds = _cmbAuth.SelectedItem != null && _cmbAuth.SelectedItem.ToString() != "default"; + _txtUser.Enabled = needCreds; + _txtPwd.Enabled = needCreds; + _txtDomain.Enabled = needCreds; + } + + // ------------------------------------------------------------- 配置互转 + private void LoadConfigToUi() + { + _txtUrl.Text = _cfg.TfsUrl; + _txtProject.Text = _cfg.Project; + _txtType.Text = _cfg.WorkItemType; + _cmbAuth.SelectedItem = _cfg.AuthMode; + if (_cmbAuth.SelectedItem == null) _cmbAuth.SelectedItem = "ntlm"; + _txtUser.Text = _cfg.Username; + _txtPwd.Text = _cfg.Password; + _txtDomain.Text = _cfg.Domain; + _chkProxy.Checked = _cfg.UseSystemProxy; + _cmbIteration.Text = _cfg.IterationPath; + _txtExcel.Text = _cfg.LastExcelPath; + _chkSkip.Checked = _cfg.SkipExistingTitles; + _chkBypass.Checked = _cfg.BypassRules; + _txtParentPbi.Text = _cfg.ParentPbiId; + UpdateAuthUi(); + } + + private void SaveUiToConfig() + { + _cfg.TfsUrl = _txtUrl.Text.Trim(); + _cfg.Project = _txtProject.Text.Trim(); + _cfg.WorkItemType = _txtType.Text.Trim(); + _cfg.AuthMode = _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm"; + _cfg.Username = _txtUser.Text.Trim(); + _cfg.Password = _txtPwd.Text; + _cfg.Domain = _txtDomain.Text.Trim(); + _cfg.UseSystemProxy = _chkProxy.Checked; + _cfg.IterationPath = _cmbIteration.Text.Trim(); + _cfg.LastExcelPath = _txtExcel.Text.Trim(); + _cfg.SkipExistingTitles = _chkSkip.Checked; + _cfg.BypassRules = _chkBypass.Checked; + _cfg.ParentPbiId = _txtParentPbi.Text.Trim(); + _cfg.Save(); + } + + // ------------------------------------------------------------- 日志 + private void Log(string msg) + { + if (InvokeRequired) { BeginInvoke(new Action(() => Log(msg))); return; } + _txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); + } + + // ------------------------------------------------------------- 迭代加载 + private async Task LoadIterationsAsync() + { + SaveUiToConfig(); + _btnLoadIterations.Enabled = false; + try + { + using (var client = new TfsClient(_cfg)) + { + Log("测试连接..."); + Log(await client.TestConnectionAsync()); + // 提前探测导入所需字段接口,避免导入时才发现路由缺失 + try + { + var fm = await client.GetFieldMapAsync(); + Log($"字段接口可用(字段 {fm.Count} 个)"); + } + catch (Exception ex) + { + Log("⚠ 字段接口(_apis/wit/fields)不可用,导入可能受影响: " + ex.Message.Split('\n')[0]); + } + var paths = await client.GetIterationPathsAsync(_cfg.Project, Log); + _cmbIteration.Items.Clear(); + foreach (var p in paths) _cmbIteration.Items.Add(p); + if (_cmbIteration.Items.Count > 0 && string.IsNullOrWhiteSpace(_cmbIteration.Text)) + _cmbIteration.SelectedIndex = 0; + // 优先选中配置中的迭代 + for (int i = 0; i < _cmbIteration.Items.Count; i++) + { + if (string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath, + StringComparison.OrdinalIgnoreCase)) + { + _cmbIteration.SelectedIndex = i; + break; + } + } + Log($"已加载 {_cmbIteration.Items.Count} 个迭代路径"); + } + } + catch (Exception ex) + { + Log("加载迭代失败: " + ex.Message); + MessageBox.Show(this, "加载迭代失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + _btnLoadIterations.Enabled = true; + } + } + + // ------------------------------------------------------------- Excel + private void BrowseExcel() + { + using (var dlg = new OpenFileDialog()) + { + dlg.Filter = "Excel 文件 (*.xlsx;*.xlsm)|*.xlsx;*.xlsm|所有文件 (*.*)|*.*"; + dlg.Title = "选择要导入的 Excel 文件"; + if (!string.IsNullOrEmpty(_txtExcel.Text)) + { + try { dlg.InitialDirectory = Path.GetDirectoryName(_txtExcel.Text); } catch { } + } + if (dlg.ShowDialog(this) != DialogResult.OK) return; + _txtExcel.Text = dlg.FileName; + SaveUiToConfig(); + PreviewData(); + } + } + + private void GenerateTemplate() + { + using (var dlg = new SaveFileDialog()) + { + dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx"; + dlg.FileName = "TFS导入模板.xlsx"; + dlg.Title = "保存 Excel 模板"; + if (dlg.ShowDialog(this) != DialogResult.OK) return; + try + { + ExcelService.GenerateTemplate(dlg.FileName); + Log("模板已生成: " + dlg.FileName); + MessageBox.Show(this, "模板已生成:\n" + dlg.FileName, "完成", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + Log("生成模板失败: " + ex.Message); + MessageBox.Show(this, "生成模板失败:\n" + ex.Message, "错误", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void PreviewData() + { + var path = _txtExcel.Text.Trim(); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + try + { + _rows = ExcelService.ReadRows(path); + _grid.DataSource = null; + _grid.DataSource = _rows.Select((r, i) => new + { + 行号 = i + 1, + r.Title, + r.State, + 指派 = r.AssignedTo, + 财务项目 = r.FinancialProject, + r.Completed, + LogDate = r.LogDate, + Created = r.CreatedDate, + Closed = r.ClosedDate, + r.Notes + }).ToList(); + _lblStatus.Text = $"已读取 {_rows.Count} 行"; + Log($"读取 {path} 共 {_rows.Count} 行"); + SaveUiToConfig(); + } + catch (Exception ex) + { + Log("读取 Excel 失败: " + ex.Message); + MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + // ------------------------------------------------------------- Area 修正 + private async Task FixAreaAsync() + { + SaveUiToConfig(); + if (string.IsNullOrWhiteSpace(_cfg.IterationPath)) + { + MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (string.IsNullOrWhiteSpace(_cfg.AreaPath)) + { + MessageBox.Show(this, "请先在 config.json 中设置 AreaPath(如 Tools_Dev\\DataTechnologies)", "提示", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + var confirm = MessageBox.Show(this, + $"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且 Area≠{_cfg.AreaPath} 的工作项\nArea 改为 {_cfg.AreaPath}。\n\n注意:已关闭(如 Done)的工作项会被临时重新打开,改完 Area 后自动还原状态。\n\n确认执行?", + "确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + + _btnFixArea.Enabled = false; + try + { + using (var client = new TfsClient(_cfg)) + { + var r = await client.FixAreaAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType, _cfg.AreaPath, Log); + var summary = r.Item2 > 0 + ? $"Area 修正完成:成功 {r.Item1} 条,失败 {r.Item2} 条(详见日志)" + : $"Area 修正完成,共 {r.Item1} 条"; + _lblStatus.Text = summary; + Log(summary); + MessageBox.Show(this, summary, r.Item2 > 0 ? "部分完成" : "完成", + MessageBoxButtons.OK, r.Item2 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information); + } + } + catch (Exception ex) + { + Log("Area 修正失败: " + ex.Message); + MessageBox.Show(this, "Area 修正失败:\n" + ex.Message, "错误", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + _btnFixArea.Enabled = true; + } + } + + // ------------------------------------------------------------- 补挂父项 + private async Task LinkParentAsync() + { + SaveUiToConfig(); + if (string.IsNullOrWhiteSpace(_cfg.IterationPath)) + { + MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (!int.TryParse(_cfg.ParentPbiId, out var parentId)) + { + MessageBox.Show(this, "请先在「父PBI Id」输入框填入父工作项 Id(纯数字)", "提示", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + var confirm = MessageBox.Show(this, + $"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且尚无父项的工作项\n补挂为 #{parentId} 的子项。\n\n已有父项的自动跳过;已关闭的工作项会临时重新打开,挂完自动还原状态。\n\n确认执行?", + "确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + + _btnLinkParent.Enabled = false; + try + { + using (var client = new TfsClient(_cfg)) + { + var r = await client.LinkExistingAsync(_cfg.Project, _cfg.IterationPath, + _cfg.WorkItemType, parentId, Log); + var summary = $"补挂完成: 挂接 {r.Item1} / 已有父跳过 {r.Item2} / 失败 {r.Item3}"; + _lblStatus.Text = summary; + Log(summary); + MessageBox.Show(this, summary + "\n挂接后在积压工作页展开父项即可看到子项。", + r.Item3 > 0 ? "部分完成" : "完成", MessageBoxButtons.OK, + r.Item3 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information); + } + } + catch (Exception ex) + { + Log("补挂父项失败: " + ex.Message); + MessageBox.Show(this, "补挂父项失败:\n" + ex.Message, "错误", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + _btnLinkParent.Enabled = true; + } + } + + // ------------------------------------------------------------- 导入 + private async Task ImportAsync() + { + SaveUiToConfig(); + + if (string.IsNullOrWhiteSpace(_cfg.IterationPath)) + { + MessageBox.Show(this, "请先选择目标 Iteration(可点击“加载迭代列表”)", "提示", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + var path = _txtExcel.Text.Trim(); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + if (_rows.Count == 0) + { + try { _rows = ExcelService.ReadRows(path); } + catch (Exception ex) + { + MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + } + if (_rows.Count == 0) + { + MessageBox.Show(this, "Excel 中没有可导入的数据行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + _btnImport.Enabled = false; + _btnPreview.Enabled = false; + int ok = 0, skip = 0, fail = 0; + var report = new StringBuilder(); + report.AppendLine("行号,Title,结果,WorkItemId,错误"); + + try + { + using (var client = new TfsClient(_cfg)) + { + Log("测试连接..."); + Log(await client.TestConnectionAsync()); + + // 字段解析 + var fieldMap = await client.GetFieldMapAsync(); + var titleRef = "System.Title"; + var stateRef = "System.State"; + var assignedRef = "System.AssignedTo"; + var iterationRef = "System.IterationPath"; + var areaRef = "System.AreaPath"; + var createdRef = "System.CreatedDate"; + var closedRef = "Microsoft.VSTS.Common.ClosedDate"; + + var finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef); + var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef); + var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef); + Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"}"); + Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖"); + + HashSet existing = null; + if (_chkSkip.Checked) + { + Log("查询已有工作项标题..."); + existing = await client.GetExistingTitlesAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType); + Log($"服务器已有 {existing.Count} 条"); + } + + for (int i = 0; i < _rows.Count; i++) + { + var row = _rows[i]; + var line = i + 2; // Excel 实际行号(含表头) + try + { + if (existing != null && existing.Contains(row.Title)) + { + skip++; + report.AppendLine($"{line},\"{row.Title}\",SKIP,,标题已存在"); + Log($"[{i + 1}/{_rows.Count}] SKIP {row.Title}"); + continue; + } + + var fields = new List> + { + new KeyValuePair(titleRef, row.Title), + new KeyValuePair(stateRef, + string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()), + new KeyValuePair(iterationRef, _cfg.IterationPath) + }; + if (!string.IsNullOrWhiteSpace(row.AssignedTo)) + fields.Add(new KeyValuePair(assignedRef, row.AssignedTo.Trim())); + if (!string.IsNullOrWhiteSpace(_cfg.AreaPath)) + fields.Add(new KeyValuePair(areaRef, _cfg.AreaPath.Trim())); + if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null) + fields.Add(new KeyValuePair(finRef, row.FinancialProject.Trim())); + + var completed = ExcelService.ToNumber(row.Completed); + if (completed != null && compRef != null) + fields.Add(new KeyValuePair(compRef, completed)); + + // Notes -> Description(该 Task 模板必填) + if (!string.IsNullOrWhiteSpace(row.Notes)) + fields.Add(new KeyValuePair("System.Description", row.Notes.Trim())); + + var logDate = ExcelService.ToIsoDateTime(row.LogDate); + if (logDate != null && logRef != null) + fields.Add(new KeyValuePair(logRef, logDate)); + + // 系统日期字段:bypassRules 时尝试写入 + if (_chkBypass.Checked) + { + var created = ExcelService.ToIsoDateTime(row.CreatedDate); + if (created != null) + fields.Add(new KeyValuePair(createdRef, created)); + var closed = ExcelService.ToIsoDateTime(row.ClosedDate); + if (closed != null) + fields.Add(new KeyValuePair(closedRef, closed)); + } + + // 父 PBI:行级 PBI Id 列优先,其次界面默认值 + var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId; + int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null; + + var id = await client.CreateWorkItemAsync(_cfg.Project, _cfg.WorkItemType, fields, _chkBypass.Checked, parentPbi); + ok++; + report.AppendLine($"{line},\"{row.Title}\",OK,{id},"); + Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}"); + } + catch (Exception ex) + { + fail++; + report.AppendLine($"{line},\"{row.Title}\",FAIL,,\"{ex.Message.Replace("\"", "'")}\""); + Log($"[{i + 1}/{_rows.Count}] FAIL {row.Title} -> {ex.Message}"); + } + _lblStatus.Text = $"导入中 {i + 1}/{_rows.Count}..."; + } + } + } + catch (Exception ex) + { + Log("导入中断: " + ex.Message); + MessageBox.Show(this, "导入中断:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + _btnImport.Enabled = true; + _btnPreview.Enabled = true; + try + { + var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, + "import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv"); + File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8); + Log($"报告已保存: {reportPath}"); + } + catch (Exception ex) { Log("写报告失败: " + ex.Message); } + _lblStatus.Text = $"完成: 成功 {ok} / 跳过 {skip} / 失败 {fail}"; + Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}"); + MessageBox.Show(this, $"导入完成。\n成功 {ok} / 跳过 {skip} / 失败 {fail}\n详见日志与报告 CSV。", + "完成", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + } +} diff --git a/TfsExcelImporter/Program.cs b/TfsExcelImporter/Program.cs new file mode 100644 index 0000000..a417a71 --- /dev/null +++ b/TfsExcelImporter/Program.cs @@ -0,0 +1,16 @@ +using System; +using System.Windows.Forms; + +namespace TfsExcelImporter +{ + internal static class Program + { + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new MainForm()); + } + } +} diff --git a/TfsExcelImporter/TfsClient.cs b/TfsExcelImporter/TfsClient.cs new file mode 100644 index 0000000..4d8459d --- /dev/null +++ b/TfsExcelImporter/TfsClient.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.TeamFoundation.Client; +using Microsoft.TeamFoundation.Server; +using Microsoft.TeamFoundation.WorkItemTracking.Client; + +namespace TfsExcelImporter +{ + /// + /// TFS 客户端:基于 TFS Client Object Model(内部 SOAP 协议,与 VS/Team Explorer 同款)。 + /// 适用原因:实测该服务器仅有 /_apis/projects 可用,wit 全部 REST 路由 404(TFS 2013 RTM/低 Update), + /// Classification.asmx 的 WSDL 也不可达——裸 REST/SOAP 均走不通,必须走 OM。 + /// + public class TfsClient : IDisposable + { + private readonly TfsTeamProjectCollection _tpc; + private WorkItemStore _store; + + public TfsClient(AppConfig cfg) + { + ICredentials cred; + var mode = (cfg.AuthMode ?? "ntlm").ToLowerInvariant(); + if (mode == "default") + { + cred = CredentialCache.DefaultCredentials; + } + else + { + var user = string.IsNullOrEmpty(cfg.Domain) ? cfg.Username : cfg.Domain + "\\" + cfg.Username; + cred = new NetworkCredential(user, cfg.Password ?? ""); + } + _tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred); + + // TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。 + // 默认直连;勾选"使用系统代理"时保留系统代理并带 Windows 凭据过代理认证。 + if (!cfg.UseSystemProxy) + { + WebRequest.DefaultWebProxy = new WebProxy(); // 空 WebProxy = 不走代理,直连 + } + else + { + var p = WebRequest.DefaultWebProxy ?? new WebProxy(); + p.Credentials = CredentialCache.DefaultCredentials; + WebRequest.DefaultWebProxy = p; + } + } + + /// WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。 + private WorkItemStore Store + { + get { return _store ?? (_store = _tpc.GetService()); } + } + + // ------------------------------------------------------------- 连通性 + + public async Task TestConnectionAsync() + { + return await Task.Run(() => + { + _tpc.Authenticate(); + var css = _tpc.GetService(); + return $"连接成功(TFS OM/SOAP),可见项目 {css.ListAllProjects().Length} 个"; + }); + } + + // ------------------------------------------------------------- 字段 + + /// 返回显示名 -> 引用名 的不区分大小写字典(来自服务器字段定义)。 + public async Task> GetFieldMapAsync() + { + return await Task.Run(() => + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (FieldDefinition fd in Store.FieldDefinitions) + { + if (!string.IsNullOrEmpty(fd.Name) && !map.ContainsKey(fd.Name)) + map[fd.Name] = fd.ReferenceName; + } + return map; + }); + } + + /// 按显示名解析字段引用名;支持宽松前缀匹配(如 Completed -> Completed Work)。 + public static string ResolveRef(Dictionary map, string displayName, string explicitRef) + { + if (!string.IsNullOrEmpty(explicitRef)) return explicitRef; + if (string.IsNullOrEmpty(displayName)) return null; + string exact; + if (map.TryGetValue(displayName.Trim(), out exact)) return exact; + var prefix = displayName.Trim(); + foreach (var kv in map) + { + if (kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return kv.Value; + } + return null; + } + + // ------------------------------------------------------------- 迭代 + + /// 取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。 + public async Task> GetIterationPathsAsync(string project, Action log = null) + { + var list = await Task.Run(() => + { + var css = _tpc.GetService(); + var proj = css.GetProjectFromName(project); + if (proj == null) + throw new InvalidOperationException("项目不存在或无权访问: " + project); + + var structures = css.ListStructures(proj.Uri); + var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle") + ?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0); + if (iterations == null) + throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构"); + + // GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径 + var tree = css.GetNodesXml(new[] { iterations.Uri }, true); + var raw = new List(); + if (tree != null) + { + foreach (XmlNode n in tree.SelectNodes("//*[@Path]")) + { + var p = n.Attributes != null && n.Attributes["Path"] != null ? n.Attributes["Path"].Value : null; + if (!string.IsNullOrEmpty(p)) raw.Add(p); + } + } + return raw; + }); + + var result = list.Select(p => NormalizeIterationPath(p, project)) + .Where(p => p != null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)"); + return result; + } + + /// + /// 节点路径归一化为工作项 IterationPath(项目\子...)。 + /// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。 + /// + private static string NormalizeIterationPath(string nodePath, string project) + { + if (string.IsNullOrWhiteSpace(nodePath)) return null; + var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + if (segs.Count == 0) return null; + if (string.Equals(segs[0], project, StringComparison.OrdinalIgnoreCase)) segs.RemoveAt(0); + if (segs.Count > 0 && + (segs[0].Equals("Iteration", StringComparison.OrdinalIgnoreCase) || + segs[0].Equals("Iterations", StringComparison.OrdinalIgnoreCase) || + segs[0].Equals("Area", StringComparison.OrdinalIgnoreCase) || + segs[0].Equals("Areas", StringComparison.OrdinalIgnoreCase))) + segs.RemoveAt(0); + if (segs.Count == 0) return null; + return project + "\\" + string.Join("\\", segs); + } + + // ------------------------------------------------------------- 查重 + + /// 查询指定迭代下同类型工作项的标题集合。 + public async Task> GetExistingTitlesAsync(string project, string iterationPath, string workItemType) + { + return await Task.Run(() => + { + var wiql = + "SELECT [System.Title] FROM WorkItems WHERE " + + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}'"; + var titles = new HashSet(StringComparer.Ordinal); + foreach (WorkItem wi in Store.Query(wiql)) + { + if (!string.IsNullOrEmpty(wi.Title)) titles.Add(wi.Title); + } + return titles; + }); + } + + // ------------------------------------------------------------- 创建 + + /// + /// 创建工作项(OM Save)。返回新工作项 ID。 + /// 说明:OM 14.x 的 SaveFlags 无 BypassRules(15.x 才加入,且 15.x 不兼容 TFS 2013)—— + /// System.CreatedDate 为服务器管理的只读字段,无法写入(取导入时间); + /// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入, + /// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。 + /// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。 + /// + public async Task CreateWorkItemAsync(string project, string workItemType, + List> fields, bool bypassRules, int? parentPbiId = null) + { + return await Task.Run(() => + { + var wi = new WorkItem(Store.Projects[project].WorkItemTypes[workItemType]); + var closedSet = false; + foreach (var kv in fields) + { + if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) continue; + if (kv.Key == "System.CreatedDate") + continue; // 服务器管理字段,OM14 无法写入 + if (kv.Key == "Microsoft.VSTS.Common.ClosedDate") + { + try { wi.Fields[kv.Key].Value = kv.Value; closedSet = true; } + catch { /* 环境不允许设置,跳过 */ } + continue; + } + wi.Fields[kv.Key].Value = kv.Value; + } + + // 父 PBI 链接(与建项同一 Save 原子完成;PBI 不存在等错误会整行报 FAIL) + if (parentPbiId.HasValue) + { + var parentEnd = FindParentLinkEnd(); + if (parentEnd == null) + throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy"); + wi.Links.Add(new RelatedLink(parentEnd, parentPbiId.Value)); + } + + var bad = CheckInvalid(wi); + if (bad != null && closedSet && + bad.IndexOf("ClosedDate", StringComparison.OrdinalIgnoreCase) >= 0) + { + ClearClosedDate(wi); + closedSet = false; + bad = CheckInvalid(wi); + } + if (bad != null) + throw new InvalidOperationException("字段校验失败: " + bad); + + try + { + wi.Save(); + } + catch (Exception ex) + { + if (!closedSet) throw; + // 服务器端拒绝(如 ClosedDate 只读规则):清空降级为普通保存 + ClearClosedDate(wi); + try + { + wi.Save(); + closedSet = false; + } + catch (Exception ex2) + { + throw new InvalidOperationException("保存失败: " + ex.Message + + (ex2 == null ? "" : " / 降级重试: " + ex2.Message)); + } + } + return wi.Id; + }); + } + + /// 找父子链接类型的 Parent 端(子→父)。 + private WorkItemLinkTypeEnd FindParentLinkEnd() + { + foreach (WorkItemLinkType lt in Store.WorkItemLinkTypes) + { + if (string.Equals(lt.ReferenceName, "System.LinkTypes.Hierarchy", StringComparison.OrdinalIgnoreCase)) + return lt.ReverseEnd; + } + return null; + } + + private static string CheckInvalid(WorkItem wi) + { + var invalid = wi.Validate(); + if (invalid.Count == 0) return null; + var names = new StringBuilder(); + foreach (Field f in invalid) + names.Append(f.Name).Append("(").Append(FieldStatusText(f)).Append("); "); + return names.ToString(); + } + + /// 把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。 + private static string FieldStatusText(Field f) + { + switch (f.Status.ToString()) + { + case "InvalidEmpty": return "必填字段为空"; + case "InvalidTooLong": return "值超过最大长度"; + case "InvalidType": return "值类型不匹配"; + case "InvalidValue": return "值不在允许范围内"; + case "InvalidFormat": return "值格式不正确"; + case "InvalidList": return "值不在允许列表中"; + case "InvalidNull": return "值不能为空"; + case "InvalidComputed": return "计算字段不可直接修改"; + case "InvalidNotNew": return "仅新建时可设置"; + case "Valid": return "有效"; + default: return "状态=" + f.Status; + } + } + + private static void ClearClosedDate(WorkItem wi) + { + try { wi.Fields["Microsoft.VSTS.Common.ClosedDate"].Value = null; } catch { } + } + + /// + /// 把指定迭代下、类型匹配、且 AreaPath 不等于目标值的工作项,批量改为目标 Area。 + /// 用于修复"导入时未设 AreaPath 导致团队视图看不到"的历史数据。 + /// 关闭状态(如 Task 的 Done)的工作项是只读的,会报 TF400276, + /// 此时会走"重新打开 -> 改 Area -> 还原状态"的状态循环。 + /// 返回 (成功条数, 失败条数)。 + /// + public async Task> FixAreaAsync(string project, string iterationPath, string workItemType, + string areaPath, Action log) + { + return await Task.Run(() => + { + var wiql = "SELECT [System.Id] FROM WorkItems WHERE " + + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " + + $"[System.AreaPath] <> '{EscapeWiql(areaPath)}'"; + var ok = 0; + var fail = 0; + foreach (WorkItem wi in Store.Query(wiql)) + { + var state = Convert.ToString(wi.Fields["System.State"].Value); + try + { + wi.Fields["System.AreaPath"].Value = areaPath; + wi.Save(); + ok++; + log?.Invoke($" #{wi.Id} [{state}] {wi.Title} Area -> {areaPath}"); + } + catch (Exception ex) + { + var msg = ex.Message ?? ""; + if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开")) + { + if (TryFixAreaViaReopen(wi, areaPath, log)) { ok++; continue; } + } + fail++; + log?.Invoke($" #{wi.Id} [{state}] {wi.Title} 修改失败: {msg}"); + } + } + return Tuple.Create(ok, fail); + }); + } + + // ------------------------------------------------------------- 补挂父项 + + /// + /// 把指定迭代下、类型匹配、且尚未挂父项的工作项,批量补挂到 parentWorkItemId 下。 + /// 对应 Python 版 --link-existing:已有父项(Hierarchy-Reverse 链接)的跳过; + /// 关闭状态(如 Task 的 Done,TF400276 只读)走"重新打开 -> 挂接 -> 还原状态"循环。 + /// 返回 (成功条数, 已有父跳过条数, 失败条数)。 + /// + public async Task> LinkExistingAsync(string project, string iterationPath, + string workItemType, int parentWorkItemId, Action log) + { + return await Task.Run(() => + { + var parentEnd = FindParentLinkEnd(); + if (parentEnd == null) + throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy"); + + var wiql = "SELECT [System.Id] FROM WorkItems WHERE " + + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}'"; + var ok = 0; + var skip = 0; + var fail = 0; + foreach (WorkItem wi in Store.Query(wiql)) + { + try + { + if (HasParentLink(wi)) + { + skip++; + log?.Invoke($" #{wi.Id} {wi.Title} 已有父项,跳过"); + continue; + } + wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId)); + wi.Save(); + ok++; + log?.Invoke($" #{wi.Id} {wi.Title} -> 父项 #{parentWorkItemId}"); + } + catch (Exception ex) + { + var msg = ex.Message ?? ""; + if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开")) + { + if (TryViaReopen(wi, () => + { + // Save 失败时挂起的链接仍在 Links 中,先查重避免重复添加 + if (!HasParentLink(wi)) + wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId)); + }, log)) { ok++; continue; } + } + fail++; + log?.Invoke($" #{wi.Id} {wi.Title} 挂接失败: {msg}"); + } + } + return Tuple.Create(ok, skip, fail); + }); + } + + /// 判断工作项是否已有父级链接(System.LinkTypes.Hierarchy-Reverse)。 + private static bool HasParentLink(WorkItem wi) + { + foreach (var link in wi.Links.OfType()) + { + WorkItemLinkTypeEnd end = null; + try { end = link.LinkTypeEnd; } catch { } + if (end == null) continue; + // OM 14.x 的 WorkItemLinkTypeEnd 无 ReferenceName:用 ImmutableName(Hierarchy-Reverse) + // 或 Name(Parent) 判断;两者都兜底,防不同版本元数据差异 + var immutable = end.ImmutableName ?? ""; + var name = end.Name ?? ""; + if (immutable.IndexOf("Hierarchy-Reverse", StringComparison.OrdinalIgnoreCase) >= 0 || + string.Equals(name, "Parent", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// + /// 关闭状态的工作项(TF400276)走状态循环执行操作: + /// State 改为打开状态并 Save -> 执行 action 并 Save -> State 改回原值并 Save。 + /// 只使用当前状态合法可迁移到的打开状态(读 AllowedValues),迁移不合法则放弃。 + /// + private bool TryViaReopen(WorkItem wi, Action action, Action log) + { + var stateField = wi.Fields["System.State"]; + var origState = Convert.ToString(stateField.Value); + var openState = PickOpenState(stateField); + if (openState == null) + { + log?.Invoke($" #{wi.Id} {wi.Title} 处于关闭状态[{origState}]且没有合法的重新打开状态,跳过"); + return false; + } + try + { + log?.Invoke($" #{wi.Id} {wi.Title} 已关闭[{origState}],走状态循环: {origState} -> {openState} -> 执行操作 -> {origState}"); + stateField.Value = openState; + wi.Save(); + + action(); + + stateField.Value = origState; + wi.Save(); + + log?.Invoke($" #{wi.Id} 操作完成(已还原状态为 {origState})"); + return true; + } + catch (Exception ex) + { + // 中途失败时把状态改回去,避免工作项停留在中间状态 + try + { + var cur = Convert.ToString(wi.Fields["System.State"].Value); + if (cur != origState) + { + wi.Fields["System.State"].Value = origState; + wi.Save(); + log?.Invoke($" #{wi.Id} 已回滚状态为 {origState}"); + } + } + catch { /* 回滚失败也只能记录 */ } + log?.Invoke($" #{wi.Id} {wi.Title} 状态循环失败: {ex.Message}"); + return false; + } + } + + /// + /// 关闭状态的工作项(TF400276)走状态循环修 Area。 + /// + private bool TryFixAreaViaReopen(WorkItem wi, string areaPath, Action log) + { + return TryViaReopen(wi, () => { wi.Fields["System.AreaPath"].Value = areaPath; }, log); + } + + /// 按常见命名顺序,从当前状态可迁移到的 AllowedValues 里挑一个"打开"状态。 + private static string PickOpenState(Field stateField) + { + var candidates = new[] + { + "To Do", "New", "Active", "In Progress", "Proposed", "Approved", + "Committed", "Open", "Design", "Development", "InProgress" + }; + foreach (var c in candidates) + foreach (var v in stateField.AllowedValues) + if (string.Equals(Convert.ToString(v), c, StringComparison.OrdinalIgnoreCase)) + return Convert.ToString(v); + return null; + } + + // ------------------------------------------------------------- 工具 + + private static string EscapeWiql(string s) + { + return (s ?? "").Replace("'", "''"); + } + + public void Dispose() + { + _tpc.Dispose(); + } + } +} diff --git a/TfsExcelImporter/TfsExcelImporter.csproj b/TfsExcelImporter/TfsExcelImporter.csproj new file mode 100644 index 0000000..ce48ffc --- /dev/null +++ b/TfsExcelImporter/TfsExcelImporter.csproj @@ -0,0 +1,31 @@ + + + + WinExe + net48 + true + latest + disable + TFS Excel 导入工具 + TFS Excel 导入工具 + true + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/TfsExcelImporter/config.json b/TfsExcelImporter/config.json new file mode 100644 index 0000000..bc23575 --- /dev/null +++ b/TfsExcelImporter/config.json @@ -0,0 +1,20 @@ +{ + "TfsUrl": "http://pdtsf01.dev.goldenstand.cn:8080/tfs/DefaultCollection", + "Project": "Tools_Dev", + "WorkItemType": "Product Backlog Item", + "IterationPath": "", + "AreaPath": "Tools_Dev\\DataTechnologies", + "AuthMode": "ntlm", + "Username": "", + "Password": "", + "Domain": "", + "UseSystemProxy": false, + "BypassRules": true, + "SkipExistingTitles": true, + "DefaultState": "To Do", + "FinancialProjectRef": "", + "CompletedRef": "", + "LogDateRef": "", + "LastExcelPath": "", + "ParentPbiId": "" +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..afc4f4b --- /dev/null +++ b/config.json @@ -0,0 +1,36 @@ +{ + "tfs_url": "http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection", + "project": "Tools_Dev", + "team": "DataTechnologies", + "work_item_type": "Task", + "parent_title": "陈首林", + "parent_id": "", + "iteration_path": "2026年Sprint\\2026年Sprint10", + "area_path": "", + "auth": { + "mode": "ntlm", + "username": "chenshoulin", + "password": "Password72", + "domain": "" + }, + "field_refs": { + "title": "System.Title", + "state": "System.State", + "assigned_to": "System.AssignedTo", + "financial_project": "", + "completed": "", + "log_date": "", + "created_date": "System.CreatedDate", + "closed_date": "Microsoft.VSTS.Common.ClosedDate", + "iteration_path": "System.IterationPath", + "area_path": "System.AreaPath" + }, + "resolve_fields_by_display_name": { + "financial_project": "FinancialProject", + "completed": "Completed", + "log_date": "LogDate" + }, + "bypass_rules": true, + "skip_existing_titles": true, + "default_state": "To Do" +} diff --git a/data/work_items.example.csv b/data/work_items.example.csv new file mode 100644 index 0000000..8fc2205 --- /dev/null +++ b/data/work_items.example.csv @@ -0,0 +1,3 @@ +Title,State,AssignedTo,FinancialProject,Completed,LogDate,CreatedDate,ClosedDate,PBI Id,Notes +20261012 - 示例:货载报告解析并上传凭证受托报告相关内容,To Do,张三,信托 - 上海信托,5,2026-10-12,2026-10-30 11:14,,,示例行,导入前请删除 +20261009 示例:受托报告bug修复,Done,李四,信托 - 上海信托,8,2026-10-09,2026-10-10 18:20:00,2026-10-30 18:00:00,219975,PBI Id 列可逐行指定父项,留空则用配置里的 parent_title/parent_id diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eab4b7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28 +requests-ntlm>=1.2 diff --git a/templates/TFS导入模板.xlsx b/templates/TFS导入模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c5610adbd0620f1cc42620c7b60bf4d2657291f9 GIT binary patch literal 4865 zcmd^D&u<$=6n0BnL=ghiA|X!8id$vxu9KF;vRyTHoVZ18M1q6_sM@T@&XV=6wKGW* zPH+fqLjzPCAmvvh67>KihXQFJAx98GLLmMC4qn$e<%qz1@i;lN*jEyF2^7 z_ujnu-kX^nx$w}DlM(iNHuC!~`6t#6MpO9C?Hd_3c6WJi7$=iqJ5%(iE0*9!!l7yoJCkXn?9a&RR{n!aY~#Zq>_*% ztWBV@iepvIzU%(Cr2=uH4r{v>r83OwB*t)eSD;i-#^{jojY|X5~1x;;P+)p z8i^7Bcg^b;{p{(I-)#(}{Prsq@`aRW=L?D`Fj{v`9>$n#gE1_J+H6Se5};YNONgYz zAW>;1NPcf{FsA>MZk~7!;z`@%5YvE$uH)Lwm;HydEiu@jXyw?O4Jdy!H|eawl-UWrIel&{Ti8}X2nO`mA70)53W7=7Gc2Ht zr_G4a%=tfZwpCy}y0*I7Y8nl+pR>&jiXUVKv4Z2d;;U+%ljfm z>y&X-j8gLb_oRUk7Gc%Jf@V#bP#6?3?RKP-_zhuNHK0d`?p^w`-?UX3A zae;!7Eb4~Q(iEqfpd+2EsDvQf%)x`uxMvm4gq;J^D zR=Hvzf(l`%91rz^Re}(P4sVEu$-pKL>NhH@n?$ImFT5*MPv{pIhgKfbfOd8_`-o%+^RZGOeMIbXl?MeVcq zosI9R_vWjcx1FnB*Vo>1-utk2{UauD|Koek++FAGIcIev;Ec=C0iNYCOUQq*V$v3hC!Z&(_zv%o-~bURFR`fJPbC}3DzSE zijq)z6TM0pewc?36VCI5<4S-3^P$5!&x0^_)R%7TZr*U#Hl4W@=lVi@ZQZ$cduMSu zV7;A&Jsu5v_N1ZQ?~%~YvGJ)=b$iuWUI>_B1mo$H$nXF=i`SfmFP+t^VW=z* zwSRu`H1vhp%kiX}{F3{_U_+f?3h=8h5tkFP9EKn6gs1%M126}l=i>R4<3YNi99zR1 zOQyTYch^w54>)xAWQ6~*1vUe|U&yDIpn~9O)Vi3km7t#B7TD9H!`28+k=}+l2o3I`JgD|a%l`* + + + + + + + diff --git a/templates/_parts/_rels/.rels b/templates/_parts/_rels/.rels new file mode 100644 index 0000000..ad3ced5 --- /dev/null +++ b/templates/_parts/_rels/.rels @@ -0,0 +1,4 @@ + + + + diff --git a/templates/_parts/xl/_rels/workbook.xml.rels b/templates/_parts/xl/_rels/workbook.xml.rels new file mode 100644 index 0000000..45a0a46 --- /dev/null +++ b/templates/_parts/xl/_rels/workbook.xml.rels @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/_parts/xl/styles.xml b/templates/_parts/xl/styles.xml new file mode 100644 index 0000000..75ce262 --- /dev/null +++ b/templates/_parts/xl/styles.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/_parts/xl/workbook.xml b/templates/_parts/xl/workbook.xml new file mode 100644 index 0000000..c26af18 --- /dev/null +++ b/templates/_parts/xl/workbook.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/_parts/xl/worksheets/sheet1.xml b/templates/_parts/xl/worksheets/sheet1.xml new file mode 100644 index 0000000..25ce4c8 --- /dev/null +++ b/templates/_parts/xl/worksheets/sheet1.xml @@ -0,0 +1,41 @@ + + + + +Title +State +Assigned To +FinancialProject +Completed +LogDate +Created Date +Closed Date +PBI Id +Notes + + +20261012 - 示例:货载报告解析并上传凭证受托报告相关内容 +To Do +李晓柯 +信托 - 上海信托 +5 +2026-10-12 +2026-10-30 11:14:41 + +219975 +示例行,导入前请删除 + + +20261009 示例:受托报告bug修复 +Done +陈卫宇 +信托 - 上海信托 +8 +2026-10-09 +2026-10-10 18:20:00 +2026-10-30 18:00:00 +219975 +示例行,导入前请删除 + + + diff --git a/templates/build_template.ps1 b/templates/build_template.ps1 new file mode 100644 index 0000000..614e135 --- /dev/null +++ b/templates/build_template.ps1 @@ -0,0 +1,104 @@ +# Build TFS import xlsx from XML parts as a stored (uncompressed) ZIP. +# Pure in-process .NET byte APIs: no external processes, no assembly loading. +# Output file name uses ASCII+char trick to stay encoding-safe. +$ErrorActionPreference = 'Stop' + +$parts = Join-Path $PSScriptRoot '_parts' +$outName = 'TFS' + [char]0x5BFC + [char]0x5165 + [char]0x6A21 + [char]0x677F + '.xlsx' +$out = Join-Path $PSScriptRoot $outName + +# ---------- CRC32 (IEEE 802.3) ---------- +$tbl = New-Object 'System.Int64[]' 256 +for ($i = 0; $i -lt 256; $i++) { + $c = [int64]$i + for ($k = 0; $k -lt 8; $k++) { + if (($c -band 1) -eq 1) { $c = (3988292384 -bxor ($c -shr 1)) } else { $c = ($c -shr 1) } + $c = $c -band 4294967295 + } + $tbl[$i] = $c +} + +function Get-Crc32([byte[]]$data) { + $crc = [int64]4294967295 + foreach ($b in $data) { + $idx = [int](($crc -bxor [int64]$b) -band 255) + $crc = ($tbl[$idx] -bxor ($crc -shr 8)) -band 4294967295 + } + return ($crc -bxor 4294967295) +} + +# ---------- entry list (source relative path, zip entry name) ---------- +$entries = @( + @{ src = '[Content_Types].xml'; zip = '[Content_Types].xml' }, + @{ src = '_rels\.rels'; zip = '_rels/.rels' }, + @{ src = 'xl\workbook.xml'; zip = 'xl/workbook.xml' }, + @{ src = 'xl\_rels\workbook.xml.rels'; zip = 'xl/_rels/workbook.xml.rels' }, + @{ src = 'xl\styles.xml'; zip = 'xl/styles.xml' }, + @{ src = 'xl\worksheets\sheet1.xml'; zip = 'xl/worksheets/sheet1.xml' } +) + +$ms = New-Object System.IO.MemoryStream +$central = New-Object System.Collections.ArrayList + +foreach ($e in $entries) { + $srcPath = Join-Path $parts $e['src'] + $bytes = [System.IO.File]::ReadAllBytes($srcPath) + $nameBytes = [System.Text.Encoding]::ASCII.GetBytes([string]$e['zip']) + $crc = Get-Crc32 $bytes + $offset = $ms.Position + + $ms.Write([BitConverter]::GetBytes([uint32]0x04034b50), 0, 4) # local header signature + $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # version needed + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # flags + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # method: stored + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # mod time + $ms.Write([BitConverter]::GetBytes([uint16]33), 0, 2) # mod date 1980-01-01 + $ms.Write([BitConverter]::GetBytes([uint32]$crc), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint32]$bytes.Length), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint32]$bytes.Length), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint16]$nameBytes.Length), 0, 2) + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # extra len + $ms.Write($nameBytes, 0, $nameBytes.Length) + $ms.Write($bytes, 0, $bytes.Length) + + [void]$central.Add(@{ name = $nameBytes; crc = $crc; size = $bytes.Length; offset = $offset }) +} + +# ---------- central directory ---------- +$cdStart = $ms.Position +foreach ($c in $central) { + $ms.Write([BitConverter]::GetBytes([uint32]0x02014b50), 0, 4) # central header signature + $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # made by + $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # version needed + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # flags + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # method + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # time + $ms.Write([BitConverter]::GetBytes([uint16]33), 0, 2) # date + $ms.Write([BitConverter]::GetBytes([uint32]$c['crc']), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint32]$c['size']), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint32]$c['size']), 0, 4) + $ms.Write([BitConverter]::GetBytes([uint16]$c['name'].Length), 0, 2) + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # extra len + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # comment len + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # disk start + $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # internal attrs + $ms.Write([BitConverter]::GetBytes([uint32]0), 0, 4) # external attrs + $ms.Write([BitConverter]::GetBytes([uint32]$c['offset']), 0, 4) + $ms.Write($c['name'], 0, $c['name'].Length) +} +$cdSize = $ms.Position - $cdStart + +# ---------- end of central directory ---------- +$ms.Write([BitConverter]::GetBytes([uint32]0x06054b50), 0, 4) +$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # disk number +$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # cd disk +$ms.Write([BitConverter]::GetBytes([uint16]$central.Count), 0, 2) +$ms.Write([BitConverter]::GetBytes([uint16]$central.Count), 0, 2) +$ms.Write([BitConverter]::GetBytes([uint32]$cdSize), 0, 4) +$ms.Write([BitConverter]::GetBytes([uint32]$cdStart), 0, 4) +$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # comment len + +[System.IO.File]::WriteAllBytes($out, $ms.ToArray()) +$ms.Dispose() + +Write-Output ('CREATED ' + $out + ' size=' + (New-Object System.IO.FileInfo $out).Length) diff --git a/tfs_import.py b/tfs_import.py new file mode 100644 index 0000000..11b19f2 --- /dev/null +++ b/tfs_import.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +""" +TFS 2013 工作项批量导入工具 + +目标环境(来自截图): + 服务器 : http://pdtsf01.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())