using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text; using System.Threading.Tasks; using System.Xml; using Microsoft.TeamFoundation.Client; using Microsoft.TeamFoundation.Server; using Microsoft.TeamFoundation.WorkItemTracking.Client; namespace TfsExcelImporter { /// /// TFS 客户端:基于 TFS Client Object Model(内部 SOAP 协议,与 VS/Team Explorer 同款)。 /// 适用原因:实测该服务器仅有 /_apis/projects 可用,wit 全部 REST 路由 404(TFS 2013 RTM/低 Update), /// Classification.asmx 的 WSDL 也不可达——裸 REST/SOAP 均走不通,必须走 OM。 /// public class TfsClient : IDisposable { private readonly TfsTeamProjectCollection _tpc; private WorkItemStore _store; public TfsClient(AppConfig cfg) { // 认证固定 NTLM:用户名可写 user(用当前登录域),也可写 DOMAIN\user 显式指定域。 var user = (cfg.Username ?? "").Trim(); var pwd = cfg.Password ?? ""; var sep = user.IndexOf('\\'); ICredentials cred = sep > 0 ? new NetworkCredential(user.Substring(sep + 1), pwd, user.Substring(0, sep)) : new NetworkCredential(user, pwd); _tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred); // TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。 // 因此固定直连:把 DefaultWebProxy 置空,不读取本机系统代理设置。 WebRequest.DefaultWebProxy = new WebProxy(); } /// WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。 private WorkItemStore Store { get { return _store ?? (_store = _tpc.GetService()); } } // ------------------------------------------------------------- 连通性 public async Task TestConnectionAsync() { return await Task.Run(() => { _tpc.Authenticate(); var css = _tpc.GetService(); return $"连接成功(TFS OM/SOAP),可见项目 {css.ListAllProjects().Length} 个"; }); } // ------------------------------------------------------------- 字段 /// 返回显示名 -> 引用名 的不区分大小写字典(来自服务器字段定义)。 public async Task> GetFieldMapAsync() { return await Task.Run(() => { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (FieldDefinition fd in Store.FieldDefinitions) { if (!string.IsNullOrEmpty(fd.Name) && !map.ContainsKey(fd.Name)) map[fd.Name] = fd.ReferenceName; } return map; }); } /// 按显示名解析字段引用名;支持宽松前缀匹配(如 Completed -> Completed Work)。 public static string ResolveRef(Dictionary map, string displayName, string explicitRef) { if (!string.IsNullOrEmpty(explicitRef)) return explicitRef; if (string.IsNullOrEmpty(displayName)) return null; string exact; if (map.TryGetValue(displayName.Trim(), out exact)) return exact; var prefix = displayName.Trim(); foreach (var kv in map) { if (kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return kv.Value; } return null; } // ------------------------------------------------------------- 迭代 /// 取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。 public async Task> GetIterationPathsAsync(string project, Action log = null) { var list = await Task.Run(() => { var css = _tpc.GetService(); var proj = css.GetProjectFromName(project); if (proj == null) throw new InvalidOperationException("项目不存在或无权访问: " + project); var structures = css.ListStructures(proj.Uri); var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle") ?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0); if (iterations == null) throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构"); // GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径 var tree = css.GetNodesXml(new[] { iterations.Uri }, true); var raw = new List(); if (tree != null) { foreach (XmlNode n in tree.SelectNodes("//*[@Path]")) { var p = n.Attributes != null && n.Attributes["Path"] != null ? n.Attributes["Path"].Value : null; if (!string.IsNullOrEmpty(p)) raw.Add(p); } } return raw; }); var result = list.Select(p => NormalizeIterationPath(p, project)) .Where(p => p != null) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToList(); log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)"); return result; } /// /// 节点路径归一化为工作项 IterationPath(项目\子...)。 /// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。 /// private static string NormalizeIterationPath(string nodePath, string project) { if (string.IsNullOrWhiteSpace(nodePath)) return null; var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); if (segs.Count == 0) return null; if (string.Equals(segs[0], project, StringComparison.OrdinalIgnoreCase)) segs.RemoveAt(0); if (segs.Count > 0 && (segs[0].Equals("Iteration", StringComparison.OrdinalIgnoreCase) || segs[0].Equals("Iterations", StringComparison.OrdinalIgnoreCase) || segs[0].Equals("Area", StringComparison.OrdinalIgnoreCase) || segs[0].Equals("Areas", StringComparison.OrdinalIgnoreCase))) segs.RemoveAt(0); if (segs.Count == 0) return null; return project + "\\" + string.Join("\\", segs); } // ------------------------------------------------------------- 查重 /// 查询指定迭代下同类型工作项的标题集合。 public async Task> GetExistingTitlesAsync(string project, string iterationPath, string workItemType) { return await Task.Run(() => { var wiql = "SELECT [System.Title] FROM WorkItems WHERE " + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}'"; var titles = new HashSet(StringComparer.Ordinal); foreach (WorkItem wi in Store.Query(wiql)) { if (!string.IsNullOrEmpty(wi.Title)) titles.Add(wi.Title); } return titles; }); } // ------------------------------------------------------------- 创建 /// /// 创建工作项(OM Save)。返回新工作项 ID。 /// 说明:字段校验不可绕过——OM 14.x 的 SaveFlags 无 BypassRules(15.x 才加入,且 15.x 不兼容 TFS 2013), /// 因此校验失败时直接把「哪个字段 / 什么原因 / 怎么改」写进异常(见 CheckInvalid), /// 不做静默降级,避免产出违反模板规则的脏数据。 /// System.CreatedDate 为服务器管理的只读字段,无法写入(取导入时间); /// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入, /// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。 /// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。 /// public async Task CreateWorkItemAsync(string project, string workItemType, List> fields, int? parentPbiId = null) { return await Task.Run(() => { var wi = new WorkItem(Store.Projects[project].WorkItemTypes[workItemType]); var closedSet = false; foreach (var kv in fields) { if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) continue; if (kv.Key == "System.CreatedDate") continue; // 服务器管理字段,OM14 无法写入 if (kv.Key == "Microsoft.VSTS.Common.ClosedDate") { try { wi.Fields[kv.Key].Value = kv.Value; closedSet = true; } catch { /* 环境不允许设置,跳过 */ } continue; } wi.Fields[kv.Key].Value = kv.Value; } // 父 PBI 链接(与建项同一 Save 原子完成;PBI 不存在等错误会整行报 FAIL) if (parentPbiId.HasValue) { var parentEnd = FindParentLinkEnd(); if (parentEnd == null) throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy"); wi.Links.Add(new RelatedLink(parentEnd, parentPbiId.Value)); } var bad = CheckInvalid(wi); if (bad != null && closedSet && bad.IndexOf("ClosedDate", StringComparison.OrdinalIgnoreCase) >= 0) { ClearClosedDate(wi); closedSet = false; bad = CheckInvalid(wi); } if (bad != null) throw new InvalidOperationException("字段校验失败: " + bad); try { wi.Save(); } catch (Exception ex) { if (!closedSet) throw; // 服务器端拒绝(如 ClosedDate 只读规则):清空降级为普通保存 ClearClosedDate(wi); try { wi.Save(); closedSet = false; } catch (Exception ex2) { throw new InvalidOperationException("保存失败: " + ex.Message + (ex2 == null ? "" : " / 降级重试: " + ex2.Message)); } } return wi.Id; }); } /// 找父子链接类型的 Parent 端(子→父)。 private WorkItemLinkTypeEnd FindParentLinkEnd() { foreach (WorkItemLinkType lt in Store.WorkItemLinkTypes) { if (string.Equals(lt.ReferenceName, "System.LinkTypes.Hierarchy", StringComparison.OrdinalIgnoreCase)) return lt.ReverseEnd; } return null; } /// /// 把 wi.Validate() 的结果整理成「字段(原因) → 怎么改」的单行提示。 /// 单行是硬要求:这段文字会写进 import_report.csv,换行会破坏 CSV 的行结构。 /// private static string CheckInvalid(WorkItem wi) { var invalid = wi.Validate(); if (invalid.Count == 0) return null; var sb = new StringBuilder(); foreach (Field f in invalid) { if (sb.Length > 0) sb.Append(";"); sb.Append(f.Name).Append('(').Append(FieldStatusText(f)).Append(") → ") .Append(FixHint(f)); } return sb.ToString(); } /// /// 按字段状态给出修复动作:导入失败时用户拿到的应该是「补哪一列、改成什么」, /// 而不是只有一句「字段校验失败」。注意校验无法绕过(OM 14.x 无 BypassRules)。 /// private static string FixHint(Field f) { switch (f.Status.ToString()) { case "InvalidEmpty": case "InvalidNull": return "必填但没值:Excel 补上这一列,并确认字段映射是否对上" + AllowedHint(f); case "InvalidTooLong": return "值超长:截断到上限内(TFS 字符串字段默认上限 255 字符)"; case "InvalidType": return "类型不匹配,该字段是 " + FieldTypeText(f) + ":检查单元格里是文本还是数字/日期"; case "InvalidValue": case "InvalidList": return "值不在允许范围" + AllowedHint(f); case "InvalidFormat": return "格式不对:日期写 yyyy-MM-dd,数字不要带单位或千分位"; case "InvalidComputed": return "计算字段不可写:把这一列从 Excel 去掉"; case "InvalidNotNew": return "只在新建时可设置:不要对已存在工作项改这一列"; default: return "按服务器返回的规则修正该字段的值"; } } /// 列出字段的允许值(列表字段 InvalidList 的主因),最多展开 8 个。 private static string AllowedHint(Field f) { try { var vals = new List(); int total = 0; foreach (object v in f.AllowedValues) { total++; if (vals.Count < 8) vals.Add(Convert.ToString(v)); } if (vals.Count == 0) return ""; return "(允许值: " + string.Join(" / ", vals.ToArray()) + (total > vals.Count ? " 等 " + total + " 项" : "") + ")"; } catch { return ""; } } /// 字段类型(String / Integer / DateTime ...),用于提示类型不匹配。 private static string FieldTypeText(Field f) { try { return f.FieldDefinition.FieldType.ToString(); } catch { return "未知类型"; } } /// 把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。 private static string FieldStatusText(Field f) { switch (f.Status.ToString()) { case "InvalidEmpty": return "必填字段为空"; case "InvalidTooLong": return "值超过最大长度"; case "InvalidType": return "值类型不匹配"; case "InvalidValue": return "值不在允许范围内"; case "InvalidFormat": return "值格式不正确"; case "InvalidList": return "值不在允许列表中"; case "InvalidNull": return "值不能为空"; case "InvalidComputed": return "计算字段不可直接修改"; case "InvalidNotNew": return "仅新建时可设置"; case "Valid": return "有效"; default: return "状态=" + f.Status; } } private static void ClearClosedDate(WorkItem wi) { try { wi.Fields["Microsoft.VSTS.Common.ClosedDate"].Value = null; } catch { } } /// /// 把指定迭代下、类型匹配、且 AreaPath 不等于目标值的工作项,批量改为目标 Area。 /// 用于修复"导入时未设 AreaPath 导致团队视图看不到"的历史数据。 /// 关闭状态(如 Task 的 Done)的工作项是只读的,会报 TF400276, /// 此时会走"重新打开 -> 改 Area -> 还原状态"的状态循环。 /// 返回 (成功条数, 失败条数)。 /// public async Task> FixAreaAsync(string project, string iterationPath, string workItemType, string areaPath, Action log) { return await Task.Run(() => { var wiql = "SELECT [System.Id] FROM WorkItems WHERE " + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " + $"[System.AreaPath] <> '{EscapeWiql(areaPath)}'"; var ok = 0; var fail = 0; foreach (WorkItem wi in Store.Query(wiql)) { var state = Convert.ToString(wi.Fields["System.State"].Value); try { wi.Fields["System.AreaPath"].Value = areaPath; wi.Save(); ok++; log?.Invoke($" #{wi.Id} [{state}] {wi.Title} Area -> {areaPath}"); } catch (Exception ex) { var msg = ex.Message ?? ""; if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开")) { if (TryFixAreaViaReopen(wi, areaPath, log)) { ok++; continue; } } fail++; log?.Invoke($" #{wi.Id} [{state}] {wi.Title} 修改失败: {msg}"); } } return Tuple.Create(ok, fail); }); } // ------------------------------------------------------------- 补挂父项 /// /// 把指定迭代下、类型匹配、且尚未挂父项的工作项,批量补挂到 parentWorkItemId 下。 /// 对应 Python 版 --link-existing:已有父项(Hierarchy-Reverse 链接)的跳过; /// 关闭状态(如 Task 的 Done,TF400276 只读)走"重新打开 -> 挂接 -> 还原状态"循环。 /// 返回 (成功条数, 已有父跳过条数, 失败条数)。 /// public async Task> LinkExistingAsync(string project, string iterationPath, string workItemType, int parentWorkItemId, Action log) { return await Task.Run(() => { var parentEnd = FindParentLinkEnd(); if (parentEnd == null) throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy"); var wiql = "SELECT [System.Id] FROM WorkItems WHERE " + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}'"; var ok = 0; var skip = 0; var fail = 0; foreach (WorkItem wi in Store.Query(wiql)) { try { if (HasParentLink(wi)) { skip++; log?.Invoke($" #{wi.Id} {wi.Title} 已有父项,跳过"); continue; } wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId)); wi.Save(); ok++; log?.Invoke($" #{wi.Id} {wi.Title} -> 父项 #{parentWorkItemId}"); } catch (Exception ex) { var msg = ex.Message ?? ""; if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开")) { if (TryViaReopen(wi, () => { // Save 失败时挂起的链接仍在 Links 中,先查重避免重复添加 if (!HasParentLink(wi)) wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId)); }, log)) { ok++; continue; } } fail++; log?.Invoke($" #{wi.Id} {wi.Title} 挂接失败: {msg}"); } } return Tuple.Create(ok, skip, fail); }); } /// 判断工作项是否已有父级链接(System.LinkTypes.Hierarchy-Reverse)。 private static bool HasParentLink(WorkItem wi) { foreach (var link in wi.Links.OfType()) { WorkItemLinkTypeEnd end = null; try { end = link.LinkTypeEnd; } catch { } if (end == null) continue; // OM 14.x 的 WorkItemLinkTypeEnd 无 ReferenceName:用 ImmutableName(Hierarchy-Reverse) // 或 Name(Parent) 判断;两者都兜底,防不同版本元数据差异 var immutable = end.ImmutableName ?? ""; var name = end.Name ?? ""; if (immutable.IndexOf("Hierarchy-Reverse", StringComparison.OrdinalIgnoreCase) >= 0 || string.Equals(name, "Parent", StringComparison.OrdinalIgnoreCase)) return true; } return false; } /// /// 关闭状态的工作项(TF400276)走状态循环执行操作: /// State 改为打开状态并 Save -> 执行 action 并 Save -> State 改回原值并 Save。 /// 只使用当前状态合法可迁移到的打开状态(读 AllowedValues),迁移不合法则放弃。 /// private bool TryViaReopen(WorkItem wi, Action action, Action log) { var stateField = wi.Fields["System.State"]; var origState = Convert.ToString(stateField.Value); var openState = PickOpenState(stateField); if (openState == null) { log?.Invoke($" #{wi.Id} {wi.Title} 处于关闭状态[{origState}]且没有合法的重新打开状态,跳过"); return false; } try { log?.Invoke($" #{wi.Id} {wi.Title} 已关闭[{origState}],走状态循环: {origState} -> {openState} -> 执行操作 -> {origState}"); stateField.Value = openState; wi.Save(); action(); stateField.Value = origState; wi.Save(); log?.Invoke($" #{wi.Id} 操作完成(已还原状态为 {origState})"); return true; } catch (Exception ex) { // 中途失败时把状态改回去,避免工作项停留在中间状态 try { var cur = Convert.ToString(wi.Fields["System.State"].Value); if (cur != origState) { wi.Fields["System.State"].Value = origState; wi.Save(); log?.Invoke($" #{wi.Id} 已回滚状态为 {origState}"); } } catch { /* 回滚失败也只能记录 */ } log?.Invoke($" #{wi.Id} {wi.Title} 状态循环失败: {ex.Message}"); return false; } } /// /// 关闭状态的工作项(TF400276)走状态循环修 Area。 /// private bool TryFixAreaViaReopen(WorkItem wi, string areaPath, Action log) { return TryViaReopen(wi, () => { wi.Fields["System.AreaPath"].Value = areaPath; }, log); } /// 按常见命名顺序,从当前状态可迁移到的 AllowedValues 里挑一个"打开"状态。 private static string PickOpenState(Field stateField) { var candidates = new[] { "To Do", "New", "Active", "In Progress", "Proposed", "Approved", "Committed", "Open", "Design", "Development", "InProgress" }; foreach (var c in candidates) foreach (var v in stateField.AllowedValues) if (string.Equals(Convert.ToString(v), c, StringComparison.OrdinalIgnoreCase)) return Convert.ToString(v); return null; } // ------------------------------------------------------------- 工具 private static string EscapeWiql(string s) { return (s ?? "").Replace("'", "''"); } public void Dispose() { _tpc.Dispose(); } } }