- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。 - 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。 - 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。 - 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。 - 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
586 lines
28 KiB
C#
586 lines
28 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|