Skip to content

Commit c9587bc

Browse files
authored
Preserve order of mappings in TfsNodeStructureToolOptions (#2743)
Fixes #2608 The mappings were defined as dictionary, which does not have an order. The items added to dictionary are returned in random order. In case of mappings, we need to preserver the order of them, because we need them to be matched in order specified in configuration. This is important, because more than one mapping can match input value. **This is a breaking change. The configuration of `TfsNodeStructureTool` must be changed after this change.** ## Example Old configuration: ``` json "TfsNodeStructureTool": { "Enabled": true, "Areas": { "Filters": [], "Mappings": { "Foo\\\\AAA\\\\123\\\\(.+)": "FooDest\\AAA\\$1", "Foo\\\\(.+)": "FooDest\\$1" } } } ``` New configuration: ``` json "TfsNodeStructureTool": { "Enabled": true, "Areas": { "Filters": [], "Mappings": [ { "Match": "Foo\\\\AAA\\\\123\\\\(.+)", "Replacement": "FooDest\\AAA\\$1" }, { "Match": "Foo\\\\(.+)", "Replacement": "FooDest\\$1" } ] } } ``` +semver: minor
2 parents 9f7ae56 + 8cf9898 commit c9587bc

File tree

3 files changed

+70
-78
lines changed

3 files changed

+70
-78
lines changed

src/MigrationTools.Clients.TfsObjectModel.Tests/Tools/TfsNodeStructureTests.cs

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
using System.Collections.Generic;
2-
using Microsoft.Extensions.Configuration;
1+
using System;
2+
using System.Collections.Generic;
33
using Microsoft.Extensions.DependencyInjection;
44
using Microsoft.TeamFoundation.TestManagement.WebApi;
55
using Microsoft.VisualStudio.TestTools.UnitTesting;
6-
using MigrationTools.Tests;
7-
using System.Threading.Tasks;
86
using MigrationTools.Shadows;
9-
using System;
107
using MigrationTools.Tools;
118

129
namespace MigrationTools.Tests.Tools
@@ -22,10 +19,9 @@ public void GetTfsNodeStructureTool_WithDifferentAreaPath()
2219
options.Enabled = true;
2320
options.Areas = new NodeOptions()
2421
{
25-
Mappings = new Dictionary<string, string>()
26-
{
27-
{ @"^SourceProject\\PUL", "TargetProject\\test\\PUL" }
28-
}
22+
Mappings = [
23+
new() { Match = @"^SourceProject\\PUL", Replacement = "TargetProject\\test\\PUL" }
24+
]
2925
};
3026
var nodeStructure = GetTfsNodeStructureTool(options);
3127

@@ -82,17 +78,15 @@ public void TestFixAreaPath_WhenAreaPathInQuery_WithPrefixProjectToNodesEnabled_
8278
options.Enabled = true;
8379
options.Areas = new NodeOptions()
8480
{
85-
Mappings = new Dictionary<string, string>()
86-
{
87-
{ "^SourceServer\\\\(.*)" , "TargetServer\\SourceServer\\$1" }
88-
}
81+
Mappings = [
82+
new() { Match = "^SourceServer\\\\(.*)", Replacement = "TargetServer\\SourceServer\\$1" }
83+
]
8984
};
9085
options.Iterations = new NodeOptions()
9186
{
92-
Mappings = new Dictionary<string, string>()
93-
{
94-
{ "^SourceServer\\\\(.*)" , "TargetServer\\SourceServer\\$1" }
95-
}
87+
Mappings = [
88+
new() { Match = "^SourceServer\\\\(.*)", Replacement = "TargetServer\\SourceServer\\$1" }
89+
]
9690
};
9791
var nodeStructure = GetTfsNodeStructureTool(options);
9892

@@ -124,18 +118,16 @@ public void TestFixAreaPath_WhenAreaPathInQuery_WithPrefixProjectToNodesEnabled_
124118
options.Enabled = true;
125119
options.Areas = new NodeOptions()
126120
{
127-
Mappings = new Dictionary<string, string>()
128-
{
129-
{ "^Source Project\\\\(.*)" , "Target Project\\Source Project\\$1" }
130-
}
121+
Mappings = [
122+
new() { Match = "^Source Project\\\\(.*)", Replacement = "Target Project\\Source Project\\$1" }
123+
]
131124
};
132125

133126
options.Iterations = new NodeOptions()
134127
{
135-
Mappings = new Dictionary<string, string>()
136-
{
137-
{ "^Source Project\\\\(.*)" , "Target Project\\Source Project\\$1" }
138-
}
128+
Mappings = [
129+
new() { Match = "^Source Project\\\\(.*)", Replacement = "Target Project\\Source Project\\$1" }
130+
]
139131
};
140132
var settings = new TfsNodeStructureToolSettings() { SourceProjectName = "Source Project", TargetProjectName = "Target Project", FoundNodes = new Dictionary<string, bool>() };
141133
var nodeStructure = GetTfsNodeStructureTool(options, settings);
@@ -225,7 +217,7 @@ private static TfsNodeStructureTool GetTfsNodeStructureTool(TfsNodeStructureTool
225217

226218
private static TfsNodeStructureTool GetTfsNodeStructureTool()
227219
{
228-
var options = new TfsNodeStructureToolOptions() { Enabled = true, Areas = new NodeOptions { Mappings = new Dictionary<string, string>() }, Iterations = new NodeOptions { Mappings = new Dictionary<string, string>() } };
220+
var options = new TfsNodeStructureToolOptions() { Enabled = true, Areas = new NodeOptions(), Iterations = new NodeOptions() };
229221
var settings = new TfsNodeStructureToolSettings() { SourceProjectName = "SourceServer", TargetProjectName = "TargetServer", FoundNodes = new Dictionary<string, bool>() };
230222
return GetTfsNodeStructureTool(options, settings);
231223
}
@@ -255,4 +247,4 @@ private static TfsNodeStructureTool GetTfsNodeStructureTool(TfsNodeStructureTool
255247
}
256248

257249
}
258-
}
250+
}

