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;
}
// ------------------------------------------------------------- 迭代 / Area
/// 取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。
public Task> GetIterationPathsAsync(string project, Action log = null)
{
return GetClassificationPathsAsync(project, "ProjectLifecycle", "Iteration", "迭代", log);
}
///
/// 取项目下全部 Area 完整路径(Tools_Dev\DataTechnologies 形式),按路径排序。
/// Area 与迭代同属分类结构(ICommonStructureService),只是根节点不同:
/// TFS 2013 里 Area 的 StructureType 固定为 ProjectModelHierarchy,迭代是 ProjectLifecycle。
///
public Task> GetAreaPathsAsync(string project, Action log = null)
{
return GetClassificationPathsAsync(project, "ProjectModelHierarchy", "Area", "Area", log);
}
///
/// 通用分类树抓取:按 StructureType 挑根节点(拿不到就用名字模糊匹配兜底),
/// GetNodesXml 深度展开整棵树,再取每个节点的 Path 属性。迭代与 Area 共用这一条通道。
///
private async Task> GetClassificationPathsAsync(string project, string structureType,
string nameHint, string label, Action log)
{
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 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();
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;
}
///
/// 分类节点路径归一化为工作项字段值(项目\子...),迭代与 Area 共用。
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
///
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)
/// 下拉用的轻量工作项快照:只要 Id / 标题 / 类型 / 状态 / 迭代。
public class WorkItemBrief
{
public int Id;
public string Title;
public string Type;
public string State;
public string IterationPath;
///
/// 列表里给人看的文本:Id 打头,后面是标题;状态只在「非 New」时追加。
/// 新建未审批的 PBI 全是 New,逐条显示纯属噪音——真正要看的是 Done/Closed 这类
/// 挂上去也展不开的父项,让异常状态自己显形就够了。
///
public override string ToString()
{
var tail = string.IsNullOrWhiteSpace(State)
|| string.Equals(State, "New", StringComparison.OrdinalIgnoreCase)
? "" : " · " + State;
return "#" + Id + " " + (Title ?? "") + tail;
}
}
///
/// 查可当父项用的工作项(默认 Product Backlog Item)。
/// 先按目标迭代过滤;该迭代下一条都没有时放宽到全项目——迭代里还没建 PBI 是常态,
/// 直接返回空列表会让人以为「接口没通」。
///
public async Task> GetBacklogItemsAsync(string project, string iterationPath,
Action 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;
}
///
/// WIQL 取工作项快照。workItemType 为空时按 excludeType 反向过滤(排除导入类型本身)。
/// iterationPath 为空时不限迭代。
///
private async Task> 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();
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;
});
}
// ------------------------------------------------------------- 查重
/// 查询指定迭代下同类型工作项的标题集合。
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(() =>
{
// 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);
});
}
// ------------------------------------------------------------- 补挂父项
///
/// 把指定迭代下、类型匹配、且尚未挂父项的工作项,批量补挂到 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();
}
}
}