Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<PackageVersion Include="Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi" Version="19.225.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="3.8.3" />
<PackageVersion Include="MSTest.TestFramework" Version="3.8.3" />
<PackageVersion Include="ncalc" Version="1.3.8" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="NuGet.Protocol" Version="6.13.2" />
Expand Down
69 changes: 69 additions & 0 deletions docs/_data/reference.fieldmaps.fieldcalculationmap.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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: ''

---
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<PackageReference Include="Microsoft.TeamFoundation.DistributedTask.Common.Contracts" />
<PackageReference Include="Microsoft.TeamFoundation.DistributedTask.WebApi" />
<PackageReference Include="Microsoft.TeamFoundationServer.ExtendedClient" />
<PackageReference Include="ncalc" />
<PackageReference Include="Newtonsoft.Json.Schema" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Performs mathematical calculations on numeric fields using NCalc expressions during migration.
/// </summary>
public class FieldCalculationMap : FieldMapBase
{
/// <summary>
/// Initializes a new instance of the FieldCalculationMap class.
/// </summary>
/// <param name="logger">Logger for the field map operations</param>
/// <param name="telemetryLogger">Telemetry logger for tracking operations</param>
public FieldCalculationMap(ILogger<FieldCalculationMap> logger, ITelemetryLogger telemetryLogger)
: base(logger, telemetryLogger)
{
}

private FieldCalculationMapOptions Config { get { return (FieldCalculationMapOptions)_Config; } }

/// <summary>
/// Configures the field map with the specified options and validates required settings.
/// </summary>
/// <param name="config">The field map configuration options</param>
/// <exception cref="ArgumentNullException">Thrown when required fields are not specified</exception>
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.");

Check warning on line 40 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'expression' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)

Check warning on line 40 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'expression' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)
}

if (string.IsNullOrEmpty(Config.targetField))
{
throw new ArgumentNullException(nameof(Config.targetField), "The target field must be specified.");

Check warning on line 45 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'targetField' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)

Check warning on line 45 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'targetField' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)
}

if (Config.parameters == null || Config.parameters.Count == 0)
{
throw new ArgumentNullException(nameof(Config.parameters), "At least one parameter mapping must be specified.");

Check warning on line 50 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'parameters' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)

Check warning on line 50 in src/MigrationTools.Clients.TfsObjectModel/Tools/FieldMappingTool/FieldMaps/FieldCalculationMap.cs

View workflow job for this annotation

GitHub Actions / Build, Test, Sonar Cloud Analysis, & Package

The parameter name 'parameters' is not declared in the argument list. (https://rules.sonarsource.com/csharp/RSPEC-3928)
}
}

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<string, object>();
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);
}
}

/// <summary>
/// Attempts to convert a field value to a numeric type.
/// </summary>
/// <param name="value">The field value to convert</param>
/// <param name="numericValue">The converted numeric value</param>
/// <returns>True if conversion was successful, false otherwise</returns>
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;
}

/// <summary>
/// Attempts to convert the calculation result to the appropriate type for the target field.
/// </summary>
/// <param name="result">The calculation result</param>
/// <param name="targetField">The target field</param>
/// <param name="convertedResult">The converted result</param>
/// <returns>True if conversion was successful, false otherwise</returns>
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;
}
}
}
}
Loading
Loading