Skip to content

Commit

Permalink
Merge pull request #1032 from solidify/feature/fix-for-conflicting-cl…
Browse files Browse the repository at this point in the history
…assification-node-names

Classification nodes with conflicting names in ADO are now added with a revision number
  • Loading branch information
Alexander-Hjelm authored Apr 21, 2024
2 parents 535b3c8 + 38315e0 commit 426218c
Showing 1 changed file with 87 additions and 27 deletions.
114 changes: 87 additions & 27 deletions src/WorkItemMigrator/WorkItemImport/Agent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.TeamFoundation.Core.WebApi;
using Microsoft.TeamFoundation.Common;
using Microsoft.TeamFoundation.Core.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.Operations;
Expand All @@ -13,7 +14,6 @@
using System.Threading.Tasks;
using VsWebApi = Microsoft.VisualStudio.Services.WebApi;
using WebApi = Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using WebModel = Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;

namespace WorkItemImport
{
Expand All @@ -26,6 +26,8 @@ public class Agent
public int RootIteration { get; private set; }
public Dictionary<string, int> AreaCache { get; private set; } = new Dictionary<string, int>();
public int RootArea { get; private set; }
private readonly Dictionary<string, string> _iterationPathMap = new Dictionary<string, string>();
private readonly Dictionary<string, string> _areaPathMap = new Dictionary<string, string>();

private WitClientUtils _witClientUtils;
private WebApi.WorkItemTrackingHttpClient _wiClient;
Expand Down Expand Up @@ -221,7 +223,7 @@ internal static Agent Initialize(MigrationContext context, Settings settings)
return null;
}

