Files
TFSImport/TfsExcelImporter/TfsClient.cs
T
chenshoulin fe23e1c64a feat: 增加父 PBI 列表加载功能与界面优化
- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。
- 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。
- 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。
- 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。
- 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
2026-11-02 13:07:58 +08:00

677 lines
32 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);
}
// ------------------------------------------------------------- 父项(backlog item)
/// <summary>下拉用的轻量工作项快照:只要 Id / 标题 / 类型 / 状态 / 迭代。</summary>
public class WorkItemBrief
{
public int Id;
public string Title;
public string Type;
public string State;
public string IterationPath;
/// <summary>
/// 列表里给人看的文本:Id 打头,后面是标题;状态只在「非 New」时追加。
/// 新建未审批的 PBI 全是 New,逐条显示纯属噪音——真正要看的是 Done/Closed 这类
/// 挂上去也展不开的父项,让异常状态自己显形就够了。
/// </summary>
public override string ToString()
{
var tail = string.IsNullOrWhiteSpace(State)
|| string.Equals(State, "New", StringComparison.OrdinalIgnoreCase)
? "" : " · " + State;
return "#" + Id + " " + (Title ?? "") + tail;
}
}
/// <summary>
/// 查可当父项用的工作项(默认 Product Backlog Item)。
/// 先按目标迭代过滤;该迭代下一条都没有时放宽到全项目——迭代里还没建 PBI 是常态,
/// 直接返回空列表会让人以为「接口没通」。
/// </summary>
public async Task<List<WorkItemBrief>> GetBacklogItemsAsync(string project, string iterationPath,
Action<string> log = null)
{
var scoped = await QueryWorkItemsAsync(project, iterationPath, "Product Backlog Item");
if (scoped.Count > 0)
{
log?.Invoke($"父项候选: 迭代「{iterationPath}」下的 Product Backlog Item {scoped.Count} 条");
return scoped;
}
log?.Invoke("父项候选: 该迭代下没有 Product Backlog Item,放宽到全项目");
var all = await QueryWorkItemsAsync(project, null, "Product Backlog Item");
if (all.Count > 0)
{
log?.Invoke($"父项候选: 全项目 Product Backlog Item {all.Count} 条");
return all;
}
// 非敏捷模板(CMMI/Scrum 的老项目)没有 PBI 类型:退化为「除 Task 外的所有工作项」
var fallback = await QueryWorkItemsAsync(project, iterationPath, null, excludeType: "Task");
log?.Invoke($"父项候选: 未找到 Product Backlog Item,退回「非 Task 工作项」{fallback.Count} 条");
return fallback;
}
/// <summary>
/// WIQL 取工作项快照。workItemType 为空时按 excludeType 反向过滤(排除导入类型本身)。
/// iterationPath 为空时不限迭代。
/// </summary>
private async Task<List<WorkItemBrief>> QueryWorkItemsAsync(string project, string iterationPath,
string workItemType, string excludeType = null)
{
return await Task.Run(() =>
{
var wiql = new StringBuilder(
"SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State], [System.IterationPath] " +
"FROM WorkItems WHERE ");
wiql.Append($"[System.TeamProject] = '{EscapeWiql(project)}'");
if (!string.IsNullOrWhiteSpace(iterationPath))
wiql.Append($" AND [System.IterationPath] = '{EscapeWiql(iterationPath)}'");
if (!string.IsNullOrWhiteSpace(workItemType))
wiql.Append($" AND [System.WorkItemType] = '{EscapeWiql(workItemType)}'");
else if (!string.IsNullOrWhiteSpace(excludeType))
wiql.Append($" AND [System.WorkItemType] <> '{EscapeWiql(excludeType)}'");
wiql.Append(" ORDER BY [System.Id]");
var list = new List<WorkItemBrief>();
foreach (WorkItem wi in Store.Query(wiql.ToString()))
{
list.Add(new WorkItemBrief
{
Id = wi.Id,
Title = wi.Title,
Type = Convert.ToString(wi.Fields["System.WorkItemType"].Value),
State = Convert.ToString(wi.Fields["System.State"].Value),
IterationPath = Convert.ToString(wi.Fields["System.IterationPath"].Value)
});
}
return list;
});
}
// ------------------------------------------------------------- 查重
/// <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();
}
}
}