Skip to content

Commit d7c5946

Browse files
authored
Merge branch 'main' into copilot/fix-2622
2 parents 583de9d + eb60426 commit d7c5946

File tree

8 files changed

+568
-0
lines changed

8 files changed

+568
-0
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<PackageVersion Include="Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi" Version="19.225.1" />
3333
<PackageVersion Include="MSTest.TestAdapter" Version="3.8.3" />
3434
<PackageVersion Include="MSTest.TestFramework" Version="3.8.3" />
35+
<PackageVersion Include="ncalc" Version="1.3.8" />
3536
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
3637
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
3738
<PackageVersion Include="NuGet.Protocol" Version="6.13.2" />
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
optionsClassName: FieldCalculationMapOptions
2+
optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions
3+
configurationSamples:
4+
- name: defaults
5+
order: 2
6+
description:
7+
code: >-
8+
{
9+
"MigrationTools": {
10+
"Version": "16.0",
11+
"CommonTools": {
12+
"FieldMappingTool": {
13+
"FieldMaps": [
14+
{
15+
"FieldMapType": "FieldCalculationMap",
16+
"ApplyTo": [
17+
"*"
18+
]
19+
}
20+
]
21+
}
22+
}
23+
}
24+
}
25+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
26+
- name: sample
27+
order: 1
28+
description:
29+
code: There is no sample, but you can check the classic below for a general feel.
30+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
31+
- name: classic
32+
order: 3
33+
description:
34+
code: >-
35+
{
36+
"$type": "FieldCalculationMapOptions",
37+
"expression": null,
38+
"parameters": {},
39+
"targetField": null,
40+
"ApplyTo": [
41+
"*"
42+
]
43+
}
44+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
45+
description: Performs mathematical calculations on numeric fields using NCalc expressions during migration.
46+
className: FieldCalculationMap
47+
typeName: FieldMaps
48+
architecture:
49+
options:
50+
- parameterName: ApplyTo
51+
type: List
52+
description: missing XML code comments
53+
defaultValue: missing XML code comments
54+
- parameterName: expression
55+
type: String
56+
description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2").
57+
defaultValue: null
58+
- parameterName: parameters
59+
type: Dictionary
60+
description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names.
61+
defaultValue: '{}'
62+
- parameterName: targetField
63+
type: String
64+
description: Gets or sets the target field reference name where the calculated result will be stored.
65+
defaultValue: null
66+
status: missing XML code comments
67+
processingTarget: missing XML code comments
68+
classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
69+
optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
optionsClassName: FieldCalculationMapOptions
3+
optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions
4+
configurationSamples:
5+
- name: defaults
6+
order: 2
7+
description:
8+
code: >-
9+
{
10+
"MigrationTools": {
11+
"Version": "16.0",
12+
"CommonTools": {
13+
"FieldMappingTool": {
14+
"FieldMaps": [
15+
{
16+
"FieldMapType": "FieldCalculationMap",
17+
"ApplyTo": [
18+
"*"
19+
]
20+
}
21+
]
22+
}
23+
}
24+
}
25+
}
26+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
27+
- name: sample
28+
order: 1
29+
description:
30+
code: There is no sample, but you can check the classic below for a general feel.
31+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
32+
- name: classic
33+
order: 3
34+
description:
35+
code: >-
36+
{
37+
"$type": "FieldCalculationMapOptions",
38+
"expression": null,
39+
"parameters": {},
40+
"targetField": null,
41+
"ApplyTo": [
42+
"*"
43+
]
44+
}
45+
sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
46+
description: Performs mathematical calculations on numeric fields using NCalc expressions during migration.
47+
className: FieldCalculationMap
48+
typeName: FieldMaps
49+
architecture:
50+
options:
51+
- parameterName: ApplyTo
52+
type: List
53+
description: missing XML code comments
54+
defaultValue: missing XML code comments
55+
- parameterName: expression
56+
type: String
57+
description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2").
58+
defaultValue: null
59+
- parameterName: parameters
60+
type: Dictionary
61+
description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names.
62+
defaultValue: '{}'
63+
- parameterName: targetField
64+
type: String
65+
description: Gets or sets the target field reference name where the calculated result will be stored.
66+
defaultValue: null
67+
status: missing XML code comments
68+
processingTarget: missing XML code comments
69+
classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
70+
optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
71+
72+
redirectFrom:
73+
- /Reference/FieldMaps/FieldCalculationMapOptions/
74+
layout: reference
75+
toc: true
76+
permalink: /Reference/FieldMaps/FieldCalculationMap/
77+
title: FieldCalculationMap
78+
categories:
79+
- FieldMaps
80+
-
81+
topics:
82+
- topic: notes
83+
path: /docs/Reference/FieldMaps/FieldCalculationMap-notes.md
84+
exists: false
85+
markdown: ''
86+
- topic: introduction
87+
path: /docs/Reference/FieldMaps/FieldCalculationMap-introduction.md
88+
exists: false
89+
markdown: ''
90+
91+
---