(var iterationCache, int rootIteration) = agent.CreateClasificationCacheAsync(settings.Project, WebModel.TreeStructureGroup.Iterations).Result;
(var iterationCache, int rootIteration) = agent.CreateClasificationCacheAsync(settings.Project, TreeStructureGroup.Iterations).Result;
if (iterationCache == null)
{
Logger.Log(LogLevel.Critical, "Could not build iteration cache.");
Expand All @@ -231,7 +233,7 @@ internal static Agent Initialize(MigrationContext context, Settings settings)
agent.IterationCache = iterationCache;
agent.RootIteration = rootIteration;

(var areaCache, int rootArea) = agent.CreateClasificationCacheAsync(settings.Project, WebModel.TreeStructureGroup.Areas).Result;
(var areaCache, int rootArea) = agent.CreateClasificationCacheAsync(settings.Project, TreeStructureGroup.Areas).Result;
if (areaCache == null)
{
Logger.Log(LogLevel.Critical, "Could not build area cache.");
Expand Down Expand Up @@ -394,12 +396,12 @@ private async Task<Operation> WaitForLongRunningOperation(Guid operationId, int
}
}

private async Task<(Dictionary<string, int>, int)> CreateClasificationCacheAsync(string project, WebModel.TreeStructureGroup structureGroup)
private async Task<(Dictionary<string, int>, int)> CreateClasificationCacheAsync(string project, TreeStructureGroup structureGroup)
{
try
{
Logger.Log(LogLevel.Info, $"Building {(structureGroup == WebModel.TreeStructureGroup.Iterations ? "iteration" : "area")} cache...");
WebModel.WorkItemClassificationNode all = await WiClient.GetClassificationNodeAsync(project, structureGroup, null, 1000);
Logger.Log(LogLevel.Info, $"Building {(structureGroup == TreeStructureGroup.Iterations ? "iteration" : "area")} cache...");
WorkItemClassificationNode all = await WiClient.GetClassificationNodeAsync(project, structureGroup, null, 1000);

var clasificationCache = new Dictionary<string, int>();

Expand All @@ -413,68 +415,126 @@ private async Task<Operation> WaitForLongRunningOperation(Guid operationId, int
}
catch (Exception ex)
{
Logger.Log(ex, $"Error while building {(structureGroup == WebModel.TreeStructureGroup.Iterations ? "iteration" : "area")} cache.");
Logger.Log(ex, $"Error while building {(structureGroup == TreeStructureGroup.Iterations ? "iteration" : "area")} cache.");
return (null, -1);
}
}

private void CreateClasificationCacheRec(WebModel.WorkItemClassificationNode current, Dictionary<string, int> agg, string parentPath)
private void CreateClasificationCacheRec(WorkItemClassificationNode current, Dictionary<string, int> agg, string parentPath)
{
string fullName = !string.IsNullOrWhiteSpace(parentPath) ? parentPath + "/" + current.Name : current.Name;

agg.Add(fullName, current.Id);
Logger.Log(LogLevel.Debug, $"{(current.StructureType == WebModel.TreeNodeStructureType.Iteration ? "Iteration" : "Area")} '{fullName}' added to cache");
Logger.Log(LogLevel.Debug, $"{(current.StructureType == TreeNodeStructureType.Iteration ? "Iteration" : "Area")} '{fullName}' added to cache");
if (current.Children != null)
{
foreach (var node in current.Children)
CreateClasificationCacheRec(node, agg, fullName);
}
}

public int? EnsureClasification(string fullName, WebModel.TreeStructureGroup structureGroup = WebModel.TreeStructureGroup.Iterations)
public string EnsureClasification(
string fullName,
TreeStructureGroup structureGroup = TreeStructureGroup.Iterations
)
{
if (string.IsNullOrWhiteSpace(fullName))
{
Logger.Log(LogLevel.Error, "Empty value provided for node name/path.");
throw new ArgumentException("fullName");
}

var path = fullName.Split('/');
var name = path[path.Length - 1];
var parent = string.Join("/", path.Take(path.Length - 1));
var pathSplit = fullName.Split('/');
var name = pathSplit[pathSplit.Length - 1];
var parent = string.Join("/", pathSplit.Take(pathSplit.Length - 1));

string nameMapped = "";
string fullNameMapped = "";

if (structureGroup == TreeStructureGroup.Iterations)
{
nameMapped = GetMappedClassificationNodePath(_iterationPathMap, name);
fullNameMapped = parent.IsNullOrEmpty() ? nameMapped : $"{parent}/{nameMapped}";
}
else if (structureGroup == TreeStructureGroup.Areas)
{
nameMapped = GetMappedClassificationNodePath(_areaPathMap, name);
fullNameMapped = parent.IsNullOrEmpty() ? nameMapped : $"{parent}/{nameMapped}";
}
else
{
Logger.Log(LogLevel.Error, $"Invalid tree structure group: {structureGroup}");
}

if (!string.IsNullOrEmpty(parent))
EnsureClasification(parent, structureGroup);

var cache = structureGroup == WebModel.TreeStructureGroup.Iterations ? IterationCache : AreaCache;
var cache = structureGroup == TreeStructureGroup.Iterations ? IterationCache : AreaCache;

lock (cache)
{
if (cache.TryGetValue(fullName, out int id))
return id;
if (cache.TryGetValue(fullNameMapped, out int id))
return fullNameMapped;

WebModel.WorkItemClassificationNode node = null;
WorkItemClassificationNode node = null;

try
{
node = WiClient.CreateOrUpdateClassificationNodeAsync(
new WebModel.WorkItemClassificationNode() { Name = name, }, Settings.Project, structureGroup, parent).Result;
new WorkItemClassificationNode() { Name = nameMapped, }, Settings.Project, structureGroup, parent).Result;
}
catch (Exception ex)
{
Logger.Log(ex, $"Error while adding {(structureGroup == WebModel.TreeStructureGroup.Iterations ? "iteration" : "area")} '{fullName}' to Azure DevOps/TFS.", LogLevel.Critical);
Logger.Log(ex, $"Error while adding {(structureGroup == TreeStructureGroup.Iterations ? "iteration" : "area")} '{fullNameMapped}' to Azure DevOps/TFS.", LogLevel.Warning);
}

if (node != null)
{
Logger.Log(LogLevel.Debug, $"{(structureGroup == WebModel.TreeStructureGroup.Iterations ? "Iteration" : "Area")} '{fullName}' added to Azure DevOps/TFS.");
cache.Add(fullName, node.Id);
return node.Id;
Logger.Log(LogLevel.Debug, $"{(structureGroup == TreeStructureGroup.Iterations ? "Iteration" : "Area")} '{fullNameMapped}' added to Azure DevOps/TFS.");
cache.Add(fullNameMapped, node.Id);
return fullNameMapped;
}
}
return null;
}

// Ensure that classification nodes with conflicting names in ADO are migrated with unique names.
// ADO Classification nodes are case insensitive
private string GetMappedClassificationNodePath(Dictionary<string, string> dictionary, string name)
{
if (!dictionary.ContainsKey(name))
{
string nameUpdated = name;
bool newSprintNameInIterationPathCaseInvariant = false;
int suffix = 0;
while (!newSprintNameInIterationPathCaseInvariant)
{
if (!DictionaryContainsValueCaseInvariant(dictionary, nameUpdated))
{
newSprintNameInIterationPathCaseInvariant = true;
dictionary[name] = nameUpdated;
}
suffix += 1;
nameUpdated = $"{name}-{suffix}";
}
}
name = dictionary[name];
return name;
}

private bool DictionaryContainsValueCaseInvariant(Dictionary<string, string> dictionary, string name)
{
foreach (var value in dictionary.Values)
{
if (value.ToLower() == name.ToLower())
{
return true;
}
}
return false;
}


#endregion

#region Import Revision
Expand Down Expand Up @@ -516,8 +576,8 @@ private bool UpdateWIFields(IEnumerable<WiField> fields, WorkItem wi)

if (!string.IsNullOrWhiteSpace(iterationPath))
{
EnsureClasification(iterationPath, WebModel.TreeStructureGroup.Iterations);
wi.Fields[WiFieldReference.IterationPath] = $@"{Settings.Project}\{iterationPath}".Replace("/", @"\");
string iterationPathMapped = EnsureClasification(iterationPath, TreeStructureGroup.Iterations);
wi.Fields[WiFieldReference.IterationPath] = $@"{Settings.Project}\{iterationPathMapped}".Replace("/", @"\");
}
else
{
Expand All @@ -540,8 +600,8 @@ private bool UpdateWIFields(IEnumerable<WiField> fields, WorkItem wi)

if (!string.IsNullOrWhiteSpace(areaPath))
{
EnsureClasification(areaPath, WebModel.TreeStructureGroup.Areas);
wi.Fields[WiFieldReference.AreaPath] = $@"{Settings.Project}\{areaPath}".Replace("/", @"\");
string areaPathMapped = EnsureClasification(areaPath, TreeStructureGroup.Areas);
wi.Fields[WiFieldReference.AreaPath] = $@"{Settings.Project}\{areaPathMapped}".Replace("/", @"\");
}
else
{
Expand Down

0 comments on commit 426218c

Please sign in to comment.