chore: 初始化 TFS 2013 工作项批量导入工具集
- TfsExcelImporter: WinForms 桌面版,走 TFS Client OM(本环境 wit REST 路由 404,OM 是唯一可行通道) - tfs_import.py: REST 版命令行导入器,供 wit 接口可用的 TFS 实例使用 - templates: Excel 导入模板及其 OOXML 源部件 - README: 两套工具定位、配置、环境坑点与验证方式 - 真实台账与本机工作笔记不入库,仅提交 work_items.example.csv 说明列结构
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# xlsx 及其 OOXML 源部件:按字节原样入库,避免 CRLF 转换污染模板
|
||||||
|
*.xlsx binary
|
||||||
|
templates/_parts/** text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
+25
@@ -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
|
||||||
@@ -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` 级别的小批量导入(先删服务器已有同标题项或开启跳过),并在积压工作页展开父项确认子任务可见。
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace TfsExcelImporter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 程序配置。保存于 exe 同目录 config.json,界面修改后自动写回。
|
||||||
|
/// </summary>
|
||||||
|
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<AppConfig>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
|
||||||
|
namespace TfsExcelImporter
|
||||||
|
{
|
||||||
|
/// <summary>一行待导入的工作项数据(列顺序与 Excel 模板一致)。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Excel 读取与模板生成。
|
||||||
|
/// 模板列: Title | State | Assigned To | FinancialProject | Completed | LogDate | Created Date | Closed Date | PBI Id | Notes
|
||||||
|
/// </summary>
|
||||||
|
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 ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- 读取数据
|
||||||
|
|
||||||
|
/// <summary>读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。</summary>
|
||||||
|
public static List<WorkItemRow> ReadRows(string path)
|
||||||
|
{
|
||||||
|
var rows = new List<WorkItemRow>();
|
||||||
|
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<string, int> MapHeaders(IXLWorksheet ws, IXLRange range)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, int>(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<string, int> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- 时间转换
|
||||||
|
|
||||||
|
/// <summary>把单元格文本转成 TFS 接受的 ISO 时间;空值返回 null;解析失败抛异常。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completed 数值:整数去小数点,非法值返回 null。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WorkItemRow> _rows = new List<WorkItemRow>();
|
||||||
|
|
||||||
|
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<string> 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<KeyValuePair<string, object>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, object>(titleRef, row.Title),
|
||||||
|
new KeyValuePair<string, object>(stateRef,
|
||||||
|
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
|
||||||
|
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath)
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
|
||||||
|
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
|
||||||
|
if (!string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||||||
|
fields.Add(new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim()));
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
|
||||||
|
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
|
||||||
|
|
||||||
|
var completed = ExcelService.ToNumber(row.Completed);
|
||||||
|
if (completed != null && compRef != null)
|
||||||
|
fields.Add(new KeyValuePair<string, object>(compRef, completed));
|
||||||
|
|
||||||
|
// Notes -> Description(该 Task 模板必填)
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.Notes))
|
||||||
|
fields.Add(new KeyValuePair<string, object>("System.Description", row.Notes.Trim()));
|
||||||
|
|
||||||
|
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
|
||||||
|
if (logDate != null && logRef != null)
|
||||||
|
fields.Add(new KeyValuePair<string, object>(logRef, logDate));
|
||||||
|
|
||||||
|
// 系统日期字段:bypassRules 时尝试写入
|
||||||
|
if (_chkBypass.Checked)
|
||||||
|
{
|
||||||
|
var created = ExcelService.ToIsoDateTime(row.CreatedDate);
|
||||||
|
if (created != null)
|
||||||
|
fields.Add(new KeyValuePair<string, object>(createdRef, created));
|
||||||
|
var closed = ExcelService.ToIsoDateTime(row.ClosedDate);
|
||||||
|
if (closed != null)
|
||||||
|
fields.Add(new KeyValuePair<string, object>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// TFS 客户端:基于 TFS Client Object Model(内部 SOAP 协议,与 VS/Team Explorer 同款)。
|
||||||
|
/// 适用原因:实测该服务器仅有 /_apis/projects 可用,wit 全部 REST 路由 404(TFS 2013 RTM/低 Update),
|
||||||
|
/// Classification.asmx 的 WSDL 也不可达——裸 REST/SOAP 均走不通,必须走 OM。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。</summary>
|
||||||
|
private WorkItemStore Store
|
||||||
|
{
|
||||||
|
get { return _store ?? (_store = _tpc.GetService<WorkItemStore>()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 连通性
|
||||||
|
|
||||||
|
public async Task<string> TestConnectionAsync()
|
||||||
|
{
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
_tpc.Authenticate();
|
||||||
|
var css = _tpc.GetService<ICommonStructureService>();
|
||||||
|
return $"连接成功(TFS OM/SOAP),可见项目 {css.ListAllProjects().Length} 个";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 字段
|
||||||
|
|
||||||
|
/// <summary>返回显示名 -> 引用名 的不区分大小写字典(来自服务器字段定义)。</summary>
|
||||||
|
public async Task<Dictionary<string, string>> GetFieldMapAsync()
|
||||||
|
{
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (FieldDefinition fd in Store.FieldDefinitions)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(fd.Name) && !map.ContainsKey(fd.Name))
|
||||||
|
map[fd.Name] = fd.ReferenceName;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按显示名解析字段引用名;支持宽松前缀匹配(如 Completed -> Completed Work)。</summary>
|
||||||
|
public static string ResolveRef(Dictionary<string, string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 迭代
|
||||||
|
|
||||||
|
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
|
||||||
|
public async Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
|
||||||
|
{
|
||||||
|
var list = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var css = _tpc.GetService<ICommonStructureService>();
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点路径归一化为工作项 IterationPath(项目\子...)。
|
||||||
|
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 查重
|
||||||
|
|
||||||
|
/// <summary>查询指定迭代下同类型工作项的标题集合。</summary>
|
||||||
|
public async Task<HashSet<string>> 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<string>(StringComparer.Ordinal);
|
||||||
|
foreach (WorkItem wi in Store.Query(wiql))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(wi.Title)) titles.Add(wi.Title);
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 创建
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建工作项(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。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> CreateWorkItemAsync(string project, string workItemType,
|
||||||
|
List<KeyValuePair<string, object>> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>找父子链接类型的 Parent 端(子→父)。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。</summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把指定迭代下、类型匹配、且 AreaPath 不等于目标值的工作项,批量改为目标 Area。
|
||||||
|
/// 用于修复"导入时未设 AreaPath 导致团队视图看不到"的历史数据。
|
||||||
|
/// 关闭状态(如 Task 的 Done)的工作项是只读的,会报 TF400276,
|
||||||
|
/// 此时会走"重新打开 -> 改 Area -> 还原状态"的状态循环。
|
||||||
|
/// 返回 (成功条数, 失败条数)。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Tuple<int, int>> FixAreaAsync(string project, string iterationPath, string workItemType,
|
||||||
|
string areaPath, Action<string> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- 补挂父项
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把指定迭代下、类型匹配、且尚未挂父项的工作项,批量补挂到 parentWorkItemId 下。
|
||||||
|
/// 对应 Python 版 --link-existing:已有父项(Hierarchy-Reverse 链接)的跳过;
|
||||||
|
/// 关闭状态(如 Task 的 Done,TF400276 只读)走"重新打开 -> 挂接 -> 还原状态"循环。
|
||||||
|
/// 返回 (成功条数, 已有父跳过条数, 失败条数)。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Tuple<int, int, int>> LinkExistingAsync(string project, string iterationPath,
|
||||||
|
string workItemType, int parentWorkItemId, Action<string> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>判断工作项是否已有父级链接(System.LinkTypes.Hierarchy-Reverse)。</summary>
|
||||||
|
private static bool HasParentLink(WorkItem wi)
|
||||||
|
{
|
||||||
|
foreach (var link in wi.Links.OfType<RelatedLink>())
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关闭状态的工作项(TF400276)走状态循环执行操作:
|
||||||
|
/// State 改为打开状态并 Save -> 执行 action 并 Save -> State 改回原值并 Save。
|
||||||
|
/// 只使用当前状态合法可迁移到的打开状态(读 AllowedValues),迁移不合法则放弃。
|
||||||
|
/// </summary>
|
||||||
|
private bool TryViaReopen(WorkItem wi, Action action, Action<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关闭状态的工作项(TF400276)走状态循环修 Area。
|
||||||
|
/// </summary>
|
||||||
|
private bool TryFixAreaViaReopen(WorkItem wi, string areaPath, Action<string> log)
|
||||||
|
{
|
||||||
|
return TryViaReopen(wi, () => { wi.Fields["System.AreaPath"].Value = areaPath; }, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按常见命名顺序,从当前状态可迁移到的 AllowedValues 里挑一个"打开"状态。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<AssemblyTitle>TFS Excel 导入工具</AssemblyTitle>
|
||||||
|
<Product>TFS Excel 导入工具</Product>
|
||||||
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Excel 读写 -->
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.102.2" />
|
||||||
|
<!-- JSON (保留给 config.json) -->
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<!-- TFS Client OM(内部 SOAP 协议,兼容 TFS 2013;REST wit 路由缺失时唯一可行通道) -->
|
||||||
|
<PackageReference Include="Microsoft.TeamFoundationServer.ExtendedClient" Version="14.*" />
|
||||||
|
<!-- 无需安装 VS 也可用 dotnet build 编译 net48 -->
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="config.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
+36
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
requests>=2.28
|
||||||
|
requests-ntlm>=1.2
|
||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||||
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||||
|
</Types>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
</Relationships>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||||
|
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
||||||
|
<borders count="1"><border/></borders>
|
||||||
|
<cellStyleXfs count="1"><xf/></cellStyleXfs>
|
||||||
|
<cellXfs count="1"><xf/></cellXfs>
|
||||||
|
</styleSheet>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets>
|
||||||
|
<sheet name="工作项" sheetId="1" r:id="rId1"/>
|
||||||
|
</sheets>
|
||||||
|
</workbook>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<sheetData>
|
||||||
|
<row r="1">
|
||||||
|
<c r="A1" t="inlineStr"><is><t>Title</t></is></c>
|
||||||
|
<c r="B1" t="inlineStr"><is><t>State</t></is></c>
|
||||||
|
<c r="C1" t="inlineStr"><is><t>Assigned To</t></is></c>
|
||||||
|
<c r="D1" t="inlineStr"><is><t>FinancialProject</t></is></c>
|
||||||
|
<c r="E1" t="inlineStr"><is><t>Completed</t></is></c>
|
||||||
|
<c r="F1" t="inlineStr"><is><t>LogDate</t></is></c>
|
||||||
|
<c r="G1" t="inlineStr"><is><t>Created Date</t></is></c>
|
||||||
|
<c r="H1" t="inlineStr"><is><t>Closed Date</t></is></c>
|
||||||
|
<c r="I1" t="inlineStr"><is><t>PBI Id</t></is></c>
|
||||||
|
<c r="J1" t="inlineStr"><is><t>Notes</t></is></c>
|
||||||
|
</row>
|
||||||
|
<row r="2">
|
||||||
|
<c r="A2" t="inlineStr"><is><t>20261012 - 示例:货载报告解析并上传凭证受托报告相关内容</t></is></c>
|
||||||
|
<c r="B2" t="inlineStr"><is><t>To Do</t></is></c>
|
||||||
|
<c r="C2" t="inlineStr"><is><t>李晓柯</t></is></c>
|
||||||
|
<c r="D2" t="inlineStr"><is><t>信托 - 上海信托</t></is></c>
|
||||||
|
<c r="E2" t="inlineStr"><is><t>5</t></is></c>
|
||||||
|
<c r="F2" t="inlineStr"><is><t>2026-10-12</t></is></c>
|
||||||
|
<c r="G2" t="inlineStr"><is><t>2026-10-30 11:14:41</t></is></c>
|
||||||
|
<c r="H2" t="inlineStr"><is><t></t></is></c>
|
||||||
|
<c r="I2" t="inlineStr"><is><t>219975</t></is></c>
|
||||||
|
<c r="J2" t="inlineStr"><is><t>示例行,导入前请删除</t></is></c>
|
||||||
|
</row>
|
||||||
|
<row r="3">
|
||||||
|
<c r="A3" t="inlineStr"><is><t>20261009 示例:受托报告bug修复</t></is></c>
|
||||||
|
<c r="B3" t="inlineStr"><is><t>Done</t></is></c>
|
||||||
|
<c r="C3" t="inlineStr"><is><t>陈卫宇</t></is></c>
|
||||||
|
<c r="D3" t="inlineStr"><is><t>信托 - 上海信托</t></is></c>
|
||||||
|
<c r="E3" t="inlineStr"><is><t>8</t></is></c>
|
||||||
|
<c r="F3" t="inlineStr"><is><t>2026-10-09</t></is></c>
|
||||||
|
<c r="G3" t="inlineStr"><is><t>2026-10-10 18:20:00</t></is></c>
|
||||||
|
<c r="H3" t="inlineStr"><is><t>2026-10-30 18:00:00</t></is></c>
|
||||||
|
<c r="I3" t="inlineStr"><is><t>219975</t></is></c>
|
||||||
|
<c r="J3" t="inlineStr"><is><t>示例行,导入前请删除</t></is></c>
|
||||||
|
</row>
|
||||||
|
</sheetData>
|
||||||
|
</worksheet>
|
||||||
@@ -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)
|
||||||
+467
@@ -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())
|
||||||
Reference in New Issue
Block a user