chore: 初始化 TFS 2013 工作项批量导入工具集
- TfsExcelImporter: WinForms 桌面版,走 TFS Client OM(本环境 wit REST 路由 404,OM 是唯一可行通道) - tfs_import.py: REST 版命令行导入器,供 wit 接口可用的 TFS 实例使用 - templates: Excel 导入模板及其 OOXML 源部件 - README: 两套工具定位、配置、环境坑点与验证方式 - 真实台账与本机工作笔记不入库,仅提交 work_items.example.csv 说明列结构
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
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)
|
||||
{
|
||||
ICredentials cred;
|
||||
var mode = (cfg.AuthMode ?? "ntlm").ToLowerInvariant();
|
||||
if (mode == "default")
|
||||
{
|
||||
cred = CredentialCache.DefaultCredentials;
|
||||
}
|
||||
else
|
||||
{
|
||||
var user = string.IsNullOrEmpty(cfg.Domain) ? cfg.Username : cfg.Domain + "\\" + cfg.Username;
|
||||
cred = new NetworkCredential(user, cfg.Password ?? "");
|
||||
}
|
||||
_tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred);
|
||||
|
||||
// TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。
|
||||
// 默认直连;勾选"使用系统代理"时保留系统代理并带 Windows 凭据过代理认证。
|
||||
if (!cfg.UseSystemProxy)
|
||||
{
|
||||
WebRequest.DefaultWebProxy = new WebProxy(); // 空 WebProxy = 不走代理,直连
|
||||
}
|
||||
else
|
||||
{
|
||||
var p = WebRequest.DefaultWebProxy ?? new WebProxy();
|
||||
p.Credentials = CredentialCache.DefaultCredentials;
|
||||
WebRequest.DefaultWebProxy = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 迭代
|
||||
|
||||
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
|
||||
public async Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
|
||||
{
|
||||
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 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<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 => 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点路径归一化为工作项 IterationPath(项目\子...)。
|
||||
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 查重
|
||||
|
||||
/// <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)——
|
||||
/// 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, bool bypassRules, 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;
|
||||
}
|
||||
|
||||
private static string CheckInvalid(WorkItem wi)
|
||||
{
|
||||
var invalid = wi.Validate();
|
||||
if (invalid.Count == 0) return null;
|
||||
var names = new StringBuilder();
|
||||
foreach (Field f in invalid)
|
||||
names.Append(f.Name).Append("(").Append(FieldStatusText(f)).Append("); ");
|
||||
return names.ToString();
|
||||
}
|
||||
|
||||
/// <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(() =>
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 补挂父项
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user