src/MigrationTools.Clients.TfsObjectModel/Tools/TfsNodeStructureTool.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,17 @@ public string GetNewNodeName(string sourceNodePath, TfsNodeStructureType nodeStr
117117
{
118118
foreach (var mapper in mappers)
119119
{
120-
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}", mapper.Key);
121-
if (Regex.IsMatch(sourceNodePath, mapper.Key, RegexOptions.IgnoreCase))
120+
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}", mapper.Match);
121+
if (Regex.IsMatch(sourceNodePath, mapper.Match, RegexOptions.IgnoreCase))
122122
{
123-
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::Match", mapper.Key);
124-
string replacement = Regex.Replace(sourceNodePath, mapper.Key, mapper.Value);
125-
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::replaceWith({replace})", mapper.Key, replacement);
123+
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::Match", mapper.Match);
124+
string replacement = Regex.Replace(sourceNodePath, mapper.Match, mapper.Replacement);
125+
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::replaceWith({replace})", mapper.Match, replacement);
126126
return replacement;
127127
}
128128
else
129129
{
130-
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::NoMatch", mapper.Key);
130+
Log.LogDebug("NodeStructureEnricher.GetNewNodeName::Mappers::{key}::NoMatch", mapper.Match);
131131
}
132132
}
133133
}
@@ -261,14 +261,14 @@ private NodeInfo GetOrCreateNode(string nodePath, DateTime? startDate, DateTime?
261261
return parentNode;
262262
}
263263