src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
<PackageReference Include="Microsoft.TeamFoundation.DistributedTask.Common.Contracts" />
3737
<PackageReference Include="Microsoft.TeamFoundation.DistributedTask.WebApi" />
3838
<PackageReference Include="Microsoft.TeamFoundationServer.ExtendedClient" />
39+
<PackageReference Include="ncalc" />
3940
<PackageReference Include="Newtonsoft.Json.Schema" />
4041
<PackageReference Include="OpenTelemetry.Exporter.Console" />
4142
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.TeamFoundation.WorkItemTracking.Client;
6+
using MigrationTools.Tools;
7+
using MigrationTools.Tools.Infrastructure;
8+
using NCalc;
9+
10+
namespace MigrationTools.FieldMaps.AzureDevops.ObjectModel
11+
{
12+
/// <summary>
13+
/// Performs mathematical calculations on numeric fields using NCalc expressions during migration.
14+
/// </summary>
15+
public class FieldCalculationMap : FieldMapBase
16+
{
17+
/// <summary>
18+
/// Initializes a new instance of the FieldCalculationMap class.
19+
/// </summary>
20+
/// <param name="logger">Logger for the field map operations</param>
21+
/// <param name="telemetryLogger">Telemetry logger for tracking operations</param>
22+
public FieldCalculationMap(ILogger<FieldCalculationMap> logger, ITelemetryLogger telemetryLogger)
23+
: base(logger, telemetryLogger)
24+
{
25+
}
26+
27+
private FieldCalculationMapOptions Config { get { return (FieldCalculationMapOptions)_Config; } }
28+
29+
/// <summary>
30+
/// Configures the field map with the specified options and validates required settings.
31+
/// </summary>
32+
/// <param name="config">The field map configuration options</param>
33+
/// <exception cref="ArgumentNullException">Thrown when required fields are not specified</exception>
34+
public override void Configure(IFieldMapOptions config)
35+
{
36+
base.Configure(config);
37+
38+
if (string.IsNullOrEmpty(Config.expression))
39+
{
40+
throw new ArgumentNullException(nameof(Config.expression), "The expression field must be specified.");
41+
}
42+
43+
if (string.IsNullOrEmpty(Config.targetField))
44+
{
45+
throw new ArgumentNullException(nameof(Config.targetField), "The target field must be specified.");
46+
}
47+
48+
if (Config.parameters == null || Config.parameters.Count == 0)
49+
{
50+
throw new ArgumentNullException(nameof(Config.parameters), "At least one parameter mapping must be specified.");
51+
}
52+
}
53+
54+
public override string MappingDisplayName => $"{Config.expression} -> {Config.targetField}";
55+
56+
internal override void InternalExecute(WorkItem source, WorkItem target)
57+
{
58+
try
59+
{
60+
// Validate that target field exists
61+
if (!target.Fields.Contains(Config.targetField))
62+
{
63+
Log.LogWarning("FieldCalculationMap: Target field '{TargetField}' does not exist on work item {WorkItemId}. Skipping calculation.", Config.targetField, target.Id);
64+
return;
65+
}
66+
67+
// Validate that all source fields exist and collect their values
68+
var parameterValues = new Dictionary<string, object>();
69+
foreach (var parameter in Config.parameters)
70+
{
71+
if (!source.Fields.Contains(parameter.Value))
72+
{
73+
Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' does not exist on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id);
74+
return;
75+
}
76+
77+
var fieldValue = source.Fields[parameter.Value].Value;
78+
if (fieldValue == null)
79+
{
80+
Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' is null on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id);
81+
return;
82+
}
83+
84+
// Convert field value to numeric
85+
if (!TryConvertToNumeric(fieldValue, out var numericValue))
86+
{
87+
Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' with value '{FieldValue}' is not numeric on work item {WorkItemId}. Skipping calculation.", parameter.Value, fieldValue, source.Id);
88+
return;
89+
}
90+
91+
parameterValues[parameter.Key] = numericValue;
92+
}
93+
94+
// Evaluate the expression
95+
var expression = new Expression(Config.expression);
96+
97+
// Set parameters
98+
foreach (var param in parameterValues)
99+
{
100+
expression.Parameters[param.Key] = param.Value;
101+
}
102+
103+
// Evaluate and get result
104+
var result = expression.Evaluate();
105+
106+
if (expression.HasErrors())
107+
{
108+
Log.LogError("FieldCalculationMap: Expression evaluation failed with error: {Error} for work item {WorkItemId}", expression.Error, source.Id);
109+
return;
110+
}
111+
112+
// Convert result to appropriate numeric type and set target field
113+
if (TryConvertToTargetFieldType(result, target.Fields[Config.targetField], out var convertedResult))
114+
{
115+
target.Fields[Config.targetField].Value = convertedResult;
116+
Log.LogDebug("FieldCalculationMap: Successfully calculated and set field '{TargetField}' to '{Result}' for work item {WorkItemId}", Config.targetField, convertedResult, target.Id);
117+
}
118+
else
119+
{
120+
Log.LogWarning("FieldCalculationMap: Could not convert calculation result '{Result}' to target field type for work item {WorkItemId}", result, target.Id);
121+
}
122+
}
123+
catch (Exception ex)
124+
{
125+
Log.LogError(ex, "FieldCalculationMap: Unexpected error during calculation for work item {WorkItemId}", target.Id);
126+
}
127+
}
128+
129+
/// <summary>
130+
/// Attempts to convert a field value to a numeric type.
131+
/// </summary>
132+
/// <param name="value">The field value to convert</param>
133+
/// <param name="numericValue">The converted numeric value</param>
134+
/// <returns>True if conversion was successful, false otherwise</returns>
135+
private static bool TryConvertToNumeric(object value, out object numericValue)
136+
{
137+
numericValue = null;
138+
139+
if (value is int || value is long || value is decimal || value is double || value is float)
140+
{
141+
numericValue = value;
142+
return true;
143+
}
144+
145+
var stringValue = value.ToString().Trim();
146+
147+
// Try different numeric types
148+
if (int.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
149+
{
150+
numericValue = intValue;
151+
return true;
152+
}
153+
154+
if (long.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
155+
{
156+
numericValue = longValue;
157+
return true;
158+
}
159+
160+
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
161+
{
162+
numericValue = doubleValue;
163+
return true;
164+
}
165+
166+
return false;
167+
}
168+
169+
/// <summary>
170+
/// Attempts to convert the calculation result to the appropriate type for the target field.
171+
/// </summary>
172+
/// <param name="result">The calculation result</param>
173+
/// <param name="targetField">The target field</param>
174+
/// <param name="convertedResult">The converted result</param>
175+
/// <returns>True if conversion was successful, false otherwise</returns>
176+
private static bool TryConvertToTargetFieldType(object result, Field targetField, out object convertedResult)
177+
{
178+
convertedResult = null;
179+
180+
try
181+
{
182+
// Check target field type and convert accordingly
183+
var fieldType = targetField.FieldDefinition.FieldType;
184+
185+
switch (fieldType)
186+
{
187+
case FieldType.Integer:
188+
if (result is double doubleResult)
189+
{
190+
convertedResult = (int)Math.Round(doubleResult);
191+
}
192+
else
193+
{
194+
convertedResult = Convert.ToInt32(result);
195+
}
196+
return true;
197+
198+
case FieldType.Double:
199+
convertedResult = Convert.ToDouble(result);
200+
return true;
201+
202+
case FieldType.String:
203+
// Allow setting string fields with numeric results
204+
convertedResult = result.ToString();
205+
return true;
206+
207+
default:
208+
// For other field types, try direct assignment
209+
convertedResult = result;
210+
return true;
211+
}
212+
}
213+
catch (Exception)
214+
{
215+
return false;
216+
}
217+
}
218+
}
219+
}

0 commit comments

Comments
 (0)