Files
TFSImport/TfsExcelImporter/TfsClient.cs
T
chenshoulin 8d1af0d11c 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 说明列结构
2026-11-01 11:21:20 +08:00

512 lines
23 KiB
C#
Raw Blame History

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