diff --git a/Directory.Packages.props b/Directory.Packages.props
index 26f027485..2a77e63f5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,7 @@
+
diff --git a/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml b/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml
new file mode 100644
index 000000000..be7d1c87a
--- /dev/null
+++ b/docs/_data/reference.fieldmaps.fieldcalculationmap.yaml
@@ -0,0 +1,69 @@
+optionsClassName: FieldCalculationMapOptions
+optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions
+configurationSamples:
+- name: defaults
+ order: 2
+ description:
+ code: >-
+ {
+ "MigrationTools": {
+ "Version": "16.0",
+ "CommonTools": {
+ "FieldMappingTool": {
+ "FieldMaps": [
+ {
+ "FieldMapType": "FieldCalculationMap",
+ "ApplyTo": [
+ "*"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+- name: sample
+ order: 1
+ description:
+ code: There is no sample, but you can check the classic below for a general feel.
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+- name: classic
+ order: 3
+ description:
+ code: >-
+ {
+ "$type": "FieldCalculationMapOptions",
+ "expression": null,
+ "parameters": {},
+ "targetField": null,
+ "ApplyTo": [
+ "*"
+ ]
+ }
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+description: Performs mathematical calculations on numeric fields using NCalc expressions during migration.
+className: FieldCalculationMap
+typeName: FieldMaps
+architecture:
+options:
+- parameterName: ApplyTo
+ type: List
+ description: missing XML code comments
+ defaultValue: missing XML code comments
+- parameterName: expression
+ type: String
+ description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2").
+ defaultValue: null
+- parameterName: parameters
+ type: Dictionary
+ description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names.
+ defaultValue: '{}'
+- parameterName: targetField
+ type: String
+ description: Gets or sets the target field reference name where the calculated result will be stored.
+ defaultValue: null
+status: missing XML code comments
+processingTarget: missing XML code comments
+classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
+optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
diff --git a/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md b/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md
new file mode 100644
index 000000000..de6f197f0
--- /dev/null
+++ b/docs/collections/_reference/reference.fieldmaps.fieldcalculationmap.md
@@ -0,0 +1,91 @@
+---
+optionsClassName: FieldCalculationMapOptions
+optionsClassFullName: MigrationTools.Tools.FieldCalculationMapOptions
+configurationSamples:
+- name: defaults
+ order: 2
+ description:
+ code: >-
+ {
+ "MigrationTools": {
+ "Version": "16.0",
+ "CommonTools": {
+ "FieldMappingTool": {
+ "FieldMaps": [
+ {
+ "FieldMapType": "FieldCalculationMap",
+ "ApplyTo": [
+ "*"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+- name: sample
+ order: 1
+ description:
+ code: There is no sample, but you can check the classic below for a general feel.
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+- name: classic
+ order: 3
+ description:
+ code: >-
+ {
+ "$type": "FieldCalculationMapOptions",
+ "expression": null,
+ "parameters": {},
+ "targetField": null,
+ "ApplyTo": [
+ "*"
+ ]
+ }
+ sampleFor: MigrationTools.Tools.FieldCalculationMapOptions
+description: Performs mathematical calculations on numeric fields using NCalc expressions during migration.
+className: FieldCalculationMap
+typeName: FieldMaps
+architecture:
+options:
+- parameterName: ApplyTo
+ type: List
+ description: missing XML code comments
+ defaultValue: missing XML code comments
+- parameterName: expression
+ type: String
+ description: Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2").
+ defaultValue: null
+- parameterName: parameters
+ type: Dictionary
+ description: Gets or sets a dictionary mapping variable names used in the expression to source field reference names.
+ defaultValue: '{}'
+- parameterName: targetField
+ type: String
+ description: Gets or sets the target field reference name where the calculated result will be stored.
+ defaultValue: null
+status: missing XML code comments
+processingTarget: missing XML code comments
+classFile: src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
+optionsClassFile: src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
+
+redirectFrom:
+- /Reference/FieldMaps/FieldCalculationMapOptions/
+layout: reference
+toc: true
+permalink: /Reference/FieldMaps/FieldCalculationMap/
+title: FieldCalculationMap
+categories:
+- FieldMaps
+-
+topics:
+- topic: notes
+ path: /docs/Reference/FieldMaps/FieldCalculationMap-notes.md
+ exists: false
+ markdown: ''
+- topic: introduction
+ path: /docs/Reference/FieldMaps/FieldCalculationMap-introduction.md
+ exists: false
+ markdown: ''
+
+---
\ No newline at end of file
diff --git a/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj b/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj
index 4742bd98d..0102a479a 100644
--- a/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj
+++ b/src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj
@@ -36,6 +36,7 @@
+
diff --git a/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs b/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
new file mode 100644
index 000000000..53160fa6b
--- /dev/null
+++ b/src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs
@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using Microsoft.TeamFoundation.WorkItemTracking.Client;
+using MigrationTools.Tools;
+using MigrationTools.Tools.Infrastructure;
+using NCalc;
+
+namespace MigrationTools.FieldMaps.AzureDevops.ObjectModel
+{
+ ///
+ /// Performs mathematical calculations on numeric fields using NCalc expressions during migration.
+ ///
+ public class FieldCalculationMap : FieldMapBase
+ {
+ ///
+ /// Initializes a new instance of the FieldCalculationMap class.
+ ///
+ /// Logger for the field map operations
+ /// Telemetry logger for tracking operations
+ public FieldCalculationMap(ILogger logger, ITelemetryLogger telemetryLogger)
+ : base(logger, telemetryLogger)
+ {
+ }
+
+ private FieldCalculationMapOptions Config { get { return (FieldCalculationMapOptions)_Config; } }
+
+ ///
+ /// Configures the field map with the specified options and validates required settings.
+ ///
+ /// The field map configuration options
+ /// Thrown when required fields are not specified
+ public override void Configure(IFieldMapOptions config)
+ {
+ base.Configure(config);
+
+ if (string.IsNullOrEmpty(Config.expression))
+ {
+ throw new ArgumentNullException(nameof(Config.expression), "The expression field must be specified.");
+ }
+
+ if (string.IsNullOrEmpty(Config.targetField))
+ {
+ throw new ArgumentNullException(nameof(Config.targetField), "The target field must be specified.");
+ }
+
+ if (Config.parameters == null || Config.parameters.Count == 0)
+ {
+ throw new ArgumentNullException(nameof(Config.parameters), "At least one parameter mapping must be specified.");
+ }
+ }
+
+ public override string MappingDisplayName => $"{Config.expression} -> {Config.targetField}";
+
+ internal override void InternalExecute(WorkItem source, WorkItem target)
+ {
+ try
+ {
+ // Validate that target field exists
+ if (!target.Fields.Contains(Config.targetField))
+ {
+ Log.LogWarning("FieldCalculationMap: Target field '{TargetField}' does not exist on work item {WorkItemId}. Skipping calculation.", Config.targetField, target.Id);
+ return;
+ }
+
+ // Validate that all source fields exist and collect their values
+ var parameterValues = new Dictionary();
+ foreach (var parameter in Config.parameters)
+ {
+ if (!source.Fields.Contains(parameter.Value))
+ {
+ Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' does not exist on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id);
+ return;
+ }
+
+ var fieldValue = source.Fields[parameter.Value].Value;
+ if (fieldValue == null)
+ {
+ Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' is null on work item {WorkItemId}. Skipping calculation.", parameter.Value, source.Id);
+ return;
+ }
+
+ // Convert field value to numeric
+ if (!TryConvertToNumeric(fieldValue, out var numericValue))
+ {
+ Log.LogWarning("FieldCalculationMap: Source field '{SourceField}' with value '{FieldValue}' is not numeric on work item {WorkItemId}. Skipping calculation.", parameter.Value, fieldValue, source.Id);
+ return;
+ }
+
+ parameterValues[parameter.Key] = numericValue;
+ }
+
+ // Evaluate the expression
+ var expression = new Expression(Config.expression);
+
+ // Set parameters
+ foreach (var param in parameterValues)
+ {
+ expression.Parameters[param.Key] = param.Value;
+ }
+
+ // Evaluate and get result
+ var result = expression.Evaluate();
+
+ if (expression.HasErrors())
+ {
+ Log.LogError("FieldCalculationMap: Expression evaluation failed with error: {Error} for work item {WorkItemId}", expression.Error, source.Id);
+ return;
+ }
+
+ // Convert result to appropriate numeric type and set target field
+ if (TryConvertToTargetFieldType(result, target.Fields[Config.targetField], out var convertedResult))
+ {
+ target.Fields[Config.targetField].Value = convertedResult;
+ Log.LogDebug("FieldCalculationMap: Successfully calculated and set field '{TargetField}' to '{Result}' for work item {WorkItemId}", Config.targetField, convertedResult, target.Id);
+ }
+ else
+ {
+ Log.LogWarning("FieldCalculationMap: Could not convert calculation result '{Result}' to target field type for work item {WorkItemId}", result, target.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.LogError(ex, "FieldCalculationMap: Unexpected error during calculation for work item {WorkItemId}", target.Id);
+ }
+ }
+
+ ///
+ /// Attempts to convert a field value to a numeric type.
+ ///
+ /// The field value to convert
+ /// The converted numeric value
+ /// True if conversion was successful, false otherwise
+ private static bool TryConvertToNumeric(object value, out object numericValue)
+ {
+ numericValue = null;
+
+ if (value is int || value is long || value is decimal || value is double || value is float)
+ {
+ numericValue = value;
+ return true;
+ }
+
+ var stringValue = value.ToString().Trim();
+
+ // Try different numeric types
+ if (int.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
+ {
+ numericValue = intValue;
+ return true;
+ }
+
+ if (long.TryParse(stringValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
+ {
+ numericValue = longValue;
+ return true;
+ }
+
+ if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
+ {
+ numericValue = doubleValue;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to convert the calculation result to the appropriate type for the target field.
+ ///
+ /// The calculation result
+ /// The target field
+ /// The converted result
+ /// True if conversion was successful, false otherwise
+ private static bool TryConvertToTargetFieldType(object result, Field targetField, out object convertedResult)
+ {
+ convertedResult = null;
+
+ try
+ {
+ // Check target field type and convert accordingly
+ var fieldType = targetField.FieldDefinition.FieldType;
+
+ switch (fieldType)
+ {
+ case FieldType.Integer:
+ if (result is double doubleResult)
+ {
+ convertedResult = (int)Math.Round(doubleResult);
+ }
+ else
+ {
+ convertedResult = Convert.ToInt32(result);
+ }
+ return true;
+
+ case FieldType.Double:
+ convertedResult = Convert.ToDouble(result);
+ return true;
+
+ case FieldType.String:
+ // Allow setting string fields with numeric results
+ convertedResult = result.ToString();
+ return true;
+
+ default:
+ // For other field types, try direct assignment
+ convertedResult = result;
+ return true;
+ }
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/MigrationTools.Tests/MigrationTools.Tests.csproj b/src/MigrationTools.Tests/MigrationTools.Tests.csproj
index 418c2f54e..d191af44b 100644
--- a/src/MigrationTools.Tests/MigrationTools.Tests.csproj
+++ b/src/MigrationTools.Tests/MigrationTools.Tests.csproj
@@ -11,6 +11,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs b/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs
new file mode 100644
index 000000000..ac1cc03e6
--- /dev/null
+++ b/src/MigrationTools.Tests/Tools/FieldMaps/FieldCalculationMapTests.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using MigrationTools.Tools;
+using NCalc;
+
+namespace MigrationTools.Tests.Tools.FieldMaps
+{
+ [TestClass]
+ public class FieldCalculationMapTests
+ {
+ [TestMethod]
+ public void FieldCalculationMapOptions_Constructor_ShouldInitializeParametersDictionary()
+ {
+ // Arrange & Act
+ var options = new FieldCalculationMapOptions();
+
+ // Assert
+ Assert.IsNotNull(options.parameters);
+ Assert.AreEqual(0, options.parameters.Count);
+ }
+
+ [TestMethod]
+ public void FieldCalculationMapOptions_SetExampleConfigDefaults_ShouldSetCorrectValues()
+ {
+ // Arrange
+ var options = new FieldCalculationMapOptions();
+
+ // Act
+ options.SetExampleConfigDefaults();
+
+ // Assert
+ Assert.AreEqual("[x]*2", options.expression);
+ Assert.AreEqual("Custom.FieldC", options.targetField);
+ Assert.AreEqual(1, options.parameters.Count);
+ Assert.AreEqual("Custom.FieldB", options.parameters["x"]);
+ CollectionAssert.Contains(options.ApplyTo, "SomeWorkItemType");
+ }
+
+ [TestMethod]
+ public void NCalcExpression_SimpleCalculation_ShouldEvaluateCorrectly()
+ {
+ // Arrange
+ var expression = new Expression("[x] * 2");
+ expression.Parameters["x"] = 5;
+
+ // Act
+ var result = expression.Evaluate();
+
+ // Assert
+ Assert.AreEqual(10, result);
+ }
+
+ [TestMethod]
+ public void NCalcExpression_ComplexCalculation_ShouldEvaluateCorrectly()
+ {
+ // Arrange
+ var expression = new Expression("([a] + [b]) * [c]");
+ expression.Parameters["a"] = 10;
+ expression.Parameters["b"] = 5;
+ expression.Parameters["c"] = 2;
+
+ // Act
+ var result = expression.Evaluate();
+
+ // Assert
+ Assert.AreEqual(30, result);
+ }
+
+ [TestMethod]
+ public void NCalcExpression_DivisionCalculation_ShouldEvaluateCorrectly()
+ {
+ // Arrange
+ var expression = new Expression("[total] / [count]");
+ expression.Parameters["total"] = 100;
+ expression.Parameters["count"] = 4;
+
+ // Act
+ var result = expression.Evaluate();
+
+ // Assert - NCalc returns double for division
+ Assert.AreEqual(25.0, result);
+ }
+
+ [TestMethod]
+ public void NCalcExpression_MathFunctions_ShouldEvaluateCorrectly()
+ {
+ // Arrange - Test Abs function alone to avoid type conflicts
+ var expression = new Expression("Abs([value])");
+ expression.Parameters["value"] = -25;
+
+ // Act
+ var result = expression.Evaluate();
+
+ // Assert - NCalc returns decimal for Abs
+ Assert.AreEqual(25m, result);
+ }
+
+ [TestMethod]
+ public void NCalcExpression_SqrtFunction_ShouldEvaluateCorrectly()
+ {
+ // Arrange - Test Sqrt function separately
+ var expression = new Expression("Sqrt([square])");
+ expression.Parameters["square"] = 16.0;
+
+ // Act
+ var result = expression.Evaluate();
+
+ // Assert
+ Assert.AreEqual(4.0, result);
+ }
+
+ [TestMethod]
+ public void NCalcExpression_InvalidExpression_ShouldThrowException()
+ {
+ // Arrange
+ var expression = new Expression("[x] + ["); // Invalid syntax
+
+ // Act & Assert - NCalc throws EvaluationException for invalid syntax
+ Assert.ThrowsException(() => expression.Evaluate());
+ }
+
+ [TestMethod]
+ public void NCalcExpression_UndefinedVariable_ShouldThrowException()
+ {
+ // Arrange
+ var expression = new Expression("[undefined] * 2");
+
+ // Act & Assert
+ Assert.ThrowsException(() => expression.Evaluate());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs b/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
new file mode 100644
index 000000000..7a55e8a29
--- /dev/null
+++ b/src/MigrationTools/Tools/FieldMappingTool/FieldMaps/FieldCalculationMapOptions.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using MigrationTools.Tools.Infrastructure;
+
+namespace MigrationTools.Tools
+{
+ ///
+ /// Performs mathematical calculations on numeric fields using NCalc expressions during migration.
+ ///
+ /// ready
+ /// Work Item Field
+ public class FieldCalculationMapOptions : FieldMapOptions
+ {
+ ///
+ /// Gets or sets the NCalc expression to evaluate. Variables in the expression should be enclosed in square brackets (e.g., "[x]*2").
+ ///
+ /// null
+ public string expression { get; set; }
+
+ ///
+ /// Gets or sets a dictionary mapping variable names used in the expression to source field reference names.
+ ///
+ /// {}
+ public Dictionary parameters { get; set; }
+
+ ///
+ /// Gets or sets the target field reference name where the calculated result will be stored.
+ ///
+ /// null
+ public string targetField { get; set; }
+
+ ///
+ /// Sets example configuration defaults for documentation purposes.
+ ///
+ public void SetExampleConfigDefaults()
+ {
+ ApplyTo = new List() { "SomeWorkItemType" };
+ expression = "[x]*2";
+ parameters = new Dictionary
+ {
+ { "x", "Custom.FieldB" }
+ };
+ targetField = "Custom.FieldC";
+ }
+
+ ///
+ /// Initializes a new instance of the FieldCalculationMapOptions class.
+ ///
+ public FieldCalculationMapOptions()
+ {
+ parameters = new Dictionary();
+ }
+ }
+}
\ No newline at end of file