diff --git a/Next-Release-ChangeLog.md b/Next-Release-ChangeLog.md
index 6d2ffb32..080cf056 100644
--- a/Next-Release-ChangeLog.md
+++ b/Next-Release-ChangeLog.md
@@ -3,7 +3,8 @@ This release has a few fixes and a new feature.
CLI Commands and Options
========================
-Fixes upgrading a previous instance to the latest run-time (PR #263).
+* Fixes upgrading a previous instance to the latest run-time (PR #263).
+* New algorithm to generate unique Azure resource names.
Docker and Azure Function Hosting
@@ -13,7 +14,8 @@ No changes.
Rule Language
========================
-No changes.
+* New pre-defined constant `ruleName`, returns the name of executing rule.
+* New `store.TransitionToState` method to change the state of a Work Item (see #255).
Rule Interpreter Engine
@@ -24,6 +26,8 @@ No changes.
Build, Test, Documentation
========================
* Renamed `aggregator-cli.sln` to `aggregator3.sln`.
+* Moved assembly properties, in particoular `VersionPrefix` and `VersionSuffix`, to common `Directory.Build.props` file.
+* Added tests to upgrade from an older version.
File Hashes
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
new file mode 100644
index 00000000..3222322b
--- /dev/null
+++ b/src/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ TFS Aggregator Team
+ Aggregator 3
+ Copyright © TFS Aggregator Team
+ 1.3.0
+ beta.1
+
+
diff --git a/src/aggregator-cli/AzureBaseClass.cs b/src/aggregator-cli/AzureBaseClass.cs
index a8ea428d..e67f4aff 100644
--- a/src/aggregator-cli/AzureBaseClass.cs
+++ b/src/aggregator-cli/AzureBaseClass.cs
@@ -3,19 +3,20 @@
using Microsoft.Azure.Management.AppService.Fluent;
using Microsoft.Azure.Management.Fluent;
-
+using Microsoft.Azure.Management.ResourceManager.Fluent;
namespace aggregator.cli
{
internal abstract class AzureBaseClass
{
protected IAzure _azure;
-
+ protected IResourceManagementClient _azureManagement;
protected ILogger _logger;
- protected AzureBaseClass(IAzure azure, ILogger logger)
+ protected AzureBaseClass(IAzure azure, ILogger logger, IResourceManagementClient azureManagement = null)
{
_azure = azure;
+ _azureManagement = azureManagement;
_logger = logger;
}
@@ -41,4 +42,4 @@ protected KuduApi GetKudu(InstanceName instance)
return kudu;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/aggregator-cli/CommandBase.cs b/src/aggregator-cli/CommandBase.cs
index ac94bc44..2afea617 100644
--- a/src/aggregator-cli/CommandBase.cs
+++ b/src/aggregator-cli/CommandBase.cs
@@ -9,6 +9,12 @@
namespace aggregator.cli
{
+ internal enum ReturnType
+ {
+ ExitCode,
+ SuccessBooleanPlusIntegerValue
+ }
+
abstract class CommandBase
{
[ShowInTelemetry]
@@ -25,7 +31,13 @@ abstract class CommandBase
internal abstract Task RunAsync(CancellationToken cancellationToken);
- internal int Run(CancellationToken cancellationToken)
+ // virtual because it is the exception not the norm, don't want to force every command with a dummy implementation
+ internal virtual async Task<(bool success, int returnCode)> RunWithReturnAsync(CancellationToken cancellationToken)
+ {
+ return (true, 0);
+ }
+
+ internal (bool success, int returnCode) Run(CancellationToken cancellationToken, ReturnType returnMode = ReturnType.ExitCode)
{
var thisCommandName = this.GetType().GetCustomAttribute().Name;
@@ -61,31 +73,58 @@ internal int Run(CancellationToken cancellationToken)
// Hello World
Logger.WriteInfo($"{title.Title} v{infoVersion.InformationalVersion} (build: {fileVersion.Version} {config.Configuration}) (c) {copyright.Copyright}");
- var t = RunAsync(cancellationToken);
- t.Wait(cancellationToken);
- cancellationToken.ThrowIfCancellationRequested();
- int rc = t.Result;
+ (bool success, int returnCode) packedResult;
+
+ switch (returnMode)
+ {
+ case ReturnType.ExitCode:
+ var t = RunAsync(cancellationToken);
+ t.Wait(cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ packedResult = (default, t.Result);
+ if (packedResult.returnCode == ExitCodes.Success)
+ {
+ packedResult.success = true;
+ Logger.WriteSuccess($"{thisCommandName} Succeeded");
+ }
+ else if (packedResult.returnCode == ExitCodes.NotFound)
+ {
+ packedResult.success = true;
+ Logger.WriteWarning($"{thisCommandName} Item Not found");
+ }
+ else
+ {
+ packedResult.success = false;
+ Logger.WriteError($"{thisCommandName} Failed!");
+ }
+ break;
+ case ReturnType.SuccessBooleanPlusIntegerValue:
+ var t2 = RunWithReturnAsync(cancellationToken);
+ t2.Wait(cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ packedResult = t2.Result;
+ if (packedResult.success)
+ {
+ Logger.WriteSuccess($"{thisCommandName} Succeeded");
+ }
+ else
+ {
+ Logger.WriteError($"{thisCommandName} Failed!");
+ }
+ break;
+ default:
+ throw new NotImplementedException($"Fix code and add {returnMode} to ReturnType enum.");
+ }
var eventEnd = new EventTelemetry
{
Name = $"{thisCommandName} End"
};
- eventEnd.Properties["exitCode"] = rc.ToString();
+ // SuccessBooleanPlusIntegerValue is used only in testing scenarios
+ eventEnd.Properties["exitCode"] = packedResult.returnCode.ToString();
Telemetry.TrackEvent(eventEnd);
- if (rc == ExitCodes.Success)
- {
- Logger.WriteSuccess($"{thisCommandName} Succeeded");
- }
- else if (rc == ExitCodes.NotFound)
- {
- Logger.WriteWarning($"{thisCommandName} Item Not found");
- }
- else
- {
- Logger.WriteError($"{thisCommandName} Failed!");
- }
- return rc;
+ return packedResult;
}
catch (Exception ex)
{
@@ -95,7 +134,7 @@ internal int Run(CancellationToken cancellationToken)
: ex.InnerException.Message
);
Telemetry.TrackException(ex);
- return ExitCodes.Unexpected;
+ return (false, ExitCodes.Unexpected);
}
}
diff --git a/src/aggregator-cli/ContextBuilder.cs b/src/aggregator-cli/ContextBuilder.cs
index 846fe621..e1a7abe0 100644
--- a/src/aggregator-cli/ContextBuilder.cs
+++ b/src/aggregator-cli/ContextBuilder.cs
@@ -3,6 +3,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Management.Fluent;
+using Microsoft.Azure.Management.ResourceManager.Fluent;
using Microsoft.VisualStudio.Services.WebApi;
namespace aggregator.cli
@@ -12,12 +13,14 @@ internal class CommandContext
{
internal ILogger Logger { get; }
internal IAzure Azure { get; }
+ internal IResourceManagementClient AzureManagement { get; }
internal VssConnection Devops { get; }
internal INamingTemplates Naming { get; }
- internal CommandContext(ILogger logger, IAzure azure, VssConnection devops, INamingTemplates naming)
+ internal CommandContext(ILogger logger, IAzure azure, IResourceManagementClient azureManagement, VssConnection devops, INamingTemplates naming)
{
Logger = logger;
Azure = azure;
+ AzureManagement = azureManagement;
Devops = devops;
Naming = naming;
}
@@ -28,6 +31,7 @@ internal class ContextBuilder
private readonly string namingTemplate;
private readonly ILogger logger;
private bool azureLogon;
+ private bool azureManagementLogon;
private bool devopsLogon;
internal ContextBuilder(ILogger logger, string namingTemplate)
@@ -42,6 +46,12 @@ internal ContextBuilder WithAzureLogon()
return this;
}
+ internal ContextBuilder WithAzureManagement()
+ {
+ azureManagementLogon = true;
+ return this;
+ }
+
internal ContextBuilder WithDevOpsLogon()
{
devopsLogon = true;
@@ -51,6 +61,7 @@ internal ContextBuilder WithDevOpsLogon()
internal async Task BuildAsync(CancellationToken cancellationToken)
{
IAzure azure = null;
+ IResourceManagementClient azureManagement = null;
VssConnection devops = null;
if (azureLogon)
@@ -66,6 +77,19 @@ internal async Task BuildAsync(CancellationToken cancellationTok
azure = connection.Logon();
logger.WriteInfo($"Connected to subscription {azure.SubscriptionId}");
}
+ if (azureManagementLogon)
+ {
+ logger.WriteVerbose($"Authenticating to Azure...");
+ var (connection, reason) = AzureLogon.Load();
+ if (reason != LogonResult.Succeeded)
+ {
+ string msg = TranslateResult(reason);
+ throw new InvalidOperationException(string.Format(msg, "Azure", "logon.azure"));
+ }
+
+ azureManagement = connection.LogonManagement();
+ logger.WriteInfo($"Connected to subscription {azure.SubscriptionId}");
+ }
if (devopsLogon)
{
@@ -97,7 +121,7 @@ internal async Task BuildAsync(CancellationToken cancellationTok
break;
}
- return new CommandContext(logger, azure, devops, naming);
+ return new CommandContext(logger, azure, azureManagement, devops, naming);
}
private static string TranslateResult(LogonResult reason)
diff --git a/src/aggregator-cli/DevOps/Boards.cs b/src/aggregator-cli/DevOps/Boards.cs
index 70321baf..14707c4c 100644
--- a/src/aggregator-cli/DevOps/Boards.cs
+++ b/src/aggregator-cli/DevOps/Boards.cs
@@ -46,5 +46,30 @@ internal async Task CreateWorkItemAsync(string projectName, string title, C
return newWorkItem.Id ?? 0;
}
+ internal async Task UpdateWorkItemAsync(string projectName, int workItemId, string newTitle, CancellationToken cancellationToken)
+ {
+ logger.WriteVerbose($"Reading Azure DevOps project data...");
+ var projectClient = devops.GetClient();
+ var project = await projectClient.GetProject(projectName);
+ logger.WriteInfo($"Project {projectName} data read.");
+
+ var witClient = devops.GetClient();
+ JsonPatchDocument patchDocument = new JsonPatchDocument
+ {
+ new JsonPatchOperation()
+ {
+ Operation = Operation.Replace,
+ Path = "/fields/System.Title",
+ Value = newTitle
+ }
+ };
+
+ logger.WriteVerbose($"Updating work item #{workItemId} in '{project.Name}' with new Title '{newTitle}'");
+ var workItem = await witClient.UpdateWorkItemAsync(patchDocument, workItemId, validateOnly: false, bypassRules: false, cancellationToken: cancellationToken);
+ logger.WriteInfo($"Updated work item ID {workItem.Id} '{workItem.Fields["System.Title"]}' in '{project.Name}'");
+
+ return 0;
+ }
+
}
}
diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs
index 09708df5..3066af0a 100644
--- a/src/aggregator-cli/Instances/AggregatorInstances.cs
+++ b/src/aggregator-cli/Instances/AggregatorInstances.cs
@@ -17,8 +17,8 @@ class AggregatorInstances : AzureBaseClass
{
private readonly INamingTemplates naming;
- public AggregatorInstances(IAzure azure, ILogger logger, INamingTemplates naming)
- : base(azure, logger)
+ public AggregatorInstances(IAzure azure, IResourceManagementClient azureManagement, ILogger logger, INamingTemplates naming)
+ : base(azure, logger, azureManagement)
{
this.naming = naming;
}
@@ -329,30 +329,66 @@ internal async Task ReadLogAsync(InstanceName instance, string functionN
return logData;
}
- internal async Task UpdateAsync(InstanceName instance, string requiredVersion, string sourceUrl, CancellationToken cancellationToken)
+ internal async Task UpdateAsync(InstanceCreateNames instance, string requiredVersion, string sourceUrl, CancellationToken cancellationToken)
{
+ // capture the list of rules/Functions _before_
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': retriving rules");
+ var rules = new AggregatorRules(_azure, _logger);
+ var allRules = await rules.ListAsync(instance, cancellationToken);
+ var ruleNames = allRules.Select(r => r.RuleName).ToList();
+
// update runtime package
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': updating run-time package to {requiredVersion} from {sourceUrl}");
var package = new FunctionRuntimePackage(_logger);
bool ok = await package.UpdateVersionAsync(requiredVersion, sourceUrl, instance, _azure, cancellationToken);
if (!ok)
return false;
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': force AzFunction runtime version");
await ForceFunctionRuntimeVersionAsync(instance, cancellationToken);
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': updating root files");
var uploadFiles = await UpdateDefaultFilesAsync(package);
- var rules = new AggregatorRules(_azure, _logger);
- var allRules = await rules.ListAsync(instance, cancellationToken);
-
- foreach (var ruleName in allRules.Select(r => r.RuleName))
+ foreach (var ruleName in ruleNames)
{
- _logger.WriteInfo($"Updating Rule '{ruleName}'");
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': updating Rule '{ruleName}'");
await rules.UploadRuleFilesAsync(instance, ruleName, uploadFiles, cancellationToken);
}
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': updating Tag 'aggregatorVersion' on resources");
+ // TODO we should use APIs instead of template in creation...
+ await SetVersionTag(instance, "Microsoft.Web", "sites", instance.FunctionAppName, "2021-01-01", cancellationToken);
+ // HACK cannot use instance.StorageAccountName because older version used a random seed to generate the name
+ var minVersionFixingStorageNameGeneration = new Semver.SemVersion(1, 3, 0, "beta");
+ var storageAccounts = await _azure.StorageAccounts.ListByResourceGroupAsync(instance.ResourceGroupName);
+ // we assume that there is one and only one StorageAccount created by aggregator in the ResourceGroup
+ var storageAccount = storageAccounts.FirstOrDefault(a => a.Tags.ContainsKey("aggregatorVersion"));
+ if (Semver.SemVersion.TryParse(storageAccount?.Tags["aggregatorVersion"], out var semVer)
+ && semVer >= minVersionFixingStorageNameGeneration)
+ {
+ await SetVersionTag(instance, "Microsoft.Storage", "storageAccounts", instance.StorageAccountName, "2021-01-01", cancellationToken);
+ }
+ else
+ {
+ await SetVersionTag(instance, "Microsoft.Storage", "storageAccounts", storageAccount.Name, "2021-01-01", cancellationToken);
+ }
+ await SetVersionTag(instance, "microsoft.insights", "components", instance.AppInsightName, "2020-02-02", cancellationToken);
+ await SetVersionTag(instance, "Microsoft.Web", "serverfarms", instance.HostingPlanName, "2021-01-01", cancellationToken);
+
+ _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': complete");
return true;
}
+ private async Task SetVersionTag(InstanceName instance, string resourceProviderNamespace, string resourceType, string resourceName, string apiVersion, CancellationToken cancellationToken)
+ {
+ //string apiVersion = ;
+ var theResource = await _azureManagement.Resources.GetAsync(instance.ResourceGroupName, resourceProviderNamespace, "", resourceType, resourceName, apiVersion, cancellationToken);
+ var infoVersion = GetCustomAttribute();
+ theResource.Tags["aggregatorVersion"] = infoVersion.InformationalVersion;
+ await _azureManagement.Resources.UpdateAsync(instance.ResourceGroupName, resourceProviderNamespace, "", resourceType, resourceName, apiVersion, theResource, cancellationToken);
+ }
+
private static async Task> UpdateDefaultFilesAsync(FunctionRuntimePackage package)
{
var uploadFiles = new Dictionary();
@@ -372,9 +408,10 @@ private static async Task> UpdateDefaultFilesAsync(Fu
private async Task ForceFunctionRuntimeVersionAsync(InstanceName instance, CancellationToken cancellationToken)
{
+ // HACK this must match appSettings of Microsoft.Web/sites resource in instance-template.json !!!
const string TargetVersion = "~4";
const string DotNetVersion = "v6.0";
- // Change V2/V3 to V4 FUNCTIONS_EXTENSION_VERSION ~4
+ // Change FUNCTIONS_EXTENSION_VERSION to TargetVersion
var webFunctionApp = await GetWebApp(instance, cancellationToken);
var currentAzureRuntimeVersion = webFunctionApp.GetAppSettings()
.GetValueOrDefault("FUNCTIONS_EXTENSION_VERSION");
diff --git a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs
index 35524ffb..15b82f1b 100644
--- a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs
+++ b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs
@@ -36,7 +36,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
.WithDevOpsLogon() // need the token, so we can save it in the app settings
.BuildAsync(cancellationToken);
context.ResourceGroupDeprecationCheck(this.ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
var instance = context.Naming.Instance(Name, ResourceGroup);
if (Authentication)
{
diff --git a/src/aggregator-cli/Instances/InstallInstanceCommand.cs b/src/aggregator-cli/Instances/InstallInstanceCommand.cs
index 483c6370..180daa53 100644
--- a/src/aggregator-cli/Instances/InstallInstanceCommand.cs
+++ b/src/aggregator-cli/Instances/InstallInstanceCommand.cs
@@ -68,7 +68,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
.WithDevOpsLogon() // need the token, so we can save it in the app settings
.BuildAsync(cancellationToken);
context.ResourceGroupDeprecationCheck(this.ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
var instance = context.Naming.GetInstanceCreateNames(Name, ResourceGroup);
bool ok = await instances.AddAsync(instance, Location, RequiredVersion, SourceUrl, tuning, cancellationToken);
return ok ? ExitCodes.Success : ExitCodes.Failure;
diff --git a/src/aggregator-cli/Instances/ListInstancesCommand.cs b/src/aggregator-cli/Instances/ListInstancesCommand.cs
index 56319b5e..e5c81490 100644
--- a/src/aggregator-cli/Instances/ListInstancesCommand.cs
+++ b/src/aggregator-cli/Instances/ListInstancesCommand.cs
@@ -21,7 +21,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
.WithAzureLogon()
.BuildAsync(cancellationToken);
context.ResourceGroupDeprecationCheck(this.ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
if (!string.IsNullOrEmpty(Location))
{
context.Logger.WriteVerbose($"Searching aggregator instances in {Location} Region...");
diff --git a/src/aggregator-cli/Instances/StreamLogsCommand.cs b/src/aggregator-cli/Instances/StreamLogsCommand.cs
index 5c204f91..b1739c77 100644
--- a/src/aggregator-cli/Instances/StreamLogsCommand.cs
+++ b/src/aggregator-cli/Instances/StreamLogsCommand.cs
@@ -20,7 +20,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
.BuildAsync(cancellationToken);
context.ResourceGroupDeprecationCheck(this.ResourceGroup);
var instance = context.Naming.Instance(Instance, ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
bool ok = await instances.StreamLogsAsync(instance, cancellationToken);
return ok ? ExitCodes.Success : ExitCodes.Failure;
}
diff --git a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs
index 3bd5ab9e..aaa24b20 100644
--- a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs
+++ b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs
@@ -38,7 +38,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
_ = await mappings.RemoveInstanceAsync(instance);
}
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
var ok = await instances.RemoveAsync(instance, Location);
return ok ? ExitCodes.Success : ExitCodes.Failure;
}
diff --git a/src/aggregator-cli/Instances/UpdateInstanceCommand.cs b/src/aggregator-cli/Instances/UpdateInstanceCommand.cs
index 909ccd64..c726aa0f 100644
--- a/src/aggregator-cli/Instances/UpdateInstanceCommand.cs
+++ b/src/aggregator-cli/Instances/UpdateInstanceCommand.cs
@@ -26,11 +26,12 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
{
var context = await Context
.WithAzureLogon()
+ .WithAzureManagement()
.BuildAsync(cancellationToken);
context.ResourceGroupDeprecationCheck(this.ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
- var instance = context.Naming.Instance(Instance, ResourceGroup);
+ var instances = new AggregatorInstances(context.Azure, context.AzureManagement, context.Logger, context.Naming);
+ var instance = context.Naming.GetInstanceCreateNames(Instance, ResourceGroup);
bool ok = await instances.UpdateAsync(instance, RequiredVersion, SourceUrl, cancellationToken);
return ok ? ExitCodes.Success : ExitCodes.Failure;
diff --git a/src/aggregator-cli/Logon/AzureLogon.cs b/src/aggregator-cli/Logon/AzureLogon.cs
index f42f0a3d..d1eb15b8 100644
--- a/src/aggregator-cli/Logon/AzureLogon.cs
+++ b/src/aggregator-cli/Logon/AzureLogon.cs
@@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Microsoft.Azure.Management.Fluent;
using Microsoft.Azure.Management.ResourceManager.Fluent;
+using Microsoft.Azure.Management.ResourceManager.Fluent.Authentication;
using Microsoft.Azure.Management.ResourceManager.Fluent.Core;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
@@ -74,5 +75,22 @@ public async Task GetAuthorizationToken()
return result.AccessToken;
}
+
+ internal IResourceManagementClient LogonManagement()
+ {
+ var spCreds = new ServicePrincipalLoginInformation()
+ {
+ ClientId = this.ClientId,
+ ClientSecret = this.ClientSecret,
+ };
+ var azureCreds = new AzureCredentials(spCreds, TenantId, AzureEnvironment.AzureGlobalCloud);
+ var restClient = RestClient.Configure()
+ .WithEnvironment(AzureEnvironment.AzureGlobalCloud)
+ .WithCredentials(azureCreds)
+ .Build();
+ var resourceClient = new ResourceManagementClient(restClient);
+ resourceClient.SubscriptionId = SubscriptionId;
+ return resourceClient;
+ }
}
}
diff --git a/src/aggregator-cli/Naming/BuiltInNamingTemplates.cs b/src/aggregator-cli/Naming/BuiltInNamingTemplates.cs
index 02c49196..d5c5d440 100644
--- a/src/aggregator-cli/Naming/BuiltInNamingTemplates.cs
+++ b/src/aggregator-cli/Naming/BuiltInNamingTemplates.cs
@@ -14,10 +14,28 @@ internal class BuiltInNamingTemplates : INamingTemplates
FunctionAppSuffix = "aggregator",
};
- private static string GetRandomString(int size, string allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789")
+ private static int PseudoHash(string s, int limit)
+ {
+ long total = 0;
+ var c = s.ToCharArray();
+
+ // Horner's rule for generating a polynomial
+ // of 11 using ASCII values of the characters
+ for (int k = 0; k < c.Length; k++)
+ total += 11 * total + (int)c[k];
+
+ total = total % limit;
+
+ if (total < 0)
+ total += limit;
+
+ return (int)total;
+ }
+
+ private static string GetRandomString(string input, int size, string allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789")
{
#pragma warning disable S2245 // Make sure that using this pseudorandom number generator is safe here
- var randomGen = new Random((int)DateTime.Now.Ticks);
+ var randomGen = new Random(PseudoHash(input, allowedChars.Length));
return new string(
Enumerable.Range(0, size)
.Select(x => allowedChars[randomGen.Next(0, allowedChars.Length)])
@@ -36,7 +54,7 @@ internal InstanceCreateNamesImpl(string name, string resourceGroup, bool isCusto
{
HostingPlanName = $"{functionAppName}-plan";
AppInsightName = $"{functionAppName}-ai";
- StorageAccountName = $"aggregator{GetRandomString(8)}";
+ StorageAccountName = $"aggregator{GetRandomString(functionAppName, 8)}";
}
}
diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs
index c3731af8..23cf5cd6 100644
--- a/src/aggregator-cli/Program.cs
+++ b/src/aggregator-cli/Program.cs
@@ -79,7 +79,7 @@ void cancelEventHandler(object sender, ConsoleCancelEventArgs e)
});
var types = new Type[]
{
- typeof(CreateTestCommand), typeof(CleanupTestCommand),
+ typeof(CreateTestCommand), typeof(UpdateTestCommand), typeof(CleanupTestCommand),
typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), typeof(LogoffCommand), typeof(LogonEnvCommand),
typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UpdateInstanceCommand),
typeof(UninstallInstanceCommand), typeof(ConfigureInstanceCommand), typeof(StreamLogsCommand),
@@ -89,39 +89,40 @@ void cancelEventHandler(object sender, ConsoleCancelEventArgs e)
typeof(MapLocalRuleCommand)
};
var parserResult = parser.ParseArguments(args, types);
- int rc = ExitCodes.Unexpected;
+ (bool success, int returnCode) returnValue = (false, ExitCodes.Unexpected);
parserResult
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
- .WithParsed(cmd => rc = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken, ReturnType.SuccessBooleanPlusIntegerValue))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
+ .WithParsed(cmd => returnValue = cmd.Run(cancellationToken))
.WithNotParsed(errs =>
{
var helpText = HelpText.AutoBuild(parserResult);
Console.Error.Write(helpText);
- rc = ExitCodes.InvalidArguments;
+ returnValue = (false, ExitCodes.InvalidArguments);
});
-
+ int rc = returnValue.returnCode;
mainTimer.Stop();
Telemetry.TrackEvent("CLI End", null,
new Dictionary {
diff --git a/src/aggregator-cli/TestCommands/CreateTestCommand.cs b/src/aggregator-cli/TestCommands/CreateTestCommand.cs
index 96d0f48b..22d49cf6 100644
--- a/src/aggregator-cli/TestCommands/CreateTestCommand.cs
+++ b/src/aggregator-cli/TestCommands/CreateTestCommand.cs
@@ -25,14 +25,24 @@ class CreateTestCommand : CommandBase
[Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")]
public string RuleName { get; set; }
+
+ [Option('n', "returnId", Required = false, Default =false, HelpText = "Return work item id instead of return code.")]
+ public bool returnId { get; set; }
+
+
internal override async Task RunAsync(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException("Use CreateTestCommand.RunWithReturnAsync() instead");
+ }
+
+ internal override async Task<(bool success, int returnCode)> RunWithReturnAsync(CancellationToken cancellationToken)
{
var context = await Context
.WithAzureLogon()
.WithDevOpsLogon()
.BuildAsync(cancellationToken);
var instance = context.Naming.Instance(Instance, ResourceGroup);
- var instances = new AggregatorInstances(context.Azure, context.Logger, context.Naming);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
var boards = new Boards(context.Devops, context.Logger);
int id = await boards.CreateWorkItemAsync(this.Project, this.Title, cancellationToken);
@@ -43,7 +53,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken)
// no need to use the output, it is checked by user or test
await instances.ReadLogAsync(instance, this.RuleName, -1, cancellationToken: cancellationToken);
- return id > 0 ? 0 : 1;
+ return returnId ? (id > 0, id) : (id > 0, 0);
}
}
}
diff --git a/src/aggregator-cli/TestCommands/UpdateTestCommand.cs b/src/aggregator-cli/TestCommands/UpdateTestCommand.cs
new file mode 100644
index 00000000..160750fa
--- /dev/null
+++ b/src/aggregator-cli/TestCommands/UpdateTestCommand.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CommandLine;
+
+namespace aggregator.cli
+{
+ [Verb("test.update", HelpText = "Updates a work item and capture the log.", Hidden = true)]
+ class UpdateTestCommand : CommandBase
+ {
+ [Option('p', "project", Required = true, HelpText = "Azure DevOps project name.")]
+ public string Project { get; set; }
+
+ [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")]
+ public string Instance { get; set; }
+
+ [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")]
+ public string ResourceGroup { get; set; }
+
+ [Option('n', "id", Required = true, HelpText = "Work Item ID.")]
+ public int Id { get; set; }
+
+ [Option('t', "title", Required = true, Default = "Aggregator CLI Test Task", HelpText = "Title for new Work Item.")]
+ public string NewTitleValue { get; set; }
+
+ //[Option('l', "lastLinePattern", Required = false, Default = @"Executed \'Functions\.", HelpText = "RegEx Pattern identifying last line of logs.")]
+ //public string LastLinePattern { get; set; }
+ [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")]
+ public string RuleName { get; set; }
+
+ internal override async Task RunAsync(CancellationToken cancellationToken)
+ {
+ var context = await Context
+ .WithAzureLogon()
+ .WithDevOpsLogon()
+ .BuildAsync(cancellationToken);
+ var instance = context.Naming.Instance(Instance, ResourceGroup);
+ var instances = new AggregatorInstances(context.Azure, null, context.Logger, context.Naming);
+ var boards = new Boards(context.Devops, context.Logger);
+
+ int rc = await boards.UpdateWorkItemAsync(this.Project, this.Id, this.NewTitleValue, cancellationToken);
+
+ // wait for the Event to be processed in AzDO, sent via WebHooks, and the Function to run
+ await Task.Delay(new TimeSpan(0, 2, 0), cancellationToken);
+
+ // no need to use the output, it is checked by user or test
+ await instances.ReadLogAsync(instance, this.RuleName, -1, cancellationToken: cancellationToken);
+
+ return rc;
+ }
+ }
+}
diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj
index d0fb1d69..b84d09f3 100644
--- a/src/aggregator-cli/aggregator-cli.csproj
+++ b/src/aggregator-cli/aggregator-cli.csproj
@@ -9,12 +9,7 @@
../../art/Aggregator.ico
Aggregator CLI
- TFS Aggregator Team
- Aggregator CLI
- Copyright © TFS Aggregator Team
Aggregator CLI manages Rules for Azure DevOps
- 0.0.1
- localdev
diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj
index d0f8d872..d1ca192c 100644
--- a/src/aggregator-function/aggregator-function.csproj
+++ b/src/aggregator-function/aggregator-function.csproj
@@ -5,12 +5,7 @@
aggregator
Aggregator Runtime
- TFS Aggregator Team
- Aggregator CLI
- Copyright © TFS Aggregator Team
Azure Function Runtime for Azure DevOps Aggregator Rules
- 0.0.1
- localdev
DEBUG;TRACE
diff --git a/src/aggregator-function/aggregator-manifest.ini b/src/aggregator-function/aggregator-manifest.ini
index 742fc25a..c0c9fcc7 100644
--- a/src/aggregator-function/aggregator-manifest.ini
+++ b/src/aggregator-function/aggregator-manifest.ini
@@ -1 +1 @@
-version=0.4.5
+version=1.3.0-beta.1
diff --git a/src/aggregator-host/Authentication/ApiKeyAuthenticationHandler.cs b/src/aggregator-host/Authentication/ApiKeyAuthenticationHandler.cs
index 010af733..36a308cb 100644
--- a/src/aggregator-host/Authentication/ApiKeyAuthenticationHandler.cs
+++ b/src/aggregator-host/Authentication/ApiKeyAuthenticationHandler.cs
@@ -6,7 +6,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using Microsoft.Extensions.Primitives;
namespace aggregator_host
{
diff --git a/src/aggregator-host/aggregator-host.csproj b/src/aggregator-host/aggregator-host.csproj
index d1c3c1e1..11edf3eb 100644
--- a/src/aggregator-host/aggregator-host.csproj
+++ b/src/aggregator-host/aggregator-host.csproj
@@ -5,13 +5,8 @@
aggregator_host
win-x64
- Aggregator CLI
- TFS Aggregator Team
- Aggregator Host
- Copyright © TFS Aggregator Team
+ Aggregator Host
Aggregator Hosts the Rules Interpreter
- 0.0.1
- localdev
..\.sonarlint\tfsaggregator_aggregator-clicsharp.ruleset
diff --git a/src/aggregator-ruleng/EngineContext.cs b/src/aggregator-ruleng/Engine/EngineContext.cs
similarity index 73%
rename from src/aggregator-ruleng/EngineContext.cs
rename to src/aggregator-ruleng/Engine/EngineContext.cs
index 68d89c0f..da7628e7 100644
--- a/src/aggregator-ruleng/EngineContext.cs
+++ b/src/aggregator-ruleng/Engine/EngineContext.cs
@@ -1,10 +1,11 @@
using System;
+using System.Threading;
namespace aggregator.Engine
{
public class EngineContext
{
- public EngineContext(IClientsContext clients, Guid projectId, string projectName, IAggregatorLogger logger, IRuleSettings ruleSettings)
+ public EngineContext(IClientsContext clients, Guid projectId, string projectName, IAggregatorLogger logger, IRuleSettings ruleSettings, bool dryRun, CancellationToken cancellationToken)
{
Clients = clients;
Logger = logger;
@@ -12,6 +13,8 @@ public EngineContext(IClientsContext clients, Guid projectId, string projectName
ProjectId = projectId;
ProjectName = projectName;
RuleSettings = ruleSettings;
+ CancellationToken = cancellationToken;
+ DryRun = dryRun;
}
public Guid ProjectId { get; internal set; }
@@ -20,5 +23,7 @@ public EngineContext(IClientsContext clients, Guid projectId, string projectName
internal IAggregatorLogger Logger { get; }
internal Tracker Tracker { get; }
internal IRuleSettings RuleSettings { get; }
+ internal CancellationToken CancellationToken { get; }
+ internal bool DryRun { get; }
}
}
diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/Engine/RuleEngine.cs
similarity index 87%
rename from src/aggregator-ruleng/RuleEngine.cs
rename to src/aggregator-ruleng/Engine/RuleEngine.cs
index 3b3399cd..2b34f87f 100644
--- a/src/aggregator-ruleng/RuleEngine.cs
+++ b/src/aggregator-ruleng/Engine/RuleEngine.cs
@@ -27,7 +27,7 @@ protected RuleEngineBase(IAggregatorLogger logger, SaveMode saveMode, bool dryRu
public async Task RunAsync(IRule rule, Guid projectId, WorkItemData workItemPayload, string eventType, IClientsContext clients, CancellationToken cancellationToken = default)
{
- var executionContext = CreateRuleExecutionContext(projectId, workItemPayload, eventType, clients, rule.Settings);
+ var executionContext = CreateRuleExecutionContext(rule, projectId, workItemPayload, eventType, clients, rule.Settings, cancellationToken);
var result = await ExecuteRuleAsync(rule, executionContext, cancellationToken);
@@ -36,10 +36,10 @@ public async Task RunAsync(IRule rule, Guid projectId, WorkItemData work
protected abstract Task ExecuteRuleAsync(IRule rule, RuleExecutionContext executionContext, CancellationToken cancellationToken = default);
- protected RuleExecutionContext CreateRuleExecutionContext(Guid projectId, WorkItemData workItemPayload, string eventType, IClientsContext clients, IRuleSettings ruleSettings)
+ protected RuleExecutionContext CreateRuleExecutionContext(IRule rule, Guid projectId, WorkItemData workItemPayload, string eventType, IClientsContext clients, IRuleSettings ruleSettings, CancellationToken cancellationToken = default)
{
var workItem = workItemPayload.WorkItem;
- var context = new EngineContext(clients, projectId, workItem.GetTeamProject(), logger, ruleSettings);
+ var context = new EngineContext(clients, projectId, workItem.GetTeamProject(), logger, ruleSettings, dryRun, cancellationToken);
var store = new WorkItemStore(context, workItem);
var self = store.GetWorkItem(workItem.Id.Value);
var selfChanges = new WorkItemUpdateWrapper(workItemPayload.WorkItemUpdate);
@@ -47,6 +47,7 @@ protected RuleExecutionContext CreateRuleExecutionContext(Guid projectId, WorkIt
var globals = new RuleExecutionContext
{
+ ruleName = rule.Name,
self = self,
selfChanges = selfChanges,
store = store,
diff --git a/src/aggregator-ruleng/RuleExecutionContext.cs b/src/aggregator-ruleng/Engine/RuleExecutionContext.cs
similarity index 94%
rename from src/aggregator-ruleng/RuleExecutionContext.cs
rename to src/aggregator-ruleng/Engine/RuleExecutionContext.cs
index f1658357..1f701824 100644
--- a/src/aggregator-ruleng/RuleExecutionContext.cs
+++ b/src/aggregator-ruleng/Engine/RuleExecutionContext.cs
@@ -6,6 +6,7 @@
public class RuleExecutionContext
{
#pragma warning disable S1104 // Fields should not have public accessibility
+ public string ruleName;
public WorkItemWrapper self;
public WorkItemUpdateWrapper selfChanges;
public WorkItemStore store;
diff --git a/src/aggregator-ruleng/RuleExecutor.cs b/src/aggregator-ruleng/Engine/RuleExecutor.cs
similarity index 100%
rename from src/aggregator-ruleng/RuleExecutor.cs
rename to src/aggregator-ruleng/Engine/RuleExecutor.cs
diff --git a/src/aggregator-ruleng/ScriptedRuleWrapper.cs b/src/aggregator-ruleng/Engine/ScriptedRuleWrapper.cs
similarity index 100%
rename from src/aggregator-ruleng/ScriptedRuleWrapper.cs
rename to src/aggregator-ruleng/Engine/ScriptedRuleWrapper.cs
diff --git a/src/aggregator-ruleng/WorkItemData.cs b/src/aggregator-ruleng/Engine/WorkItemData.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemData.cs
rename to src/aggregator-ruleng/Engine/WorkItemData.cs
diff --git a/src/aggregator-ruleng/WorkItemEventContext.cs b/src/aggregator-ruleng/Engine/WorkItemEventContext.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemEventContext.cs
rename to src/aggregator-ruleng/Engine/WorkItemEventContext.cs
diff --git a/src/aggregator-ruleng/EnumerableExtension.cs b/src/aggregator-ruleng/EnumerableExtension.cs
deleted file mode 100644
index cebbd5d0..00000000
--- a/src/aggregator-ruleng/EnumerableExtension.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace aggregator.Engine
-{
- public static class EnumerableExtension
- {
- ///
- /// This method exists for backward compatibility reasons.
- ///
- ///
- ///
- ///
- public static string ToSeparatedString(this IEnumerable listOfT, char separator = ',')
- {
- return listOfT
- .Aggregate("",
- (s, i) => FormattableString.Invariant($"{s}{separator}{i}"))
- [1..];
- }
- }
-}
diff --git a/src/aggregator-ruleng/Extensions.cs b/src/aggregator-ruleng/Extensions/EnumerableExtension.cs
similarity index 70%
rename from src/aggregator-ruleng/Extensions.cs
rename to src/aggregator-ruleng/Extensions/EnumerableExtension.cs
index 252900e5..2c5c57f0 100644
--- a/src/aggregator-ruleng/Extensions.cs
+++ b/src/aggregator-ruleng/Extensions/EnumerableExtension.cs
@@ -1,19 +1,27 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
-using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
-using Microsoft.VisualStudio.Services.Common;
-
+using System.Linq;
namespace aggregator.Engine
{
- public static class Extensions
+ public static class EnumerableExtension
{
- public static string GetTeamProject(this WorkItem workItem)
+ ///
+ /// This method exists for backward compatibility reasons.
+ ///
+ ///
+ ///
+ ///
+ public static string ToSeparatedString(this IEnumerable listOfT, char separator = ',')
{
- return workItem.Fields.GetCastedValueOrDefault("System.TeamProject", default(string));
+ return listOfT
+ .Aggregate("",
+ (s, i) => FormattableString.Invariant($"{s}{separator}{i}"))
+ [1..];
}
+
// source https://stackoverflow.com/a/22222439/100864
public static IEnumerable> Paginate(this IEnumerable source, int pageSize)
{
@@ -41,5 +49,6 @@ private static IEnumerable> PaginateIterator(this IEnumerable<
yield return new ReadOnlyCollection(currentPage);
}
}
+
}
}
diff --git a/src/aggregator-ruleng/Extensions/WorkItemExtension.cs b/src/aggregator-ruleng/Extensions/WorkItemExtension.cs
new file mode 100644
index 00000000..f4264d9f
--- /dev/null
+++ b/src/aggregator-ruleng/Extensions/WorkItemExtension.cs
@@ -0,0 +1,14 @@
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Microsoft.VisualStudio.Services.Common;
+
+
+namespace aggregator.Engine
+{
+ public static class WorkItemExtension
+ {
+ public static string GetTeamProject(this WorkItem workItem)
+ {
+ return workItem.Fields.GetCastedValueOrDefault("System.TeamProject", default(string));
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/IAggregatorLogger.cs b/src/aggregator-ruleng/Interfaces/IAggregatorLogger.cs
similarity index 100%
rename from src/aggregator-ruleng/IAggregatorLogger.cs
rename to src/aggregator-ruleng/Interfaces/IAggregatorLogger.cs
diff --git a/src/aggregator-ruleng/IRule.cs b/src/aggregator-ruleng/Interfaces/IRule.cs
similarity index 100%
rename from src/aggregator-ruleng/IRule.cs
rename to src/aggregator-ruleng/Interfaces/IRule.cs
diff --git a/src/aggregator-ruleng/IRuleProvider.cs b/src/aggregator-ruleng/Interfaces/IRuleProvider.cs
similarity index 100%
rename from src/aggregator-ruleng/IRuleProvider.cs
rename to src/aggregator-ruleng/Interfaces/IRuleProvider.cs
diff --git a/src/aggregator-ruleng/IRuleResult.cs b/src/aggregator-ruleng/Interfaces/IRuleResult.cs
similarity index 100%
rename from src/aggregator-ruleng/IRuleResult.cs
rename to src/aggregator-ruleng/Interfaces/IRuleResult.cs
diff --git a/src/aggregator-ruleng/IRuleSettings.cs b/src/aggregator-ruleng/Interfaces/IRuleSettings.cs
similarity index 100%
rename from src/aggregator-ruleng/IRuleSettings.cs
rename to src/aggregator-ruleng/Interfaces/IRuleSettings.cs
diff --git a/src/aggregator-ruleng/Language/IPreprocessedRule.cs b/src/aggregator-ruleng/Parsing/IPreprocessedRule.cs
similarity index 100%
rename from src/aggregator-ruleng/Language/IPreprocessedRule.cs
rename to src/aggregator-ruleng/Parsing/IPreprocessedRule.cs
diff --git a/src/aggregator-ruleng/Language/PreprocessedRule.cs b/src/aggregator-ruleng/Parsing/PreprocessedRule.cs
similarity index 100%
rename from src/aggregator-ruleng/Language/PreprocessedRule.cs
rename to src/aggregator-ruleng/Parsing/PreprocessedRule.cs
diff --git a/src/aggregator-ruleng/Language/PreprocessedRuleExtensions.cs b/src/aggregator-ruleng/Parsing/PreprocessedRuleExtensions.cs
similarity index 100%
rename from src/aggregator-ruleng/Language/PreprocessedRuleExtensions.cs
rename to src/aggregator-ruleng/Parsing/PreprocessedRuleExtensions.cs
diff --git a/src/aggregator-ruleng/Language/RuleFileParser.cs b/src/aggregator-ruleng/Parsing/RuleFileParser.cs
similarity index 100%
rename from src/aggregator-ruleng/Language/RuleFileParser.cs
rename to src/aggregator-ruleng/Parsing/RuleFileParser.cs
diff --git a/src/aggregator-ruleng/Language/RuleLanguage.cs b/src/aggregator-ruleng/Parsing/RuleLanguage.cs
similarity index 100%
rename from src/aggregator-ruleng/Language/RuleLanguage.cs
rename to src/aggregator-ruleng/Parsing/RuleLanguage.cs
diff --git a/src/aggregator-ruleng/RuleSettings.cs b/src/aggregator-ruleng/Parsing/RuleSettings.cs
similarity index 100%
rename from src/aggregator-ruleng/RuleSettings.cs
rename to src/aggregator-ruleng/Parsing/RuleSettings.cs
diff --git a/src/aggregator-ruleng/JsonPatchOperationConverter.cs b/src/aggregator-ruleng/Persistance/JsonPatchOperationConverter.cs
similarity index 100%
rename from src/aggregator-ruleng/JsonPatchOperationConverter.cs
rename to src/aggregator-ruleng/Persistance/JsonPatchOperationConverter.cs
diff --git a/src/aggregator-ruleng/Persistance/PersistBatch.cs b/src/aggregator-ruleng/Persistance/PersistBatch.cs
new file mode 100644
index 00000000..0ae150ce
--- /dev/null
+++ b/src/aggregator-ruleng/Persistance/PersistBatch.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Newtonsoft.Json;
+
+namespace aggregator.Engine.Persistance
+{
+ internal class PersistBatch : PersisterBase
+ {
+ public PersistBatch(EngineContext context)
+ : base(context) { }
+
+ internal async Task<(int created, int updated)> PersistAsync(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
+ {
+ // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs
+ // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1
+ // BUG this code won't work if there is a relation between a new (id<0) work item and an existing one (id>0): it is an API limit
+
+ var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
+ int createdCounter = createdWorkItems.Length;
+ int updatedCounter = updatedWorkItems.Length + deletedWorkItems.Length + restoredWorkItems.Length;
+
+ List batchRequests = new List();
+ foreach (var item in createdWorkItems)
+ {
+ _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {item.TeamProject}");
+
+ var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName,
+ item.WorkItemType,
+ item.Changes,
+ bypassRules: impersonate,
+ suppressNotifications: false);
+ batchRequests.Add(request);
+ }
+
+ foreach (var item in updatedWorkItems)
+ {
+ _context.Logger.WriteInfo($"Found a request to update workitem {item.Id} in {item.TeamProject}");
+
+ var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id,
+ item.Changes,
+ bypassRules: impersonate || bypassrules,
+ suppressNotifications: false);
+ batchRequests.Add(request);
+ }
+
+ var converters = new JsonConverter[] { new JsonPatchOperationConverter() };
+ string requestBody = JsonConvert.SerializeObject(batchRequests, Formatting.None, converters);
+ _context.Logger.WriteVerbose(requestBody);
+
+ if (commit)
+ {
+ _ = await ExecuteBatchRequest(batchRequests, cancellationToken);
+ await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
+ }
+ else
+ {
+ _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
+ }//if
+
+ return (createdCounter, updatedCounter);
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/Persistance/PersistByItem.cs b/src/aggregator-ruleng/Persistance/PersistByItem.cs
new file mode 100644
index 00000000..9a3b9fc7
--- /dev/null
+++ b/src/aggregator-ruleng/Persistance/PersistByItem.cs
@@ -0,0 +1,75 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace aggregator.Engine.Persistance
+{
+ internal class PersistByItem : PersisterBase
+ {
+ public PersistByItem(EngineContext context)
+ : base(context) { }
+
+ internal async Task<(int created, int updated)> PersistAsync(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
+ {
+ int createdCounter = 0;
+ int updatedCounter = 0;
+
+ var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
+ foreach (var item in createdWorkItems)
+ {
+ if (commit)
+ {
+ _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {item.TeamProject}");
+ _ = await _clients.WitClient.CreateWorkItemAsync(
+ item.Changes,
+ _context.ProjectName,
+ item.WorkItemType,
+ bypassRules: impersonate || bypassrules,
+ cancellationToken: cancellationToken
+ );
+ }
+ else
+ {
+ _context.Logger.WriteInfo($"Dry-run mode: should create a {item.WorkItemType} workitem in {item.TeamProject}");
+ }
+
+ createdCounter++;
+ }
+
+ if (commit)
+ {
+ await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
+ }
+ else if (deletedWorkItems.Any() || restoredWorkItems.Any())
+ {
+ static string FormatIds(WorkItemWrapper[] items) => string.Join(",", items.Select(item => item.Id));
+ var teamProjectName = restoredWorkItems.FirstOrDefault()?.TeamProject ??
+ deletedWorkItems.FirstOrDefault()?.TeamProject;
+ _context.Logger.WriteInfo($"Dry-run mode: should restore: {FormatIds(restoredWorkItems)} and delete {FormatIds(deletedWorkItems)} workitems from {teamProjectName}");
+ }
+ updatedCounter += restoredWorkItems.Length + deletedWorkItems.Length;
+
+ foreach (var item in updatedWorkItems.Concat(restoredWorkItems))
+ {
+ if (commit)
+ {
+ _context.Logger.WriteInfo($"Updating workitem {item.Id}");
+ _ = await _clients.WitClient.UpdateWorkItemAsync(
+ item.Changes,
+ item.Id,
+ bypassRules: impersonate || bypassrules,
+ cancellationToken: cancellationToken
+ );
+ }
+ else
+ {
+ _context.Logger.WriteInfo($"Dry-run mode: should update workitem {item.Id} in {item.TeamProject}");
+ }
+
+ updatedCounter++;
+ }
+
+ return (createdCounter, updatedCounter);
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/Persistance/PersistStateChange.cs b/src/aggregator-ruleng/Persistance/PersistStateChange.cs
new file mode 100644
index 00000000..6250a82b
--- /dev/null
+++ b/src/aggregator-ruleng/Persistance/PersistStateChange.cs
@@ -0,0 +1,47 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Services.WebApi.Patch;
+using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
+
+namespace aggregator.Engine.Persistance
+{
+ internal class PersistStateChange : PersisterBase
+ {
+ public PersistStateChange(EngineContext context)
+ : base(context) { }
+
+ internal async Task PersistAsync(WorkItemWrapper item, string comment, bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
+ {
+ if (commit)
+ {
+ var payload = new JsonPatchDocument();
+ payload.Add(new JsonPatchOperation()
+ {
+ Operation = Operation.Replace,
+ Path = "/fields/" + CoreFieldRefNames.State,
+ Value = item.State
+ });
+ payload.Add(new JsonPatchOperation()
+ {
+ Operation = Operation.Add,
+ Path = "/fields/" + CoreFieldRefNames.History,
+ Value = comment
+ }
+ );
+ _context.Logger.WriteInfo($"Updating workitem {item.Id}");
+ var updatedItem = await _clients.WitClient.UpdateWorkItemAsync(
+ payload,
+ item.Id,
+ bypassRules: impersonate || bypassrules,
+ cancellationToken: cancellationToken
+ );
+ }
+ else
+ {
+ _context.Logger.WriteInfo($"Dry-run mode: should update workitem {item.Id} in {item.TeamProject}");
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs
new file mode 100644
index 00000000..869962a5
--- /dev/null
+++ b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
+using Newtonsoft.Json;
+
+namespace aggregator.Engine.Persistance
+{
+ internal class PersistTwoPhases : PersisterBase
+ {
+ public PersistTwoPhases(EngineContext context)
+ : base(context) { }
+
+ //TODO no error handling here? SaveChanges_Batch has at least the DryRun support and error handling
+ //TODO Improve complex handling with ReplaceIdAndResetChanges and RemapIdReferences
+ internal async Task<(int created, int updated)> PersistAsync(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
+ {
+ // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs
+ // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1
+ // The workitembatchupdate API has a huge limit:
+ // it fails adding a relation between a new (id<0) work item and an existing one (id>0)
+
+ var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
+ int createdCounter = createdWorkItems.Length;
+ int updatedCounter = updatedWorkItems.Length + deletedWorkItems.Length + restoredWorkItems.Length;
+
+ //TODO strange handling, better would be a redesign here: Add links as new Objects and do not create changes when they occur but when accessed to Changes property
+ var batchRequests = new List();
+ foreach (var item in createdWorkItems)
+ {
+ _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {item.TeamProject}");
+
+ //TODO HACK better something like this: _context.Tracker.NewWorkItems.Where(wi => !wi.Relations.HasAdds(toNewItems: true))
+ var changesWithoutRelation = item.Changes
+ .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test)
+ // remove relations as we might incour in API failure
+ .Where(c => !string.Equals(c.Path, "/relations/-", StringComparison.Ordinal));
+ var document = new JsonPatchDocument();
+ document.AddRange(changesWithoutRelation);
+
+ var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName,
+ item.WorkItemType,
+ document,
+ bypassRules: impersonate || bypassrules,
+ suppressNotifications: false);
+ batchRequests.Add(request);
+ }
+
+ if (commit)
+ {
+ var batchResponses = await ExecuteBatchRequest(batchRequests, cancellationToken);
+
+ UpdateIdsInRelations(batchResponses);
+
+ await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
+ }
+ else
+ {
+ _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
+ }
+
+ batchRequests.Clear();
+ var allWorkItems = createdWorkItems.Concat(updatedWorkItems).Concat(restoredWorkItems);
+ foreach (var item in allWorkItems)
+ {
+ var changes = item.Changes
+ .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test);
+ if (!changes.Any())
+ {
+ continue;
+ }
+ _context.Logger.WriteInfo($"Found a request to update workitem {item.Id} in {_context.ProjectName}");
+ _context.Logger.WriteVerbose(JsonConvert.SerializeObject(item.Changes));
+
+ var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id,
+ item.Changes,
+ bypassRules: impersonate || bypassrules,
+ suppressNotifications: false);
+ batchRequests.Add(request);
+ }
+
+ if (commit)
+ {
+ _ = await ExecuteBatchRequest(batchRequests, cancellationToken);
+ }
+ else
+ {
+ _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
+ }
+
+ return (createdCounter, updatedCounter);
+ }
+
+ private void UpdateIdsInRelations(IEnumerable batchResponses)
+ {
+ var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
+ var realIds = createdWorkItems
+ // the response order matches the request order
+ .Zip(batchResponses, (item, response) =>
+ {
+ int oldId = item.Id;
+ var newId = response.ParseBody().Id.Value;
+
+ //TODO oldId should be known by item, and not needed to be passed as parameter
+ item.ReplaceIdAndResetChanges(oldId, newId);
+ return new { oldId, newId };
+ })
+ .ToDictionary(kvp => kvp.oldId, kvp => kvp.newId);
+
+ foreach (var item in updatedWorkItems)
+ {
+ item.RemapIdReferences(realIds);
+ }
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/Persistance/PersisterBase.cs b/src/aggregator-ruleng/Persistance/PersisterBase.cs
new file mode 100644
index 00000000..e12aac9a
--- /dev/null
+++ b/src/aggregator-ruleng/Persistance/PersisterBase.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Newtonsoft.Json;
+
+namespace aggregator.Engine.Persistance
+{
+ internal class PersisterBase
+ {
+ protected readonly EngineContext _context;
+ protected readonly IClientsContext _clients;
+
+ protected PersisterBase(EngineContext context)
+ {
+ _context = context;
+ _clients = _context.Clients;
+ }
+
+ protected async Task RestoreAndDelete(IEnumerable restore, IEnumerable delete, CancellationToken cancellationToken = default)
+ {
+ foreach (var item in delete)
+ {
+ _context.Logger.WriteInfo($"Deleting workitem {item.Id} in {item.TeamProject}");
+ _ = await _clients.WitClient.DeleteWorkItemAsync(item.Id, cancellationToken: cancellationToken);
+ }
+
+ foreach (var item in restore)
+ {
+ _context.Logger.WriteInfo($"Restoring workitem {item.Id} in {item.TeamProject}");
+ _ = await _clients.WitClient.RestoreWorkItemAsync(new WorkItemDeleteUpdate() { IsDeleted = false }, item.Id, cancellationToken: cancellationToken);
+ }
+ }
+
+ protected async Task> ExecuteBatchRequest(IList batchRequests, CancellationToken cancellationToken)
+ {
+ if (!batchRequests.Any()) return Enumerable.Empty();
+
+ var batchResponses = await _clients.WitClient.ExecuteBatchRequest(batchRequests, cancellationToken: cancellationToken);
+
+ var failedResponses = batchResponses.Where(batchResponse => !IsSuccessStatusCode(batchResponse.Code)).ToList();
+ foreach (var failedResponse in failedResponses)
+ {
+ string stringResponse = JsonConvert.SerializeObject(batchResponses, Formatting.None);
+ _context.Logger.WriteVerbose(stringResponse);
+ _context.Logger.WriteError($"Save failed: {failedResponse.Body}");
+ }
+
+ //TODO should we throw exception?
+#pragma warning disable S125 // Sections of code should not be commented out
+ //if (failedResponses.Any())
+ //{
+ // throw new InvalidOperationException("Save failed.");
+ //}
+#pragma warning restore S125 // Sections of code should not be commented out
+ return batchResponses;
+ }
+
+ private static bool IsSuccessStatusCode(int statusCode)
+ {
+ return (statusCode >= 200) && (statusCode <= 299);
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/RelationPatch.cs b/src/aggregator-ruleng/Persistance/RelationPatch.cs
similarity index 100%
rename from src/aggregator-ruleng/RelationPatch.cs
rename to src/aggregator-ruleng/Persistance/RelationPatch.cs
diff --git a/src/aggregator-ruleng/Tracker.cs b/src/aggregator-ruleng/Persistance/Tracker.cs
similarity index 100%
rename from src/aggregator-ruleng/Tracker.cs
rename to src/aggregator-ruleng/Persistance/Tracker.cs
diff --git a/src/aggregator-ruleng/WorkItemId.cs b/src/aggregator-ruleng/RuleObjects/WorkItemId.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemId.cs
rename to src/aggregator-ruleng/RuleObjects/WorkItemId.cs
diff --git a/src/aggregator-ruleng/WorkItemRelationWrapper.cs b/src/aggregator-ruleng/RuleObjects/WorkItemRelationWrapper.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemRelationWrapper.cs
rename to src/aggregator-ruleng/RuleObjects/WorkItemRelationWrapper.cs
diff --git a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/RuleObjects/WorkItemRelationWrapperCollection.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs
rename to src/aggregator-ruleng/RuleObjects/WorkItemRelationWrapperCollection.cs
diff --git a/src/aggregator-ruleng/RuleObjects/WorkItemStateWorkflow.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStateWorkflow.cs
new file mode 100644
index 00000000..eee1675e
--- /dev/null
+++ b/src/aggregator-ruleng/RuleObjects/WorkItemStateWorkflow.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Services.Common;
+using QuikGraph;
+using QuikGraph.Algorithms;
+
+namespace aggregator.Engine;
+
+internal class WorkItemStateWorkflow
+{
+ private readonly string workItemTypeName;
+ public string Name { get; private set; }
+ public string ReferenceName { get; private set; }
+ private IList States { get; } = new List();
+ private AdjacencyGraph> Graph { get; set; }
+
+ public WorkItemStateWorkflow(string workItemTypeName)
+ {
+ this.workItemTypeName = workItemTypeName;
+ }
+
+ public async Task LoadAsync(EngineContext context)
+ {
+ var workItemType = await context.Clients.WitClient.GetWorkItemTypeAsync(context.ProjectName, workItemTypeName);
+ Name = workItemType.Name;
+ ReferenceName = workItemType.ReferenceName;
+ States.Clear();
+ States.AddRange(workItemType.States.Select(s => s.Name));
+ // BUILD GRAPH
+ var edges = new List>();
+ workItemType.Transitions.ForEach(t =>
+ {
+ t.Value.ForEach(st =>
+ {
+ edges.Add(new Edge(t.Key, st.To));
+ });
+ });
+ Graph = edges.ToAdjacencyGraph>();
+ Graph.AddVertexRange(States);
+ return true;
+ }
+
+ public bool HasState(string stateName)
+ {
+ return States.Contains(stateName);
+ }
+
+ internal IEnumerable GetTransitionPath(string currentState, string targetState)
+ {
+ // Constant cost
+ Func, double> edgeCost = edge => 1;
+ TryFunc>> tryGetPaths = Graph.ShortestPathsDijkstra(edgeCost, currentState);
+ if (tryGetPaths(targetState, out var path))
+ {
+ foreach (Edge edge in path)
+ {
+ yield return edge.Target;
+ }
+ }
+ }
+}
diff --git a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs
new file mode 100644
index 00000000..99a8dc98
--- /dev/null
+++ b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs
@@ -0,0 +1,389 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.TeamFoundation.Work.WebApi.Contracts;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Microsoft.VisualStudio.Services.WebApi;
+
+
+namespace aggregator.Engine
+{
+ public class WorkItemStore
+ {
+ private const int VS403474_LIMIT = 200;
+
+ private readonly EngineContext _context;
+ private readonly IClientsContext _clients;
+ private readonly Lazy>> _lazyGetWorkItemCategories;
+ private readonly Lazy>> _lazyGetBacklogWorkItemTypesAndStates;
+ private readonly IDictionary _stateWorkflows = new Dictionary();
+
+ private readonly IdentityRef _triggerIdentity;
+
+
+ public WorkItemStore(EngineContext context)
+ {
+ _context = context;
+ _clients = _context.Clients;
+ _lazyGetWorkItemCategories = new Lazy>>(async () => await GetWorkItemCategories_Internal());
+ _lazyGetBacklogWorkItemTypesAndStates = new Lazy>>(async () => await GetBacklogWorkItemTypesAndStates_Internal());
+ }
+
+ public WorkItemStore(EngineContext context, WorkItem workItem) : this(context)
+ {
+ //initialize tracker with initial work item
+ var wrapper = new WorkItemWrapper(_context, workItem);
+ //store event initiator identity
+ _triggerIdentity = wrapper.ChangedBy;
+ }
+
+ public WorkItemWrapper GetWorkItem(int id)
+ {
+ _context.Logger.WriteVerbose($"Getting workitem {id}");
+
+ return _context.Tracker.LoadWorkItem(id, (workItemId) =>
+ {
+ _context.Logger.WriteInfo($"Loading workitem {workItemId}");
+ var item = _clients.WitClient.GetWorkItemAsync(workItemId, expand: WorkItemExpand.All).Result;
+ return new WorkItemWrapper(_context, item);
+ });
+ }
+
+ public WorkItemWrapper GetWorkItem(WorkItemRelationWrapper item)
+ {
+ return GetWorkItem(item.LinkedId);
+ }
+
+ public IList GetWorkItems(IEnumerable ids)
+ {
+ var accumulator = new List();
+
+ // prevent VS403474: You requested nnn work items which exceeds the limit of 200
+ foreach (var idBlock in ids.Paginate(VS403474_LIMIT))
+ {
+ _context.Logger.WriteVerbose($"Getting workitems {idBlock.ToSeparatedString()}");
+ var workItemBlock = _context.Tracker.LoadWorkItems(idBlock, (workItemIds) =>
+ {
+ _context.Logger.WriteInfo($"Loading workitems {workItemIds.ToSeparatedString()}");
+ var items = _clients.WitClient.GetWorkItemsAsync(workItemIds, expand: WorkItemExpand.All).Result;
+ return items.ConvertAll(i => new WorkItemWrapper(_context, i));
+ });
+
+ accumulator.AddRange(workItemBlock);
+ }
+
+ return accumulator;
+ }
+
+ public IList GetWorkItems(IEnumerable collection)
+ {
+ var ids = collection.Select(relation => relation.LinkedId);
+
+ return GetWorkItems(ids);
+ }
+
+ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = null)
+ {
+ // TODO check workItemType and projectName values by querying AzDO
+ var item = new WorkItem
+ {
+ Fields = new Dictionary
+ {
+ { CoreFieldRefNames.WorkItemType, workItemType },
+ { CoreFieldRefNames.TeamProject, projectName ?? _context.ProjectName }
+ },
+ Relations = new List(),
+ Links = new Microsoft.VisualStudio.Services.WebApi.ReferenceLinks()
+ };
+ var wrapper = new WorkItemWrapper(_context, item);
+ _context.Logger.WriteVerbose($"Made new workitem in {wrapper.TeamProject} with temporary id {wrapper.Id}");
+ //HACK
+ string baseUriString = _clients.WitClient.BaseAddress.AbsoluteUri;
+ item.Url = FormattableString.Invariant($"{baseUriString}/_apis/wit/workitems/{wrapper.Id}");
+ return wrapper;
+ }
+
+ public bool DeleteWorkItem(WorkItemWrapper workItem)
+ {
+ _context.Logger.WriteVerbose($"Mark workitem for Delete {workItem.Id}");
+
+ return ChangeRecycleStatus(workItem, RecycleStatus.ToDelete);
+ }
+
+ public bool RestoreWorkItem(WorkItemWrapper workItem)
+ {
+ _context.Logger.WriteVerbose($"Mark workitem for Restire {workItem.Id}");
+
+ return ChangeRecycleStatus(workItem, RecycleStatus.ToRestore);
+ }
+
+ private static bool ChangeRecycleStatus(WorkItemWrapper workItem, RecycleStatus toRecycleStatus)
+ {
+ if ((toRecycleStatus == RecycleStatus.ToDelete && workItem.IsDeleted) ||
+ (toRecycleStatus == RecycleStatus.ToRestore && !workItem.IsDeleted))
+ {
+ return false;
+ }
+
+ var previousStatus = workItem.RecycleStatus;
+ workItem.RecycleStatus = toRecycleStatus;
+
+ var updated = previousStatus != workItem.RecycleStatus;
+ workItem.IsDirty = updated || workItem.IsDirty;
+ return updated;
+ }
+
+ public async Task TransitionToState(WorkItemWrapper workItem, string targetState, bool bypassrules, string comment)
+ {
+ // sanity checks
+ if (workItem.IsNew)
+ {
+ _context.Logger.WriteError($"WorkItem is new: TransitionToState works only for existing WorkItems");
+ return false;
+ }
+ if (workItem.IsDeleted)
+ {
+ _context.Logger.WriteError($"WorkItem #{workItem.Id} is deleted: TransitionToState works only for existing WorkItems");
+ return false;
+ }
+ if (workItem.Changes.Any(op => op.Path == "/fields/" + CoreFieldRefNames.State))
+ {
+ _context.Logger.WriteError($"WorkItem #{workItem.Id} state has already changed: cannot use TransitionToState");
+ return false;
+ }
+
+ string workItemType = workItem.WorkItemType;
+ if (!_stateWorkflows.TryGetValue(workItemType, out var stateWorkflow))
+ {
+ // cache
+ stateWorkflow = new WorkItemStateWorkflow(workItemType);
+ if (!await stateWorkflow.LoadAsync(_context))
+ {
+ _context.Logger.WriteError($"Failed to retrieve States for work item type '{workItemType}'");
+ return false;
+ }
+ _stateWorkflows.Add(workItemType, stateWorkflow);
+ }
+
+ string currentState = workItem.State;
+
+ if (!stateWorkflow.HasState(currentState))
+ {
+ _context.Logger.WriteError($"Current state '{currentState}' is not valid for work item type '{workItemType}'");
+ return false;
+ }
+ if (!stateWorkflow.HasState(targetState))
+ {
+ _context.Logger.WriteError($"Target state '{targetState}' is not valid for work item type '{workItemType}'");
+ return false;
+ }
+
+ var stateSequence = stateWorkflow.GetTransitionPath(currentState, targetState);
+
+ if (!stateSequence.Any())
+ {
+ _context.Logger.WriteError($"Target state '{targetState}' cannot be reached from '{currentState}' for work item type '{workItemType}'");
+ return false;
+ }
+
+ // finally we can do something about
+
+ if (stateSequence.Count() == 1)
+ {
+ workItem.State = targetState;
+ _context.Logger.WriteInfo($"WorkItem #{workItem.Id} state will change from '{currentState}' to '{targetState}' when Rule exits");
+ // No need for intermediate save
+ return true;
+ }
+
+ // more than one change => need intermediate saves
+ var statePersister = new Persistance.PersistStateChange(_context);
+ bool commit = !_context.DryRun;
+ //TODO resolve catch 22 to pick impersonate value
+ bool impersonate = false;
+ foreach (var nextState in stateSequence)
+ {
+ _context.Logger.WriteVerbose($"Transitioning from '{currentState}' to '{nextState}'");
+ workItem.State = nextState;
+ if (!await statePersister.PersistAsync(workItem, comment, commit, impersonate, bypassrules, _context.CancellationToken))
+ {
+ _context.Logger.WriteError($"Target state '{targetState}' cannot be reached from '{workItemType}'");
+ // we already saved the change, so align in-mem object
+ workItem.ResetValueOfExistingField(CoreFieldRefNames.State, currentState);
+ return false;
+ }
+ _context.Logger.WriteInfo($"Transitioning WorkItem #{workItem.Id} from '{currentState}' to '{nextState}' succeeded");
+ currentState = nextState;
+ }
+ // we already saved the change, so align in-mem object
+ workItem.ResetValueOfExistingField(CoreFieldRefNames.State, targetState);
+
+ return true;
+ }
+
+ public async Task> GetWorkItemCategories()
+ {
+ return await _lazyGetWorkItemCategories.Value;
+ }
+
+ public async Task> GetBacklogWorkItemTypesAndStates()
+ {
+ return await _lazyGetBacklogWorkItemTypesAndStates.Value;
+ }
+
+
+ private void ImpersonateChanges()
+ {
+ var (created, updated, _, _) = _context.Tracker.GetChangedWorkItems();
+
+ var changedWorkItems = created.Concat(updated);
+
+ foreach (var workItem in changedWorkItems)
+ {
+ // emit JsonPatchOperation for this field even if the value is unchanged
+ workItem.ChangedBy = null;
+ workItem.ChangedBy = _triggerIdentity;
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Sonar Code Smell", "S907:\"goto\" statement should not be used")]
+ internal async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
+ {
+ if (impersonate)
+ {
+ ImpersonateChanges();
+ }
+
+ switch (mode)
+ {
+ case SaveMode.Default:
+ _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}.");
+ goto case SaveMode.TwoPhases;
+ case SaveMode.Item:
+ var byItemPersister = new Persistance.PersistByItem(_context);
+ var resultItem = await byItemPersister.PersistAsync(commit, impersonate, bypassrules, cancellationToken);
+ return resultItem;
+ case SaveMode.Batch:
+ var batchPersister = new Persistance.PersistBatch(_context);
+ var resultBatch = await batchPersister.PersistAsync(commit, impersonate, bypassrules, cancellationToken);
+ return resultBatch;
+ case SaveMode.TwoPhases:
+ var twoPhasePersister = new Persistance.PersistTwoPhases(_context);
+ var resultTwoPhases = await twoPhasePersister.PersistAsync(commit, impersonate, bypassrules, cancellationToken);
+ return resultTwoPhases;
+ default:
+ throw new InvalidOperationException($"Unsupported save mode: {mode}.");
+ }
+ }
+
+ private async Task> GetWorkItemCategories_Internal()
+ {
+ var workItemTypeCategories = await _clients.WitClient.GetWorkItemTypeCategoriesAsync(_context.ProjectName);
+ var result = workItemTypeCategories.Select(workItemTypeCategory => new WorkItemTypeCategory()
+ {
+ ReferenceName = workItemTypeCategory.ReferenceName,
+ Name = workItemTypeCategory.Name,
+ WorkItemTypeNames = workItemTypeCategory.WorkItemTypes.Select(wiType => wiType.Name)
+ })
+ .ToList();
+
+ return result;
+ }
+
+ private async Task> GetBacklogWorkItemTypesAndStates_Internal()
+ {
+ var processConfiguration = await _clients.WorkClient.GetProcessConfigurationAsync(_context.ProjectName);
+ var backlogWorkItemTypes = new List(processConfiguration.PortfolioBacklogs)
+ {
+ processConfiguration.BugWorkItems,
+ processConfiguration.RequirementBacklog,
+ processConfiguration.TaskBacklog,
+ };
+
+ var workItemCategoryStates = new List();
+ foreach (var backlog in backlogWorkItemTypes)
+ {
+ var backlogInfo = new BacklogInfo()
+ {
+ Name = backlog.Name,
+ ReferenceName = backlog.ReferenceName
+ };
+
+ foreach (var workItemTypeName in backlog.WorkItemTypes.Select(wt => wt.Name))
+ {
+ var states = await _clients.WitClient.GetWorkItemTypeStatesAsync(_context.ProjectName, workItemTypeName);
+
+ var itemTypeStates = new BacklogWorkItemTypeStates()
+ {
+ Name = workItemTypeName,
+ Backlog = backlogInfo,
+ StateCategoryStateNames = states.ToLookup(state => state.Category)
+ .ToDictionary(kvp => kvp.Key,
+ kvp => kvp.Select(state => state.Name)
+ .ToArray())
+ };
+
+ workItemCategoryStates.Add(itemTypeStates);
+ }
+
+ }
+
+ return workItemCategoryStates;
+ }
+ }
+
+ public class WorkItemTypeCategory
+ {
+ ///
+ /// Category ReferenceName, e.g. "Microsoft.EpicCategory"
+ ///
+ public string ReferenceName { get; set; }
+
+ ///
+ /// Display Name, e.g. "Epic Category"
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// WorkItemTypes in this Category, e.g. "Epic" or "Test Plan"
+ ///
+ public IEnumerable WorkItemTypeNames { get; set; }
+ }
+
+ public class BacklogWorkItemTypeStates
+ {
+ ///
+ /// WorkItem Name, e.g. "Epic"
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Backlog Information this WorkItem Type is in
+ ///
+ public BacklogInfo Backlog { get; set; }
+
+ ///
+ /// Meta-State to WorkItem state name mapping, e.g.
+ /// "InProgress" = "Active", "Resolved"
+ /// "Proposed" = "New"
+ /// "Complete" = "Closed"
+ /// "Resolved"
+ ///
+ public IDictionary StateCategoryStateNames { get; set; }
+ }
+
+ public class BacklogInfo
+ {
+ ///
+ /// The Category Reference Name, e.g. "Microsoft.EpicCategory" or "Microsoft.RequirementCategory"
+ ///
+ public string ReferenceName { get; set; }
+ ///
+ /// The Backlog Level Display Name, e.g. "Epics" or "Stories"
+ ///
+ public string Name { get; set; }
+ }
+
+}
diff --git a/src/aggregator-ruleng/WorkItemUpdateWrapper.cs b/src/aggregator-ruleng/RuleObjects/WorkItemUpdateWrapper.cs
similarity index 100%
rename from src/aggregator-ruleng/WorkItemUpdateWrapper.cs
rename to src/aggregator-ruleng/RuleObjects/WorkItemUpdateWrapper.cs
diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs
similarity index 99%
rename from src/aggregator-ruleng/WorkItemWrapper.cs
rename to src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs
index 53778afb..3d584d0d 100644
--- a/src/aggregator-ruleng/WorkItemWrapper.cs
+++ b/src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs
@@ -389,7 +389,7 @@ private void SetFieldValue(string field, object value)
IsDirty = true;
}
- private void ResetValueOfExistingField(string field, object value)
+ internal void ResetValueOfExistingField(string field, object value)
{
_item.Fields[field] = value;
Changes.RemoveAll(op => op.Path == "/fields/" + field);
diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs
deleted file mode 100644
index d3378be3..00000000
--- a/src/aggregator-ruleng/WorkItemStore.cs
+++ /dev/null
@@ -1,561 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.TeamFoundation.Work.WebApi.Contracts;
-using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
-using Microsoft.VisualStudio.Services.WebApi;
-using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
-using Newtonsoft.Json;
-
-
-namespace aggregator.Engine
-{
- public class WorkItemStore
- {
- private const int VS403474_LIMIT = 200;
-
- private readonly EngineContext _context;
- private readonly IClientsContext _clients;
- private readonly Lazy>> _lazyGetWorkItemCategories;
- private readonly Lazy>> _lazyGetBacklogWorkItemTypesAndStates;
-
- private readonly IdentityRef _triggerIdentity;
-
-
- public WorkItemStore(EngineContext context)
- {
- _context = context;
- _clients = _context.Clients;
- _lazyGetWorkItemCategories = new Lazy>>(async () => await GetWorkItemCategories_Internal());
- _lazyGetBacklogWorkItemTypesAndStates = new Lazy>>(async () => await GetBacklogWorkItemTypesAndStates_Internal());
- }
-
- public WorkItemStore(EngineContext context, WorkItem workItem) : this(context)
- {
- //initialize tracker with initial work item
- var wrapper = new WorkItemWrapper(_context, workItem);
- //store event initiator identity
- _triggerIdentity = wrapper.ChangedBy;
- }
-
- public WorkItemWrapper GetWorkItem(int id)
- {
- _context.Logger.WriteVerbose($"Getting workitem {id}");
-
- return _context.Tracker.LoadWorkItem(id, (workItemId) =>
- {
- _context.Logger.WriteInfo($"Loading workitem {workItemId}");
- var item = _clients.WitClient.GetWorkItemAsync(workItemId, expand: WorkItemExpand.All).Result;
- return new WorkItemWrapper(_context, item);
- });
- }
-
- public WorkItemWrapper GetWorkItem(WorkItemRelationWrapper item)
- {
- return GetWorkItem(item.LinkedId);
- }
-
- public IList GetWorkItems(IEnumerable ids)
- {
- var accumulator = new List();
-
- // prevent VS403474: You requested nnn work items which exceeds the limit of 200
- foreach (var idBlock in ids.Paginate(VS403474_LIMIT))
- {
- _context.Logger.WriteVerbose($"Getting workitems {idBlock.ToSeparatedString()}");
- var workItemBlock = _context.Tracker.LoadWorkItems(idBlock, (workItemIds) =>
- {
- _context.Logger.WriteInfo($"Loading workitems {workItemIds.ToSeparatedString()}");
- var items = _clients.WitClient.GetWorkItemsAsync(workItemIds, expand: WorkItemExpand.All).Result;
- return items.ConvertAll(i => new WorkItemWrapper(_context, i));
- });
-
- accumulator.AddRange(workItemBlock);
- }
-
- return accumulator;
- }
-
- public IList GetWorkItems(IEnumerable collection)
- {
- var ids = collection.Select(relation => relation.LinkedId);
-
- return GetWorkItems(ids);
- }
-
- public WorkItemWrapper NewWorkItem(string workItemType, string projectName = null)
- {
- // TODO check workItemType and projectName values by querying AzDO
- var item = new WorkItem
- {
- Fields = new Dictionary
- {
- { CoreFieldRefNames.WorkItemType, workItemType },
- { CoreFieldRefNames.TeamProject, projectName ?? _context.ProjectName }
- },
- Relations = new List(),
- Links = new Microsoft.VisualStudio.Services.WebApi.ReferenceLinks()
- };
- var wrapper = new WorkItemWrapper(_context, item);
- _context.Logger.WriteVerbose($"Made new workitem in {wrapper.TeamProject} with temporary id {wrapper.Id}");
- //HACK
- string baseUriString = _clients.WitClient.BaseAddress.AbsoluteUri;
- item.Url = FormattableString.Invariant($"{baseUriString}/_apis/wit/workitems/{wrapper.Id}");
- return wrapper;
- }
-
- public bool DeleteWorkItem(WorkItemWrapper workItem)
- {
- _context.Logger.WriteVerbose($"Mark workitem for Delete {workItem.Id}");
-
- return ChangeRecycleStatus(workItem, RecycleStatus.ToDelete);
- }
-
- public bool RestoreWorkItem(WorkItemWrapper workItem)
- {
- _context.Logger.WriteVerbose($"Mark workitem for Restire {workItem.Id}");
-
- return ChangeRecycleStatus(workItem, RecycleStatus.ToRestore);
- }
-
- private static bool ChangeRecycleStatus(WorkItemWrapper workItem, RecycleStatus toRecycleStatus)
- {
- if ((toRecycleStatus == RecycleStatus.ToDelete && workItem.IsDeleted) ||
- (toRecycleStatus == RecycleStatus.ToRestore && !workItem.IsDeleted))
- {
- return false;
- }
-
- var previousStatus = workItem.RecycleStatus;
- workItem.RecycleStatus = toRecycleStatus;
-
- var updated = previousStatus != workItem.RecycleStatus;
- workItem.IsDirty = updated || workItem.IsDirty;
- return updated;
- }
-
- public async Task> GetWorkItemCategories()
- {
- return await _lazyGetWorkItemCategories.Value;
- }
-
- public async Task> GetBacklogWorkItemTypesAndStates()
- {
- return await _lazyGetBacklogWorkItemTypesAndStates.Value;
- }
-
-
- private void ImpersonateChanges()
- {
- var (created, updated, _, _) = _context.Tracker.GetChangedWorkItems();
-
- var changedWorkItems = created.Concat(updated);
-
- foreach (var workItem in changedWorkItems)
- {
- // emit JsonPatchOperation for this field even if the value is unchanged
- workItem.ChangedBy = null;
- workItem.ChangedBy = _triggerIdentity;
- }
- }
-
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Sonar Code Smell", "S907:\"goto\" statement should not be used")]
- public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
- {
- if (impersonate)
- {
- ImpersonateChanges();
- }
-
- switch (mode)
- {
- case SaveMode.Default:
- _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}.");
- goto case SaveMode.TwoPhases;
- case SaveMode.Item:
- var resultItem = await SaveChanges_ByItem(commit, impersonate, bypassrules, cancellationToken);
- return resultItem;
- case SaveMode.Batch:
- var resultBatch = await SaveChanges_Batch(commit, impersonate, bypassrules, cancellationToken);
- return resultBatch;
- case SaveMode.TwoPhases:
- var resultTwoPhases = await SaveChanges_TwoPhases(commit, impersonate, bypassrules, cancellationToken);
- return resultTwoPhases;
- default:
- throw new InvalidOperationException($"Unsupported save mode: {mode}.");
- }
- }
-
- private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
- {
- int createdCounter = 0;
- int updatedCounter = 0;
-
- var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
- foreach (var item in createdWorkItems)
- {
- if (commit)
- {
- _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {item.TeamProject}");
- _ = await _clients.WitClient.CreateWorkItemAsync(
- item.Changes,
- _context.ProjectName,
- item.WorkItemType,
- bypassRules: impersonate || bypassrules,
- cancellationToken: cancellationToken
- );
- }
- else
- {
- _context.Logger.WriteInfo($"Dry-run mode: should create a {item.WorkItemType} workitem in {item.TeamProject}");
- }
-
- createdCounter++;
- }
-
- if (commit)
- {
- await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
- }
- else if (deletedWorkItems.Any() || restoredWorkItems.Any())
- {
- static string FormatIds(WorkItemWrapper[] items) => string.Join(",", items.Select(item => item.Id));
- var teamProjectName = restoredWorkItems.FirstOrDefault()?.TeamProject ??
- deletedWorkItems.FirstOrDefault()?.TeamProject;
- _context.Logger.WriteInfo($"Dry-run mode: should restore: {FormatIds(restoredWorkItems)} and delete {FormatIds(deletedWorkItems)} workitems from {teamProjectName}");
- }
- updatedCounter += restoredWorkItems.Length + deletedWorkItems.Length;
-
- foreach (var item in updatedWorkItems.Concat(restoredWorkItems))
- {
- if (commit)
- {
- _context.Logger.WriteInfo($"Updating workitem {item.Id}");
- _ = await _clients.WitClient.UpdateWorkItemAsync(
- item.Changes,
- item.Id,
- bypassRules: impersonate || bypassrules,
- cancellationToken: cancellationToken
- );
- }
- else
- {
- _context.Logger.WriteInfo($"Dry-run mode: should update workitem {item.Id} in {item.TeamProject}");
- }
-
- updatedCounter++;
- }
-
- return (createdCounter, updatedCounter);
- }
-
- private async Task<(int created, int updated)> SaveChanges_Batch(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
- {
- // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs
- // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1
- // BUG this code won't work if there is a relation between a new (id<0) work item and an existing one (id>0): it is an API limit
-
- var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
- int createdCounter = createdWorkItems.Length;
- int updatedCounter = updatedWorkItems.Length + deletedWorkItems.Length + restoredWorkItems.Length;
-
- List batchRequests = new List();
- foreach (var item in createdWorkItems)
- {
- _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {item.TeamProject}");
-
- var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName,
- item.WorkItemType,
- item.Changes,
- bypassRules: impersonate,
- suppressNotifications: false);
- batchRequests.Add(request);
- }
-
- foreach (var item in updatedWorkItems)
- {
- _context.Logger.WriteInfo($"Found a request to update workitem {item.Id} in {item.TeamProject}");
-
- var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id,
- item.Changes,
- bypassRules: impersonate || bypassrules,
- suppressNotifications: false);
- batchRequests.Add(request);
- }
-
- var converters = new JsonConverter[] { new JsonPatchOperationConverter() };
- string requestBody = JsonConvert.SerializeObject(batchRequests, Formatting.None, converters);
- _context.Logger.WriteVerbose(requestBody);
-
- if (commit)
- {
- _ = await ExecuteBatchRequest(batchRequests, cancellationToken);
- await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
- }
- else
- {
- _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
- }//if
-
- return (createdCounter, updatedCounter);
- }
-
- private static bool IsSuccessStatusCode(int statusCode)
- {
- return (statusCode >= 200) && (statusCode <= 299);
- }
-
- //TODO no error handling here? SaveChanges_Batch has at least the DryRun support and error handling
- //TODO Improve complex handling with ReplaceIdAndResetChanges and RemapIdReferences
- private async Task<(int created, int updated)> SaveChanges_TwoPhases(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken)
- {
- // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs
- // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1
- // The workitembatchupdate API has a huge limit:
- // it fails adding a relation between a new (id<0) work item and an existing one (id>0)
-
- var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
- int createdCounter = createdWorkItems.Length;
- int updatedCounter = updatedWorkItems.Length + deletedWorkItems.Length + restoredWorkItems.Length;
-
- //TODO strange handling, better would be a redesign here: Add links as new Objects and do not create changes when they occur but when accessed to Changes property
- var batchRequests = new List();
- foreach (var item in createdWorkItems)
- {
- _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {item.TeamProject}");
-
- //TODO HACK better something like this: _context.Tracker.NewWorkItems.Where(wi => !wi.Relations.HasAdds(toNewItems: true))
- var changesWithoutRelation = item.Changes
- .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test)
- // remove relations as we might incour in API failure
- .Where(c => !string.Equals(c.Path, "/relations/-", StringComparison.Ordinal));
- var document = new JsonPatchDocument();
- document.AddRange(changesWithoutRelation);
-
- var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName,
- item.WorkItemType,
- document,
- bypassRules: impersonate || bypassrules,
- suppressNotifications: false);
- batchRequests.Add(request);
- }
-
- if (commit)
- {
- var batchResponses = await ExecuteBatchRequest(batchRequests, cancellationToken);
-
- UpdateIdsInRelations(batchResponses);
-
- await RestoreAndDelete(restoredWorkItems, deletedWorkItems, cancellationToken);
- }
- else
- {
- _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
- }
-
- batchRequests.Clear();
- var allWorkItems = createdWorkItems.Concat(updatedWorkItems).Concat(restoredWorkItems);
- foreach (var item in allWorkItems)
- {
- var changes = item.Changes
- .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test);
- if (!changes.Any())
- {
- continue;
- }
- _context.Logger.WriteInfo($"Found a request to update workitem {item.Id} in {_context.ProjectName}");
- _context.Logger.WriteVerbose(JsonConvert.SerializeObject(item.Changes));
-
- var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id,
- item.Changes,
- bypassRules: impersonate || bypassrules,
- suppressNotifications: false);
- batchRequests.Add(request);
- }
-
- if (commit)
- {
- _ = await ExecuteBatchRequest(batchRequests, cancellationToken);
- }
- else
- {
- _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps.");
- }
-
- return (createdCounter, updatedCounter);
- }
-
- private async Task> ExecuteBatchRequest(IList batchRequests, CancellationToken cancellationToken)
- {
- if (!batchRequests.Any()) return Enumerable.Empty();
-
- var batchResponses = await _clients.WitClient.ExecuteBatchRequest(batchRequests, cancellationToken: cancellationToken);
-
- var failedResponses = batchResponses.Where(batchResponse => !IsSuccessStatusCode(batchResponse.Code)).ToList();
- foreach (var failedResponse in failedResponses)
- {
- string stringResponse = JsonConvert.SerializeObject(batchResponses, Formatting.None);
- _context.Logger.WriteVerbose(stringResponse);
- _context.Logger.WriteError($"Save failed: {failedResponse.Body}");
- }
-
- //TODO should we throw exception?
-#pragma warning disable S125 // Sections of code should not be commented out
- //if (failedResponses.Any())
- //{
- // throw new InvalidOperationException("Save failed.");
- //}
-#pragma warning restore S125 // Sections of code should not be commented out
- return batchResponses;
- }
-
-
- private async Task RestoreAndDelete(IEnumerable restore, IEnumerable delete, CancellationToken cancellationToken = default)
- {
- foreach (var item in delete)
- {
- _context.Logger.WriteInfo($"Deleting workitem {item.Id} in {item.TeamProject}");
- _ = await _clients.WitClient.DeleteWorkItemAsync(item.Id, cancellationToken: cancellationToken);
- }
-
- foreach (var item in restore)
- {
- _context.Logger.WriteInfo($"Restoring workitem {item.Id} in {item.TeamProject}");
- _ = await _clients.WitClient.RestoreWorkItemAsync(new WorkItemDeleteUpdate() { IsDeleted = false }, item.Id, cancellationToken: cancellationToken);
- }
- }
-
- private void UpdateIdsInRelations(IEnumerable batchResponses)
- {
- var (createdWorkItems, updatedWorkItems, deletedWorkItems, restoredWorkItems) = _context.Tracker.GetChangedWorkItems();
- var realIds = createdWorkItems
- // the response order matches the request order
- .Zip(batchResponses, (item, response) =>
- {
- int oldId = item.Id;
- var newId = response.ParseBody().Id.Value;
-
- //TODO oldId should be known by item, and not needed to be passed as parameter
- item.ReplaceIdAndResetChanges(oldId, newId);
- return new { oldId, newId };
- })
- .ToDictionary(kvp => kvp.oldId, kvp => kvp.newId);
-
- foreach (var item in updatedWorkItems)
- {
- item.RemapIdReferences(realIds);
- }
- }
-
- private async Task> GetWorkItemCategories_Internal()
- {
- var workItemTypeCategories = await _clients.WitClient.GetWorkItemTypeCategoriesAsync(_context.ProjectName);
- var result = workItemTypeCategories.Select(workItemTypeCategory => new WorkItemTypeCategory()
- {
- ReferenceName = workItemTypeCategory.ReferenceName,
- Name = workItemTypeCategory.Name,
- WorkItemTypeNames = workItemTypeCategory.WorkItemTypes.Select(wiType => wiType.Name)
- })
- .ToList();
-
- return result;
- }
-
- private async Task> GetBacklogWorkItemTypesAndStates_Internal()
- {
- var processConfiguration = await _clients.WorkClient.GetProcessConfigurationAsync(_context.ProjectName);
- var backlogWorkItemTypes = new List(processConfiguration.PortfolioBacklogs)
- {
- processConfiguration.BugWorkItems,
- processConfiguration.RequirementBacklog,
- processConfiguration.TaskBacklog,
- };
-
- var workItemCategoryStates = new List();
- foreach (var backlog in backlogWorkItemTypes)
- {
- var backlogInfo = new BacklogInfo()
- {
- Name = backlog.Name,
- ReferenceName = backlog.ReferenceName
- };
-
- foreach (var workItemTypeName in backlog.WorkItemTypes.Select(wt=>wt.Name))
- {
- var states = await _clients.WitClient.GetWorkItemTypeStatesAsync(_context.ProjectName, workItemTypeName);
-
- var itemTypeStates = new BacklogWorkItemTypeStates()
- {
- Name = workItemTypeName,
- Backlog = backlogInfo,
- StateCategoryStateNames = states.ToLookup(state => state.Category)
- .ToDictionary(kvp => kvp.Key,
- kvp => kvp.Select(state => state.Name)
- .ToArray())
- };
-
- workItemCategoryStates.Add(itemTypeStates);
- }
-
- }
-
- return workItemCategoryStates;
- }
-
- }
-
- public class WorkItemTypeCategory
- {
- ///
- /// Category ReferenceName, e.g. "Microsoft.EpicCategory"
- ///
- public string ReferenceName { get; set; }
-
- ///
- /// Display Name, e.g. "Epic Category"
- ///
- public string Name { get; set; }
-
- ///
- /// WorkItemTypes in this Category, e.g. "Epic" or "Test Plan"
- ///
- public IEnumerable WorkItemTypeNames { get; set; }
- }
-
- public class BacklogWorkItemTypeStates
- {
- ///
- /// WorkItem Name, e.g. "Epic"
- ///
- public string Name { get; set; }
-
- ///
- /// Backlog Information this WorkItem Type is in
- ///
- public BacklogInfo Backlog { get; set; }
-
- ///
- /// Meta-State to WorkItem state name mapping, e.g.
- /// "InProgress" = "Active", "Resolved"
- /// "Proposed" = "New"
- /// "Complete" = "Closed"
- /// "Resolved"
- ///
- public IDictionary StateCategoryStateNames { get; set; }
- }
-
- public class BacklogInfo
- {
- ///
- /// The Category Reference Name, e.g. "Microsoft.EpicCategory" or "Microsoft.RequirementCategory"
- ///
- public string ReferenceName { get; set; }
- ///
- /// The Backlog Level Display Name, e.g. "Epics" or "Stories"
- ///
- public string Name { get; set; }
- }
-
-}
diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj
index dedec4b4..0c57ce7b 100644
--- a/src/aggregator-ruleng/aggregator-ruleng.csproj
+++ b/src/aggregator-ruleng/aggregator-ruleng.csproj
@@ -6,12 +6,7 @@
latest
Aggregator Rule Engine
- TFS Aggregator Team
- Aggregator CLI
- Copyright © TFS Aggregator Team
Azure DevOps Aggregator Rule Engine Interpreter
- 0.0.1
- localdev
@@ -35,6 +30,7 @@
+
diff --git a/src/aggregator-shared/GlobalAttributes.cs b/src/aggregator-shared/GlobalAttributes.cs
index 5495f35b..19bb867e 100644
--- a/src/aggregator-shared/GlobalAttributes.cs
+++ b/src/aggregator-shared/GlobalAttributes.cs
@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
-[assembly: InternalsVisibleTo("unittests-ruleng")]
\ No newline at end of file
+[assembly: InternalsVisibleTo("unittests-ruleng")]
\ No newline at end of file
diff --git a/src/aggregator-shared/aggregator-shared.csproj b/src/aggregator-shared/aggregator-shared.csproj
index 9fbf43e5..0f202332 100644
--- a/src/aggregator-shared/aggregator-shared.csproj
+++ b/src/aggregator-shared/aggregator-shared.csproj
@@ -6,12 +6,7 @@
aggregator-shared
Aggregator Shared
- TFS Aggregator Team
- Aggregator CLI
- Copyright © TFS Aggregator Team
Azure DevOps Aggregator Shared Types
- 0.0.1
- localdev
diff --git a/src/aggregator-webshared/aggregator-webshared.csproj b/src/aggregator-webshared/aggregator-webshared.csproj
index 3f302a58..17599cdc 100644
--- a/src/aggregator-webshared/aggregator-webshared.csproj
+++ b/src/aggregator-webshared/aggregator-webshared.csproj
@@ -5,12 +5,7 @@
aggregator
Aggregator Server Common
- TFS Aggregator Team
- Aggregator CLI
- Copyright © TFS Aggregator Team
Shared code between Azure Function and ASP.NET
- 0.0.1
- localdev
..\.sonarlint\tfsaggregator_aggregator-clicsharp.ruleset
diff --git a/src/aggregator3.sln b/src/aggregator3.sln
index 2e17ddf4..e86b1e1b 100644
--- a/src/aggregator3.sln
+++ b/src/aggregator3.sln
@@ -18,7 +18,9 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{020591FD-B34A-49E6-98E5-D8DD2A838997}"
ProjectSection(SolutionItems) = preProject
.build-trigger = .build-trigger
+ aggregator-function\aggregator-manifest.ini = aggregator-function\aggregator-manifest.ini
..\.github\workflows\build-and-deploy.yml = ..\.github\workflows\build-and-deploy.yml
+ Directory.Build.props = Directory.Build.props
..\.config\dotnet-tools.json = ..\.config\dotnet-tools.json
global.json = global.json
..\build\local-build.ps1 = ..\build\local-build.ps1
diff --git a/src/integrationtests-cli/End2EndScenarioBase.cs b/src/integrationtests-cli/End2EndScenarioBase.cs
index fccedb1f..c9935ca9 100644
--- a/src/integrationtests-cli/End2EndScenarioBase.cs
+++ b/src/integrationtests-cli/End2EndScenarioBase.cs
@@ -1,8 +1,12 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Net;
+using System.Net.Http;
using System.Text.RegularExpressions;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Services.Common;
using Xunit.Abstractions;
@@ -24,6 +28,11 @@ protected End2EndScenarioBase(ITestOutputHelper output)
_output = output;
}
+ protected void WriteLineToOutput(string message)
+ {
+ _output.WriteLine(message);
+ }
+
protected async Task<(int rc, string output)> RunAggregatorCommand(string commandLine, IEnumerable<(string, string)> env = default)
{
// see https://stackoverflow.com/a/14655145/100864
@@ -57,5 +66,66 @@ protected End2EndScenarioBase(ITestOutputHelper output)
return (rc, output);
}
+
+ protected async Task<(int rc, string output)> RunAggregatorProcess(string exeDirectory, string arguments, IEnumerable<(string, string)> env = default)
+ {
+ string exeName = "aggregator-cli";
+ if (Environment.OSVersion.Platform == PlatformID.Win32NT)
+ exeName += ".exe";
+ var p = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = Path.Combine(Path.GetFullPath(exeDirectory), exeName),
+ Arguments = arguments,
+ WorkingDirectory = exeDirectory,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ }
+ };
+ if (p.Start())
+ {
+ await p.WaitForExitAsync();
+ var buf = new System.Text.StringBuilder();
+ while (!p.StandardOutput.EndOfStream)
+ {
+ string line = p.StandardOutput.ReadLine();
+ buf.AppendLine(line);
+ _output.WriteLine(line);
+ }
+ while (!p.StandardError.EndOfStream)
+ {
+ string line = p.StandardError.ReadLine();
+ buf.AppendLine(line);
+ _output.WriteLine(line);
+ }
+ return (p.ExitCode, buf.ToString());
+ }
+ return (99, string.Empty);
+ }
+
+ protected async Task DownloadFile(string sourceUrl, string destinationFile, CancellationToken cancellationToken)
+ {
+ using var client = new HttpClient();
+ using var request = new HttpRequestMessage(HttpMethod.Get, sourceUrl);
+ using var response = await client.SendAsync(request, cancellationToken);
+ switch (response.StatusCode)
+ {
+ case HttpStatusCode.OK:
+ _output.WriteLine($"Downloading file from {sourceUrl}");
+ using (var fileStream = File.Create(destinationFile))
+ {
+ await response.Content.CopyToAsync(fileStream, cancellationToken);
+ }
+ _output.WriteLine($"File downloaded.");
+ break;
+
+ default:
+ _output.WriteLine($"{sourceUrl} returned {response.ReasonPhrase}.");
+ break;
+ }//switch
+ }
}
}
diff --git a/src/integrationtests-cli/Scenario1_Minimal.cs b/src/integrationtests-cli/Scenario1_Minimal.cs
index 9a77e523..953edc59 100644
--- a/src/integrationtests-cli/Scenario1_Minimal.cs
+++ b/src/integrationtests-cli/Scenario1_Minimal.cs
@@ -42,7 +42,7 @@ async Task Logon()
[Fact, Order(10)]
- async Task InstallInstances()
+ async Task InstallInstance()
{
(int rc, string output) = await RunAggregatorCommand($"install.instance --verbose --name {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --location {TestLogonData.Location}"
+ (string.IsNullOrWhiteSpace(TestLogonData.RuntimeSourceUrl)
@@ -54,7 +54,7 @@ async Task InstallInstances()
}
[Fact, Order(20)]
- async Task AddRules()
+ async Task AddRule()
{
(int rc, string output) = await RunAggregatorCommand($"add.rule --verbose --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --name {ruleName} --file {ruleFile}");
@@ -63,7 +63,7 @@ async Task AddRules()
}
[Fact, Order(30)]
- async Task MapRules()
+ async Task MapRule()
{
(int rc, string output) = await RunAggregatorCommand($"map.rule --verbose --project \"{TestLogonData.ProjectName}\" --event workitem.created --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --rule {ruleName}");
diff --git a/src/integrationtests-cli/Scenario2_Upgrade.cs b/src/integrationtests-cli/Scenario2_Upgrade.cs
new file mode 100644
index 00000000..1b81e181
--- /dev/null
+++ b/src/integrationtests-cli/Scenario2_Upgrade.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+using XUnitPriorityOrderer;
+
+namespace integrationtests.cli
+{
+ public abstract class Scenario2_Base : End2EndScenarioBase
+ {
+ protected readonly string instancePrefix = "mintest";
+ protected readonly string ruleName = "test4";
+ protected readonly string ruleFile = "test4.rule";
+ protected readonly string runtimeFilename = "FunctionRuntime.zip";
+ protected readonly string instanceName;
+ protected readonly string oldVersionDir;
+ protected string PreviousVersionRuntimeFile => Path.Combine(oldVersionDir, runtimeFilename);
+
+ protected Scenario2_Base(ITestOutputHelper output)
+ : base(output)
+ {
+ oldVersionDir = Path.Combine(Path.GetTempPath(), TestLogonData.UniqueSuffix);
+ instanceName = instancePrefix + TestLogonData.UniqueSuffix;
+ }
+
+ protected async Task<(int rc, string output)> RunOldAggregator(string arguments, IEnumerable<(string, string)> env = default)
+ => await base.RunAggregatorProcess(oldVersionDir, arguments, env);
+ }
+
+ [TestCaseOrderer(CasePriorityOrderer.TypeName, CasePriorityOrderer.AssembyName)]
+ public class Scenario2_Upgrade : Scenario2_Base
+ {
+ public Scenario2_Upgrade(ITestOutputHelper output)
+ : base(output)
+ {
+ }
+
+ [Fact, Order(1)]
+ async Task DownloadOldVersion()
+ {
+ string package = Environment.OSVersion.Platform switch
+ {
+ PlatformID.Win32NT => "aggregator-cli-win-x64.zip",
+ PlatformID.Unix => "aggregator-cli-linux-x64.zip",
+ PlatformID.MacOSX => "aggregator-cli-osx-x64.zip",
+ _ => throw new Exception()
+ };
+ string packageURL = $"https://github.com/tfsaggregator/aggregator-cli/releases/download/v{TestLogonData.VersionToUpgrade}/{package}";
+ string packageFile = Path.GetTempFileName();
+ var cancellationToken = CancellationToken.None;
+ await DownloadFile(packageURL, packageFile, cancellationToken);
+ System.IO.Compression.ZipFile.ExtractToDirectory(packageFile, oldVersionDir, true);
+ File.Delete(packageFile);
+ string runtimeURL = $"https://github.com/tfsaggregator/aggregator-cli/releases/download/v{TestLogonData.VersionToUpgrade}/{runtimeFilename}";
+ await DownloadFile(runtimeURL, PreviousVersionRuntimeFile, cancellationToken);
+ }
+
+ [Fact, Order(5)]
+ async Task LogonToOldVersion()
+ {
+ (int rc, string output) = await RunOldAggregator(
+ $"logon.azure --subscription {TestLogonData.SubscriptionId} --client {TestLogonData.ClientId} --password {TestLogonData.ClientSecret} --tenant {TestLogonData.TenantId}");
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ (int rc2, string output2) = await RunOldAggregator(
+ $"logon.ado --url {TestLogonData.DevOpsUrl} --mode PAT --token {TestLogonData.PAT}");
+ Assert.Equal(0, rc2);
+ Assert.DoesNotContain("] Failed!", output2);
+ }
+
+ [Fact, Order(10)]
+ async Task InstallOldVersionInstance()
+ {
+ (int rc, string output) = await RunOldAggregator($"install.instance --verbose --name {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --location {TestLogonData.Location}"
+ + (string.IsNullOrWhiteSpace(TestLogonData.RuntimeSourceUrl)
+ ? string.Empty
+ : $" --sourceUrl {PreviousVersionRuntimeFile}"));
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(20)]
+ async Task AddRuleToOldVersion()
+ {
+ string ruleFullPath = Path.Combine(Directory.GetCurrentDirectory(), ruleFile);
+ (int rc, string output) = await RunOldAggregator($"add.rule --verbose --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --name {ruleName} --file {ruleFullPath}");
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(30)]
+ async Task MapRuleToOldVersion()
+ {
+ (int rc, string output) = await RunOldAggregator($"map.rule --verbose --project \"{TestLogonData.ProjectName}\" --event workitem.created --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --rule {ruleName}");
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(40)]
+ async Task TriggerRuleInOldVersion()
+ {
+ (int rc, string output) = await RunOldAggregator($"test.create --verbose --resourceGroup {TestLogonData.ResourceGroup} --instance {instanceName} --project \"{TestLogonData.ProjectName}\" --rule {ruleName} ");
+ Assert.Equal(0, rc);
+ // Sample output from rule:
+ // Returning 'Hello Task #118 from Rule 5!' from 'TestRule5'
+ Assert.Contains($"Returning 'Hello Task #", output);
+ Assert.Contains($"!' from '{ruleName}'", output);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(45)]
+ async Task LogonToCurrent()
+ {
+ (int rc, string output) = await RunAggregatorCommand(
+ $"logon.azure --subscription {TestLogonData.SubscriptionId} --client {TestLogonData.ClientId} --password {TestLogonData.ClientSecret} --tenant {TestLogonData.TenantId}");
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ (int rc2, string output2) = await RunAggregatorCommand(
+ $"logon.ado --url {TestLogonData.DevOpsUrl} --mode PAT --token {TestLogonData.PAT}");
+ Assert.Equal(0, rc2);
+ Assert.DoesNotContain("] Failed!", output2);
+ }
+
+
+ [Fact, Order(50)]
+ async Task UpgradeInstanceToLatestVersion()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"update.instance --verbose --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup}"
+ + (string.IsNullOrWhiteSpace(TestLogonData.RuntimeSourceUrl)
+ ? string.Empty
+ : $" --sourceUrl {TestLogonData.RuntimeSourceUrl}"));
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(60)]
+ async Task TriggerRuleInUpgradedInstance()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"test.create --verbose --resourceGroup {TestLogonData.ResourceGroup} --instance {instanceName} --project \"{TestLogonData.ProjectName}\" --rule {ruleName} ");
+ Assert.Equal(0, rc);
+ // Sample output from rule:
+ // Returning 'Hello Task #118 from Rule 5!' from 'TestRule5'
+ Assert.Contains($"Returning 'Hello Task #", output);
+ Assert.Contains($"!' from '{ruleName}'", output);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(99)]
+ async Task FinalCleanUp()
+ {
+ (_, _) = await RunAggregatorCommand($"unmap.rule --verbose --project \"{TestLogonData.ProjectName}\" --event * --rule * --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup}");
+ (int rc, _) = await RunAggregatorCommand($"test.cleanup --verbose --resourceGroup {TestLogonData.ResourceGroup} ");
+ WriteLineToOutput($"Deleting {oldVersionDir}");
+ Directory.Delete(oldVersionDir, true);
+ Assert.Equal(0, rc);
+ }
+ }
+}
diff --git a/src/integrationtests-cli/Scenario5_Rules.cs b/src/integrationtests-cli/Scenario5_Rules.cs
new file mode 100644
index 00000000..6d050ab9
--- /dev/null
+++ b/src/integrationtests-cli/Scenario5_Rules.cs
@@ -0,0 +1,141 @@
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+using XUnitPriorityOrderer;
+
+namespace integrationtests.cli
+{
+
+ public class Scenario5WorkItem
+ {
+ public int WorkItemId = 0;
+ }
+
+ public abstract class Scenario5_Base : End2EndScenarioBase, IClassFixture
+ {
+ protected readonly string instancePrefix = "rulestest";
+ protected readonly string ruleName = "test6";
+ protected readonly string ruleFile = "test6.rule";
+ protected readonly string instanceName;
+ protected Scenario5WorkItem wiData;
+ protected Scenario5_Base(Scenario5WorkItem wiData, ITestOutputHelper output)
+ : base(output)
+ {
+ instanceName = instancePrefix + TestLogonData.UniqueSuffix;
+ if (TestLogonData.WorkItemId > 0) wiData.WorkItemId = TestLogonData.WorkItemId;
+ this.wiData = wiData;
+ }
+ }
+
+ [TestCaseOrderer(CasePriorityOrderer.TypeName, CasePriorityOrderer.AssembyName)]
+ public class Scenario5_Rules : Scenario5_Base
+ {
+ public Scenario5_Rules(Scenario5WorkItem wiData, ITestOutputHelper output)
+ : base(wiData, output)
+ {
+ }
+
+ [Fact, Order(1)]
+ async Task Logon()
+ {
+ (int rc, string output) = await RunAggregatorCommand(
+ $"logon.azure --subscription {TestLogonData.SubscriptionId} --client {TestLogonData.ClientId} --password {TestLogonData.ClientSecret} --tenant {TestLogonData.TenantId}");
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ (int rc2, string output2) = await RunAggregatorCommand(
+ $"logon.ado --url {TestLogonData.DevOpsUrl} --mode PAT --token {TestLogonData.PAT}");
+ Assert.Equal(0, rc2);
+ Assert.DoesNotContain("] Failed!", output2);
+ }
+
+
+ [Fact, Order(10)]
+ async Task InstallInstance()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"install.instance --verbose --name {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --location {TestLogonData.Location}"
+ + (string.IsNullOrWhiteSpace(TestLogonData.RuntimeSourceUrl)
+ ? string.Empty
+ : $" --sourceUrl {TestLogonData.RuntimeSourceUrl}"));
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(20)]
+ async Task AddRule()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"add.rule --verbose --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --name {ruleName} --file {ruleFile}");
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(30)]
+ async Task MapRuleForCreate()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"map.rule --verbose --project \"{TestLogonData.ProjectName}\" --event workitem.created --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --rule {ruleName}");
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(32)]
+ async Task MapRuleForUpdate()
+ {
+ (int rc, string output) = await RunAggregatorCommand($"map.rule --verbose --project \"{TestLogonData.ProjectName}\" --event workitem.updated --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup} --rule {ruleName}");
+
+ Assert.Equal(0, rc);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(40)]
+ async Task CreateWorkItemAndCheckTrigger()
+ {
+ (int retVal, string output) = await RunAggregatorCommand($"test.create --verbose --resourceGroup {TestLogonData.ResourceGroup} --instance {instanceName} --project \"{TestLogonData.ProjectName}\" --rule {ruleName} --returnId");
+ wiData.WorkItemId = retVal;
+ //+DEBUG
+ System.Console.WriteLine($"WorkItemId is {wiData.WorkItemId}");
+ WriteLineToOutput($"WorkItemId is {wiData.WorkItemId}");
+ //-DEBUG
+ Assert.NotEqual(0, wiData.WorkItemId);
+ Assert.Contains($"Returning 'Hello Task #{wiData.WorkItemId} from Rule", output);
+ Assert.Contains($" from '{ruleName}'", output);
+ Assert.DoesNotContain("] Failed!", output);
+ }
+
+ [Fact, Order(50)]
+ async Task TriggerRuleTransitionToStateClosed()
+ {
+ //+DEBUG
+ System.Console.WriteLine($"WorkItemId is {wiData.WorkItemId}");
+ WriteLineToOutput($"WorkItemId is {wiData.WorkItemId}");
+ //-DEBUG
+ string newValue = "CloseMe";
+ (int rc, string output) = await RunAggregatorCommand($"test.update --verbose --resourceGroup {TestLogonData.ResourceGroup} --instance {instanceName} --project \"{TestLogonData.ProjectName}\" --id {wiData.WorkItemId} --title \"{newValue}\" --rule {ruleName} ");
+ Assert.Equal(0, rc);
+ Assert.Contains($"TransitionToState Closed", output);
+ Assert.Contains($"WorkItem #{wiData.WorkItemId} state will change from 'New' to 'Closed' when Rule exits", output);
+ Assert.DoesNotContain($"TransitionToState failed!", output);
+ }
+
+ [Fact, Order(51)]
+ async Task TriggerRuleTransitionFromClosedToRemoved()
+ {
+ string newValue = "DropMe";
+ (int rc, string output) = await RunAggregatorCommand($"test.update --verbose --resourceGroup {TestLogonData.ResourceGroup} --instance {instanceName} --project \"{TestLogonData.ProjectName}\" --id {wiData.WorkItemId} --title \"{newValue}\" --rule {ruleName} ");
+ Assert.Equal(0, rc);
+ Assert.Contains($"Transitioning WorkItem #{wiData.WorkItemId} from 'Closed' to 'Active' succeeded", output);
+ Assert.Contains($"Transitioning WorkItem #{wiData.WorkItemId} from 'Active' to 'Removed' succeeded", output);
+ Assert.Contains($"TransitionToState Removed", output);
+ Assert.DoesNotContain($"TransitionToState failed!", output);
+ }
+
+ [Fact, Order(99)]
+ async Task FinalCleanUp()
+ {
+ (_, _) = await RunAggregatorCommand($"unmap.rule --verbose --project \"{TestLogonData.ProjectName}\" --event * --rule * --instance {instanceName} --resourceGroup {TestLogonData.ResourceGroup}");
+ (int rc, _) = await RunAggregatorCommand($"test.cleanup --verbose --resourceGroup {TestLogonData.ResourceGroup} ");
+ Assert.Equal(0, rc);
+ }
+ }
+}
diff --git a/src/integrationtests-cli/TestLogonData.cs b/src/integrationtests-cli/TestLogonData.cs
index d59292ef..cce21e98 100644
--- a/src/integrationtests-cli/TestLogonData.cs
+++ b/src/integrationtests-cli/TestLogonData.cs
@@ -23,10 +23,14 @@ public TestLogonData(string filename)
PAT = data.pat;
string uniqueSuffix = data.uniqueSuffix;
-
UniqueSuffix = string.IsNullOrEmpty(uniqueSuffix) ? GetRandomString(8) : uniqueSuffix;
RuntimeSourceUrl = data.runtimeSourceUrl;
+ string versionToUpgrade = data.versionToUpgrade;
+ VersionToUpgrade = string.IsNullOrEmpty(versionToUpgrade) ? "1.1.0" : versionToUpgrade;
+
+ string workItemId = data.workItemId;
+ WorkItemId = string.IsNullOrEmpty(workItemId) ? 0 : int.Parse(workItemId);
}
private static string GetRandomString(int size, string allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789")
@@ -52,7 +56,10 @@ private static string GetRandomString(int size, string allowedChars = "abcdefghi
public string DevOpsUrl { get; }
public string ProjectName { get; }
public string PAT { get; }
+ public int WorkItemId { get; }
+
// Local data
public string RuntimeSourceUrl { get; }
+ public string VersionToUpgrade { get; }
}
}
diff --git a/src/integrationtests-cli/integrationtests-cli.csproj b/src/integrationtests-cli/integrationtests-cli.csproj
index 67bf193c..c5cbd999 100644
--- a/src/integrationtests-cli/integrationtests-cli.csproj
+++ b/src/integrationtests-cli/integrationtests-cli.csproj
@@ -3,7 +3,6 @@
net6.0
integrationtests.cli
-
false
@@ -33,6 +32,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/src/integrationtests-cli/test6.rule b/src/integrationtests-cli/test6.rule
new file mode 100644
index 00000000..d1d25624
--- /dev/null
+++ b/src/integrationtests-cli/test6.rule
@@ -0,0 +1,17 @@
+if (eventType == "workitem.created") {
+ return $"Hello { self.WorkItemType } #{ self.Id } from Rule 6!";
+}
+
+if (eventType == "workitem.updated" && selfChanges.Fields.ContainsKey("System.Title")) {
+
+ string newTitle = selfChanges.Fields["System.Title"].NewValue.ToString();
+ if (newTitle == "CloseMe") {
+ bool ok = await store.TransitionToState(self, "Closed", false, "Closed by rule");
+ return ok ? "TransitionToState Closed" : "TransitionToState failed!";
+ }
+ if (newTitle == "DropMe") {
+ bool ok = await store.TransitionToState(self, "Removed", false, "Removed by rule");
+ return ok ? "TransitionToState Removed" : "TransitionToState failed!";
+ }
+
+}
diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs
index 189ef3a8..4683adf7 100644
--- a/src/unittests-ruleng/RuleTests.cs
+++ b/src/unittests-ruleng/RuleTests.cs
@@ -73,6 +73,33 @@ public async Task HelloWorldRule_Succeeds()
await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any());
}
+ [Fact]
+ public async Task PrintRuleName_Succeeds()
+ {
+ string eventType = ServiceHooksEventTypeConstants.WorkItemCreated;
+ int workItemId = 42;
+ WorkItem workItem = new WorkItem
+ {
+ Id = workItemId,
+ Fields = new Dictionary
+ {
+ { "System.WorkItemType", "Bug" },
+ { "System.Title", "Hello" },
+ { "System.TeamProject", clientsContext.ProjectName },
+ }
+ };
+ witClient.GetWorkItemAsync(workItemId, expand: WorkItemExpand.All).Returns(workItem);
+ string ruleCode = @"
+return $""Hello {self.WorkItemType} #{self.Id} - {self.Title} from {ruleName}!"";
+";
+
+ var rule = new ScriptedRuleWrapper("MyTestRule", ruleCode.Mince());
+ string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, eventType, clientsContext, CancellationToken.None);
+
+ Assert.Equal("Hello Bug #42 - Hello from MyTestRule!", result);
+ await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any());
+ }
+
[Fact]
public async Task LanguageDirective_Succeeds()
{
diff --git a/src/unittests-ruleng/TransitionToStateTests.cs b/src/unittests-ruleng/TransitionToStateTests.cs
new file mode 100644
index 00000000..2b42b1e1
--- /dev/null
+++ b/src/unittests-ruleng/TransitionToStateTests.cs
@@ -0,0 +1,346 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using aggregator;
+using aggregator.Engine;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
+using NSubstitute;
+using Xunit;
+
+namespace unittests_ruleng;
+
+public class TransitionToStateTests
+{
+ private readonly IAggregatorLogger Logger;
+ private readonly WorkItemTrackingHttpClient WitClient;
+ private readonly TestClientsContext DefaultClientsContext;
+ private readonly EngineContext DefaultEngineContext;
+ private readonly WorkItemType TaskWorkItemType;
+
+ public TransitionToStateTests()
+ {
+ Logger = Substitute.For();
+
+ DefaultClientsContext = new TestClientsContext();
+
+ WitClient = DefaultClientsContext.WitClient;
+ WitClient.ExecuteBatchRequest(default).ReturnsForAnyArgs(info => new List());
+
+ DefaultEngineContext = new EngineContext(DefaultClientsContext, DefaultClientsContext.ProjectId, DefaultClientsContext.ProjectName, Logger, new RuleSettings(), false, default(CancellationToken));
+
+ TaskWorkItemType = new WorkItemType
+ {
+ Name = "Task",
+ ReferenceName = "Microsoft.VSTS.WorkItemTypes.Task",
+ IsDisabled = false,
+ Color = "A4880A",
+ States = new WorkItemStateColor[]
+ {
+ new WorkItemStateColor { Name = "Proposed", Category = "Proposed" },
+ new WorkItemStateColor { Name = "Active", Category = "InProgress" },
+ new WorkItemStateColor { Name = "Resolved", Category = "InProgress" },
+ new WorkItemStateColor { Name = "Closed", Category = "Completed" },
+ },
+ Transitions = new Dictionary
+ {
+ { "Active",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Active" },
+ new WorkItemStateTransition { To = "Closed" },
+ new WorkItemStateTransition { To = "Resolved" },
+ new WorkItemStateTransition { To = "Proposed" },
+ }},
+ { "Closed",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Closed" },
+ new WorkItemStateTransition { To = "Active" },
+ new WorkItemStateTransition { To = "Proposed" },
+ }},
+ { "Proposed",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Proposed" },
+ new WorkItemStateTransition { To = "Closed" },
+ new WorkItemStateTransition { To = "Active" },
+ }},
+ { "Resolved",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Resolved" },
+ new WorkItemStateTransition { To = "Closed" },
+ new WorkItemStateTransition { To = "Active" },
+ }},
+ { "", new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Proposed" },
+ }},
+ }
+ };
+ WitClient.GetWorkItemTypeAsync(DefaultClientsContext.ProjectName, TaskWorkItemType.Name).Returns(TaskWorkItemType);
+ }
+
+ [Fact]
+ public async Task WorkItemStateWorkflow_CanLoad_Task()
+ {
+ string workItemType = "Task";
+ var stateInfo = new WorkItemStateWorkflow(workItemType);
+
+ bool ok = await stateInfo.LoadAsync(DefaultEngineContext);
+
+ Assert.True(ok);
+ }
+
+ [Fact]
+ public async Task WorkItemStateWorkflow_Task_HasExpectedStates()
+ {
+ var stateInfo = new WorkItemStateWorkflow(TaskWorkItemType.Name);
+
+ await stateInfo.LoadAsync(DefaultEngineContext);
+
+ Assert.True(stateInfo.HasState("Proposed"));
+ Assert.True(stateInfo.HasState("Active"));
+ Assert.True(stateInfo.HasState("Resolved"));
+ Assert.True(stateInfo.HasState("Closed"));
+ }
+
+ [Fact]
+ public async Task WorkItemStateWorkflow_Task_CanTransitionFromActiveToResolved()
+ {
+ var stateInfo = new WorkItemStateWorkflow(TaskWorkItemType.Name);
+
+ await stateInfo.LoadAsync(DefaultEngineContext);
+ var path = stateInfo.GetTransitionPath("Active", "Resolved")?.ToArray();
+
+ Assert.NotNull(path);
+ Assert.Single(path);
+ Assert.Contains("Resolved", path);
+ }
+
+ [Fact]
+ public async Task WorkItemStateWorkflow_Task_CanTransitionFromProposedToResolved()
+ {
+ var stateInfo = new WorkItemStateWorkflow(TaskWorkItemType.Name);
+
+ await stateInfo.LoadAsync(DefaultEngineContext);
+ var path = stateInfo.GetTransitionPath("Proposed", "Resolved")?.ToArray();
+
+ Assert.NotNull(path);
+ Assert.Equal(2, path.Count());
+ Assert.Contains("Active", path);
+ Assert.Contains("Resolved", path);
+ }
+
+ // HAPPY PATH #1
+ [Fact]
+ public async Task TransitionToState_Task_TransitionFromActiveToResolved_NoSave()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Active" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Resolved", false, "Some comment");
+
+ Assert.True(ok);
+ await WitClient.DidNotReceiveWithAnyArgs()
+ .UpdateWorkItemAsync(Arg.Any(), Arg.Any());
+ Logger.Received()
+ .WriteInfo("WorkItem #42 state will change from 'Active' to 'Resolved' when Rule exits");
+ // TODO Assert.Empty(workItemWrap.Changes);
+ }
+
+ // HAPPY PATH #2
+ [Fact]
+ public async Task TransitionToState_Task_TransitionFromProposedToResolved_SaveTwice()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Proposed" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Resolved", false, "Some comment");
+
+ Assert.True(ok);
+ await WitClient.Received()
+ .UpdateWorkItemAsync(Arg.Any(), 42, null, false);
+ Logger.Received()
+ .WriteVerbose("Transitioning from 'Proposed' to 'Active'");
+ Logger.Received()
+ .WriteInfo("Transitioning WorkItem #42 from 'Proposed' to 'Active' succeeded");
+ Logger.Received()
+ .WriteVerbose("Transitioning from 'Active' to 'Resolved'");
+ Logger.Received()
+ .WriteInfo("Transitioning WorkItem #42 from 'Active' to 'Resolved' succeeded");
+ }
+
+ // TODO argument checking
+ [Fact]
+ public async Task TransitionToState_Task_InvalidCurrentState_Fails()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "DoesntExist" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Resolved", false, "Some comment");
+
+ Assert.False(ok);
+ Logger.Received()
+ .WriteError($"Current state 'DoesntExist' is not valid for work item type '{TaskWorkItemType.Name}'");
+ }
+
+ [Fact]
+ public async Task TransitionToState_Task_InvalidTargetState_Fails()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Closed" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "DoesntExist", false, "Some comment");
+
+ Assert.False(ok);
+ Logger.Received()
+ .WriteError($"Target state 'DoesntExist' is not valid for work item type '{TaskWorkItemType.Name}'");
+ }
+
+ [Fact]
+ public async Task TransitionToState_NewWorkItem_Fails()
+ {
+ var sut = new WorkItemStore(DefaultEngineContext);
+ var wi = sut.NewWorkItem("Task");
+ wi.Title = "Brand new";
+
+ bool ok = await sut.TransitionToState(wi, "Resolved", false, "Some comment");
+
+ Assert.False(ok);
+ Logger.Received()
+ .WriteError("WorkItem is new: TransitionToState works only for existing WorkItems");
+ }
+
+ [Fact]
+ public async Task TransitionToState_DeletedWorkItem_Fails()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Url = "/recyclebin/42",
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Closed" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Resolved", false, "Some comment");
+
+ Assert.False(ok);
+ Logger.Received()
+ .WriteError("WorkItem #42 is deleted: TransitionToState works only for existing WorkItems");
+ }
+
+ [Fact]
+ public async Task TransitionToState_StateChangedWorkItem_Fails()
+ {
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Closed" },
+ { CoreFieldRefNames.WorkItemType, TaskWorkItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ workItemWrap.State = "Active";
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Resolved", false, "Some comment");
+
+ Assert.False(ok);
+ Logger.Received()
+ .WriteError("WorkItem #42 state has already changed: cannot use TransitionToState");
+ }
+
+ [Fact]
+ public async Task TransitionToState_NoPossibleTransition_Fails()
+ {
+ var workItemType = new WorkItemType
+ {
+ Name = "CustomMade",
+ ReferenceName = "Acme.WorkItemTypes.CustomMade",
+ IsDisabled = false,
+ Color = "A4880A",
+ States = new WorkItemStateColor[]
+ {
+ new WorkItemStateColor { Name = "Proposed", Category = "Proposed" },
+ new WorkItemStateColor { Name = "Active", Category = "InProgress" },
+ new WorkItemStateColor { Name = "Closed", Category = "Completed" },
+ },
+ Transitions = new Dictionary
+ {
+ { "Active",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Active" },
+ new WorkItemStateTransition { To = "Closed" },
+ }},
+ { "Closed",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Closed" },
+ }},
+ { "Proposed",new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Proposed" },
+ new WorkItemStateTransition { To = "Active" },
+ }},
+ { "", new WorkItemStateTransition[] {
+ new WorkItemStateTransition { To = "Proposed" },
+ }},
+ }
+ };
+ WitClient.GetWorkItemTypeAsync(DefaultClientsContext.ProjectName, workItemType.Name).Returns(workItemType);
+ var workItem = new WorkItem
+ {
+ Id = 42,
+ Fields = new Dictionary()
+ {
+ { CoreFieldRefNames.State, "Closed" },
+ { CoreFieldRefNames.WorkItemType, workItemType.Name },
+ }
+ };
+ var workItemWrap = new WorkItemWrapper(DefaultEngineContext, workItem);
+ var sut = new WorkItemStore(DefaultEngineContext);
+
+ bool ok = await sut.TransitionToState(workItemWrap, "Proposed", false, "Some comment");
+
+ Assert.False(ok);
+ await WitClient.DidNotReceiveWithAnyArgs()
+ .UpdateWorkItemAsync(Arg.Any(), Arg.Any());
+ Logger.Received()
+ .WriteError($"Target state 'Proposed' cannot be reached from 'Closed' for work item type '{workItemType.Name}'");
+ }
+
+}
diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs
index dbecd056..86b92e5f 100644
--- a/src/unittests-ruleng/WorkItemStoreTests.cs
+++ b/src/unittests-ruleng/WorkItemStoreTests.cs
@@ -20,6 +20,7 @@ public class WorkItemStoreTests
private readonly IAggregatorLogger logger;
private readonly WorkItemTrackingHttpClient witClient;
private readonly TestClientsContext clientsContext;
+ private readonly EngineContext engineDefaultContext;
public WorkItemStoreTests()
{
@@ -29,6 +30,8 @@ public WorkItemStoreTests()
witClient = clientsContext.WitClient;
witClient.ExecuteBatchRequest(default).ReturnsForAnyArgs(info => new List());
+
+ engineDefaultContext = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings(), false, default(CancellationToken));
}
@@ -42,7 +45,7 @@ public void GetWorkItem_ById_Succeeds()
Fields = new Dictionary()
});
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wi = sut.GetWorkItem(workItemId);
@@ -70,7 +73,7 @@ public void GetWorkItems_ByIds_Succeeds()
}
});
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wis = sut.GetWorkItems(ids);
@@ -101,7 +104,7 @@ public void GetWorkItems_ByIds_LessThan200_Succeeds()
);
var ids = Enumerable.Range(1, 199).ToArray();
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wis = sut.GetWorkItems(ids);
@@ -122,7 +125,7 @@ public void GetWorkItems_ByIds_MoreThan200_Succeeds()
);
var ids = Enumerable.Range(1, 350).ToArray();
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wis = sut.GetWorkItems(ids);
@@ -144,7 +147,7 @@ public void GetWorkItems_ByIds_MoreThan400_Succeeds()
);
var ids = Enumerable.Range(1, 433).ToArray();
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wis = sut.GetWorkItems(ids);
@@ -160,7 +163,7 @@ public void GetWorkItems_ByIds_MoreThan400_Succeeds()
public async Task NewWorkItem_Succeeds()
{
witClient.ExecuteBatchRequest(default).ReturnsForAnyArgs(info => new List());
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var sut = new WorkItemStore(context);
var wi = sut.NewWorkItem("Task");
@@ -177,7 +180,7 @@ public async Task NewWorkItem_Succeeds()
[Fact]
public void AddChild_Succeeds()
{
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
int workItemId = 1;
witClient.GetWorkItemAsync(workItemId, expand: WorkItemExpand.All).Returns(new WorkItem
{
@@ -216,7 +219,7 @@ public void AddChild_Succeeds()
[Fact]
public void DeleteWorkItem_Succeeds()
{
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var workItem = ExampleTestData.WorkItem;
int workItemId = workItem.Id.Value;
@@ -240,7 +243,7 @@ public void DeleteWorkItem_Succeeds()
[Fact]
public void DeleteAlreadyDeletedWorkItem_NoChange()
{
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var workItem = ExampleTestData.DeltedWorkItem;
int workItemId = workItem.Id.Value;
@@ -264,7 +267,7 @@ public void DeleteAlreadyDeletedWorkItem_NoChange()
[Fact]
public void RestoreNotDeletedWorkItem_NoChange()
{
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ var context = engineDefaultContext;
var workItem = ExampleTestData.WorkItem;
int workItemId = workItem.Id.Value;
@@ -310,7 +313,7 @@ public async Task UpdateWorkItem_WithRevisionCheck_Enabled_Succeeds()
);
});
var ruleSettings = new RuleSettings { EnableRevisionCheck = true };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
var workItem = ExampleTestData.WorkItem;
int workItemId = workItem.Id.Value;
@@ -351,7 +354,7 @@ public async Task UpdateWorkItem_WithRevisionCheck_Disabled_Succeeds()
);
});
var ruleSettings = new RuleSettings { EnableRevisionCheck = false };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
var workItem = ExampleTestData.WorkItem;
int workItemId = workItem.Id.Value;
diff --git a/src/unittests-ruleng/WorkItemWrapperTests.cs b/src/unittests-ruleng/WorkItemWrapperTests.cs
index 385c592c..92546dfb 100644
--- a/src/unittests-ruleng/WorkItemWrapperTests.cs
+++ b/src/unittests-ruleng/WorkItemWrapperTests.cs
@@ -27,7 +27,7 @@ public WorkItemWrapperTests()
witClient = clientsContext.WitClient;
witClient.ExecuteBatchRequest(default).ReturnsForAnyArgs(info => new List());
- context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings());
+ context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, new RuleSettings(), false, default(System.Threading.CancellationToken));
}
[Fact]
@@ -341,7 +341,7 @@ public void ChangingAFieldWithEnableRevisionCheckOnAddsTestOperation()
{
var logger = Substitute.For();
var ruleSettings = new RuleSettings { EnableRevisionCheck = true };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
int workItemId = 42;
WorkItem workItem = new WorkItem
@@ -376,7 +376,7 @@ public void ChangingAFieldWithEnableRevisionCheckOffHasNoTestOperation()
{
var logger = Substitute.For();
var ruleSettings = new RuleSettings { EnableRevisionCheck = false };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
int workItemId = 42;
WorkItem workItem = new WorkItem
@@ -409,7 +409,7 @@ public void ChangingAPulledFieldTwiceHasASingleReplaceOperation(string firstValu
{
var logger = Substitute.For();
var ruleSettings = new RuleSettings { EnableRevisionCheck = false };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
int workItemId = 42;
WorkItem workItem = new WorkItem
@@ -443,7 +443,7 @@ public void ChangingANewFieldTwiceHasASingleAddOperation(string firstValue)
{
var logger = Substitute.For();
var ruleSettings = new RuleSettings { EnableRevisionCheck = false };
- var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings);
+ var context = new EngineContext(clientsContext, clientsContext.ProjectId, clientsContext.ProjectName, logger, ruleSettings, false, default(System.Threading.CancellationToken));
int workItemId = 42;
WorkItem workItem = new WorkItem