264-
private Dictionary<string, string> GetMaps(TfsNodeStructureType nodeStructureType)
264+
private List<NodeMapping> GetMaps(TfsNodeStructureType nodeStructureType)
265265
{
266266
switch (nodeStructureType)
267267
{
268268
case TfsNodeStructureType.Area:
269-
return Options.Areas != null ? Options.Areas.Mappings : new Dictionary<string, string>();
269+
return Options.Areas != null ? Options.Areas.Mappings : [];
270270
case TfsNodeStructureType.Iteration:
271-
return Options.Iterations != null ? Options.Iterations.Mappings : new Dictionary<string, string>();
271+
return Options.Iterations != null ? Options.Iterations.Mappings : [];
272272
default:
273273
throw new ArgumentOutOfRangeException(nameof(nodeStructureType), nodeStructureType, null);
274274
}
@@ -289,7 +289,7 @@ public void ProcessorExecutionBegin(TfsProcessor processor)
289289
} else
290290
{
291291
Log.LogInformation("SKIP: Migrating all Nodes before the Processor run.");
292-
}
292+
}
293293
}
294294
else
295295
{
@@ -694,9 +694,9 @@ public string GetMappingForMissingItem(NodeStructureItem missingItem)
694694
var mappers = GetMaps((TfsNodeStructureType)Enum.Parse(typeof(TfsNodeStructureType), missingItem.nodeType, true));
695695
foreach (var mapper in mappers)
696696
{
697-
if (Regex.IsMatch(missingItem.sourcePath, mapper.Key, RegexOptions.IgnoreCase))
697+
if (Regex.IsMatch(missingItem.sourcePath, mapper.Match, RegexOptions.IgnoreCase))
698698
{
699-
return mapper.Key;
699+
return mapper.Match;
700700
}
701701
}
702702
return null;

src/MigrationTools.Clients.TfsObjectModel/Tools/TfsNodeStructureToolOptions.cs

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Text.Json.Serialization;
4-
using Microsoft.Extensions.Options;
53
using System.Text.RegularExpressions;
6-
using Microsoft.TeamFoundation.Build.Client;
7-
using MigrationTools.Enrichers;
8-
using MigrationTools.Tools.Infrastructure;
9-
using Newtonsoft.Json.Schema;
104
using DotNet.Globbing;
5+
using Microsoft.Extensions.Options;
6+
using MigrationTools.Tools.Infrastructure;
117
using Serilog;
128

139
namespace MigrationTools.Tools
@@ -20,22 +16,14 @@ public sealed class TfsNodeStructureToolOptions : ToolOptions, ITfsNodeStructure
2016
/// <summary>
2117
/// Rules to apply to the Area Path. Is an object of NodeOptions e.g. { "Filters": ["*/**"], "Mappings": { "^oldProjectName([\\\\]?.*)$": "targetProjectA$1", } }
2218
/// </summary>
23-
/// <default>{"Filters": [], "Mappings": { "^migrationSource1([\\\\]?.*)$": "MigrationTest5$1" })</default>
24-
public NodeOptions Areas { get; set; } = new NodeOptions
25-
{
26-
Filters = new List<string>(),
27-
Mappings = new Dictionary<string, string>()
28-
};
19+
/// <default>{"Filters": [], "Mappings": []}</default>
20+
public NodeOptions Areas { get; set; } = new NodeOptions();
2921

3022
/// <summary>
3123
/// Rules to apply to the Area Path. Is an object of NodeOptions e.g. { "Filters": ["*/**"], "Mappings": { "^oldProjectName([\\\\]?.*)$": "targetProjectA$1", } }
3224
/// </summary>
33-
/// <default>{"Filters": [], "Mappings": { "^migrationSource1([\\\\]?.*)$": "MigrationTest5$1" })</default>
34-
public NodeOptions Iterations { get; set; } = new NodeOptions
35-
{
36-
Filters = new List<string>(),
37-
Mappings = new Dictionary<string, string>()
38-
};
25+
/// <default>{"Filters": [], "Mappings": []}</default>
26+
public NodeOptions Iterations { get; set; } = new NodeOptions();
3927

4028
/// <summary>
4129
/// When set to True the susyem will try to create any missing missing area or iteration paths from the revisions.
@@ -47,16 +35,29 @@ public sealed class TfsNodeStructureToolOptions : ToolOptions, ITfsNodeStructure
4735
public class NodeOptions
4836
{
4937
/// <summary>
50-
/// Using the Glob format you can specify a list of nodes that you want to match. This can be used to filter the main migration of current nodes. note: This does not negate the nees for all nodes in the history of a work item in scope for the migration MUST exist for the system to run, and this will be validated before the migration. e.g. add "migrationSource1\\Team 1,migrationSource1\\Team 1\\**" to match both the Team 1 node and all child nodes.
38+
/// Using the Glob format you can specify a list of nodes that you want to match. This can be used to filter the main migration of current nodes. note: This does not negate the nees for all nodes in the history of a work item in scope for the migration MUST exist for the system to run, and this will be validated before the migration. e.g. add "migrationSource1\\Team 1,migrationSource1\\Team 1\\**" to match both the Team 1 node and all child nodes.
5139
/// </summary>
52-
/// <default>["/"]</default>
53-
public List<string> Filters { get; set; }
40+
/// <default>[]</default>
41+
public List<string> Filters { get; set; } = [];
5442
/// <summary>
5543
/// Remapping rules for nodes, implemented with regular expressions. The rules apply with a higher priority than the `PrefixProjectToNodes`,
5644
/// that is, if no rule matches the path and the `PrefixProjectToNodes` option is enabled, then the old `PrefixProjectToNodes` behavior is applied.
5745
/// </summary>
58-
/// <default>{}</default>
59-
public Dictionary<string, string> Mappings { get; set; }
46+
/// <default>[]</default>
47+
public List<NodeMapping> Mappings { get; set; } = [];
48+
}
49+
50+
public class NodeMapping
51+
{
52+
/// <summary>
53+
/// The regular expression to match the node path.
54+
/// </summary>
55+
public string Match { get; set; } = string.Empty;
56+
57+
/// <summary>
58+
/// The replacement format for the matched node path.
59+
/// </summary>
60+
public string Replacement { get; set; } = string.Empty;
6061
}
6162

6263
public interface ITfsNodeStructureToolOptions
@@ -99,20 +100,20 @@ private ValidateOptionsResult ValidateNodeOptions(NodeOptions nodeOptions, strin
99100
return ValidateOptionsResult.Fail($"{propertyName}.Filters contains an invalid glob pattern: {filter}");
100101
}
101102
}
102-
}
103+
}
103104
// Validate Mappings (Regex for keys, Format for values)
104105
if (nodeOptions.Mappings != null)
105106
{
106107
foreach (var mapping in nodeOptions.Mappings)
107108
{
108-
if (!IsValidRegex(mapping.Key))
109+
if (!IsValidRegex(mapping.Match))
109110
{
110-
return ValidateOptionsResult.Fail($"{propertyName}.Mappings contains an invalid regex pattern: {mapping.Key}");
111+
return ValidateOptionsResult.Fail($"{propertyName}.Mappings contains an invalid regex pattern: {mapping.Match}");
111112
}
112113

113-
if (!IsValidRegexReplacementFormat(mapping.Value, mapping.Key))
114+
if (!IsValidRegexReplacementFormat(mapping.Replacement, mapping.Match))
114115
{
115-
return ValidateOptionsResult.Fail($"{propertyName}.Mappings contains an invalid format string: {mapping.Value}");
116+
return ValidateOptionsResult.Fail($"{propertyName}.Mappings contains an invalid format string: {mapping.Replacement}");
116117
}
117118
}
118119
}
@@ -122,16 +123,15 @@ private ValidateOptionsResult ValidateNodeOptions(NodeOptions nodeOptions, strin
122123
// Example glob validation (modify according to your glob syntax requirements)
123124
private bool IsValidGlobPattern(string pattern)
124125
{
125-
try
126-
{
127-
// This will parse the pattern, and if invalid, will throw an exception
128-
Glob.Parse(pattern);
129-
}
130-
catch (Exception)
131-
{
132-
return false; // If any pattern is invalid, return false
133-
}
134-
126+
try
127+
{
128+
// This will parse the pattern, and if invalid, will throw an exception
129+
Glob.Parse(pattern);
130+
}
131+
catch (Exception)
132+
{
133+
return false; // If any pattern is invalid, return false
134+
}
135135

136136
return true; // All patterns are valid
137137
}
@@ -169,4 +169,4 @@ private bool IsValidRegexReplacementFormat(string format, string regexPattern)
169169
}
170170
}
171171

172-
}
172+
}

0 commit comments

Comments
 (0)