Files
TFSImport/TfsExcelImporter/TfsClient.cs
T
chenshoulin 14f5e32262 feat: 增强数据导入功能与用户界面优化
- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。
- 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。
- 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。
- 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。
- 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
2026-11-02 10:40:52 +08:00

586 lines
28 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)
{
// 认证固定 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();
}
/// <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;
}
// ------------------------------------------------------------- 迭代 / Area
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
public Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
{
return GetClassificationPathsAsync(project, "ProjectLifecycle", "Iteration", "迭代", log);
}
/// <summary>
/// 取项目下全部 Area 完整路径(Tools_Dev\DataTechnologies 形式),按路径排序。
/// Area 与迭代同属分类结构(ICommonStructureService),只是根节点不同:
/// TFS 2013 里 Area 的 StructureType 固定为 ProjectModelHierarchy,迭代是 ProjectLifecycle。
/// </summary>
public Task<List<string>> GetAreaPathsAsync(string project, Action<string> log = null)
{
return GetClassificationPathsAsync(project, "ProjectModelHierarchy", "Area", "Area", log);
}
/// <summary>
/// 通用分类树抓取:按 StructureType 挑根节点(拿不到就用名字模糊匹配兜底),
/// GetNodesXml 深度展开整棵树,再取每个节点的 Path 属性。迭代与 Area 共用这一条通道。
/// </summary>
private async Task<List<string>> GetClassificationPathsAsync(string project, string structureType,
string nameHint, string label, Action<string> log)
{
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 root = structures.FirstOrDefault(n => string.Equals(n.StructureType, structureType, StringComparison.OrdinalIgnoreCase))
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf(nameHint, StringComparison.OrdinalIgnoreCase) >= 0);
if (root == null)
throw new InvalidOperationException($"未找到 {label} 结构(StructureType={structureType})");
// 深度展开全树;Node 元素的 Path 属性为分类节点路径
var tree = css.GetNodesXml(new[] { root.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 => NormalizeNodePath(p, project))
.Where(p => p != null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
log?.Invoke($"{label}列表来源: TFS OM ICommonStructureService({result.Count} 个)");
return result;
}
/// <summary>
/// 分类节点路径归一化为工作项字段值(项目\子...),迭代与 Area 共用。
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
/// </summary>
private static string NormalizeNodePath(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),
/// 因此校验失败时直接把「哪个字段 / 什么原因 / 怎么改」写进异常(见 CheckInvalid),
/// 不做静默降级,避免产出违反模板规则的脏数据。
/// 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, 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;
}
/// <summary>
/// 把 wi.Validate() 的结果整理成「字段(原因) → 怎么改」的单行提示。
/// 单行是硬要求:这段文字会写进 import_report.csv,换行会破坏 CSV 的行结构。
/// </summary>
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();
}
/// <summary>
/// 按字段状态给出修复动作:导入失败时用户拿到的应该是「补哪一列、改成什么」,
/// 而不是只有一句「字段校验失败」。注意校验无法绕过(OM 14.x 无 BypassRules)。
/// </summary>
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 "按服务器返回的规则修正该字段的值";
}
}
/// <summary>列出字段的允许值(列表字段 InvalidList 的主因),最多展开 8 个。</summary>
private static string AllowedHint(Field f)
{
try
{
var vals = new List<string>();
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 ""; }
}
/// <summary>字段类型(String / Integer / DateTime ...),用于提示类型不匹配。</summary>
private static string FieldTypeText(Field f)
{
try { return f.FieldDefinition.FieldType.ToString(); }
catch { return "未知类型"; }
}
/// <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(() =>
{
// SELECT 必须带全循环里要读的字段:OM 对查询未包含的字段会逐条触发完整加载
// (每条一次服务器往返),只 SELECT Id 会让批量修 Area 退化成 N+1
var wiql = "SELECT [System.Id], [System.Title], [System.State], [System.AreaPath] 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();
}
}
}