From 965c2c1e6311c1162bd4f6366b73a6c5bb5f1ff2 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 26 Mar 2022 14:10:46 +0000 Subject: [PATCH 01/14] re-organize files in ruleng project --- .../{ => Engine}/EngineContext.cs | 0 .../{ => Engine}/RuleEngine.cs | 0 .../{ => Engine}/RuleExecutionContext.cs | 0 .../{ => Engine}/RuleExecutor.cs | 0 .../{ => Engine}/ScriptedRuleWrapper.cs | 0 .../{ => Engine}/WorkItemData.cs | 0 .../{ => Engine}/WorkItemEventContext.cs | 0 src/aggregator-ruleng/EnumerableExtension.cs | 23 ------------------- .../EnumerableExtension.cs} | 21 ++++++++++++----- .../Extensions/WorkItemExtension.cs | 17 ++++++++++++++ .../{ => Interfaces}/IAggregatorLogger.cs | 0 .../{ => Interfaces}/IRule.cs | 0 .../{ => Interfaces}/IRuleProvider.cs | 0 .../{ => Interfaces}/IRuleResult.cs | 0 .../{ => Interfaces}/IRuleSettings.cs | 0 .../IPreprocessedRule.cs | 0 .../{Language => Parsing}/PreprocessedRule.cs | 0 .../PreprocessedRuleExtensions.cs | 0 .../{Language => Parsing}/RuleFileParser.cs | 0 .../{Language => Parsing}/RuleLanguage.cs | 0 .../{ => Parsing}/RuleSettings.cs | 0 .../JsonPatchOperationConverter.cs | 0 .../{ => Persistance}/RelationPatch.cs | 0 .../{ => Persistance}/Tracker.cs | 0 .../{ => RuleObjects}/WorkItemId.cs | 0 .../WorkItemRelationWrapper.cs | 0 .../WorkItemRelationWrapperCollection.cs | 0 .../{ => RuleObjects}/WorkItemStore.cs | 0 .../WorkItemUpdateWrapper.cs | 0 .../{ => RuleObjects}/WorkItemWrapper.cs | 0 30 files changed, 32 insertions(+), 29 deletions(-) rename src/aggregator-ruleng/{ => Engine}/EngineContext.cs (100%) rename src/aggregator-ruleng/{ => Engine}/RuleEngine.cs (100%) rename src/aggregator-ruleng/{ => Engine}/RuleExecutionContext.cs (100%) rename src/aggregator-ruleng/{ => Engine}/RuleExecutor.cs (100%) rename src/aggregator-ruleng/{ => Engine}/ScriptedRuleWrapper.cs (100%) rename src/aggregator-ruleng/{ => Engine}/WorkItemData.cs (100%) rename src/aggregator-ruleng/{ => Engine}/WorkItemEventContext.cs (100%) delete mode 100644 src/aggregator-ruleng/EnumerableExtension.cs rename src/aggregator-ruleng/{Extensions.cs => Extensions/EnumerableExtension.cs} (70%) create mode 100644 src/aggregator-ruleng/Extensions/WorkItemExtension.cs rename src/aggregator-ruleng/{ => Interfaces}/IAggregatorLogger.cs (100%) rename src/aggregator-ruleng/{ => Interfaces}/IRule.cs (100%) rename src/aggregator-ruleng/{ => Interfaces}/IRuleProvider.cs (100%) rename src/aggregator-ruleng/{ => Interfaces}/IRuleResult.cs (100%) rename src/aggregator-ruleng/{ => Interfaces}/IRuleSettings.cs (100%) rename src/aggregator-ruleng/{Language => Parsing}/IPreprocessedRule.cs (100%) rename src/aggregator-ruleng/{Language => Parsing}/PreprocessedRule.cs (100%) rename src/aggregator-ruleng/{Language => Parsing}/PreprocessedRuleExtensions.cs (100%) rename src/aggregator-ruleng/{Language => Parsing}/RuleFileParser.cs (100%) rename src/aggregator-ruleng/{Language => Parsing}/RuleLanguage.cs (100%) rename src/aggregator-ruleng/{ => Parsing}/RuleSettings.cs (100%) rename src/aggregator-ruleng/{ => Persistance}/JsonPatchOperationConverter.cs (100%) rename src/aggregator-ruleng/{ => Persistance}/RelationPatch.cs (100%) rename src/aggregator-ruleng/{ => Persistance}/Tracker.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemId.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemRelationWrapper.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemRelationWrapperCollection.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemStore.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemUpdateWrapper.cs (100%) rename src/aggregator-ruleng/{ => RuleObjects}/WorkItemWrapper.cs (100%) diff --git a/src/aggregator-ruleng/EngineContext.cs b/src/aggregator-ruleng/Engine/EngineContext.cs similarity index 100% rename from src/aggregator-ruleng/EngineContext.cs rename to src/aggregator-ruleng/Engine/EngineContext.cs diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/Engine/RuleEngine.cs similarity index 100% rename from src/aggregator-ruleng/RuleEngine.cs rename to src/aggregator-ruleng/Engine/RuleEngine.cs diff --git a/src/aggregator-ruleng/RuleExecutionContext.cs b/src/aggregator-ruleng/Engine/RuleExecutionContext.cs similarity index 100% rename from src/aggregator-ruleng/RuleExecutionContext.cs rename to src/aggregator-ruleng/Engine/RuleExecutionContext.cs 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..d1bdaa6e --- /dev/null +++ b/src/aggregator-ruleng/Extensions/WorkItemExtension.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +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/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/WorkItemStore.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs similarity index 100% rename from src/aggregator-ruleng/WorkItemStore.cs rename to src/aggregator-ruleng/RuleObjects/WorkItemStore.cs 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 100% rename from src/aggregator-ruleng/WorkItemWrapper.cs rename to src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs From 861ed543e9853ef789f39ac58062eb139dbc2904 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 26 Mar 2022 15:38:10 +0000 Subject: [PATCH 02/14] Refactor: move persistance code outside WorkItemStore --- .../Persistance/PersistBatch.cs | 70 +++++ .../Persistance/PersistByItem.cs | 82 ++++++ .../Persistance/PersistTwoPhases.cs | 121 ++++++++ .../Persistance/PersisterBase.cs | 69 +++++ .../RuleObjects/WorkItemStore.cs | 270 +----------------- 5 files changed, 348 insertions(+), 264 deletions(-) create mode 100644 src/aggregator-ruleng/Persistance/PersistBatch.cs create mode 100644 src/aggregator-ruleng/Persistance/PersistByItem.cs create mode 100644 src/aggregator-ruleng/Persistance/PersistTwoPhases.cs create mode 100644 src/aggregator-ruleng/Persistance/PersisterBase.cs diff --git a/src/aggregator-ruleng/Persistance/PersistBatch.cs b/src/aggregator-ruleng/Persistance/PersistBatch.cs new file mode 100644 index 00000000..7dc0d4dc --- /dev/null +++ b/src/aggregator-ruleng/Persistance/PersistBatch.cs @@ -0,0 +1,70 @@ +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.Persistance +{ + internal class PersistBatch : PersisterBase + { + public PersistBatch(EngineContext context) + : base(context) { } + + internal 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); + } + } +} diff --git a/src/aggregator-ruleng/Persistance/PersistByItem.cs b/src/aggregator-ruleng/Persistance/PersistByItem.cs new file mode 100644 index 00000000..f617e9bf --- /dev/null +++ b/src/aggregator-ruleng/Persistance/PersistByItem.cs @@ -0,0 +1,82 @@ +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.Persistance +{ + internal class PersistByItem : PersisterBase + { + public PersistByItem(EngineContext context) + : base(context) { } + + internal 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); + } + } +} diff --git a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs new file mode 100644 index 00000000..e61f1543 --- /dev/null +++ b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs @@ -0,0 +1,121 @@ +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.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)> 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 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..4b6bc525 --- /dev/null +++ b/src/aggregator-ruleng/Persistance/PersisterBase.cs @@ -0,0 +1,69 @@ +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.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/RuleObjects/WorkItemStore.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs index d3378be3..38964646 100644 --- a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs +++ b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs @@ -175,280 +175,22 @@ private void ImpersonateChanges() _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); + var saver = new Persistance.PersistByItem(_context); + var resultItem = await saver.SaveChanges_ByItem(commit, impersonate, bypassrules, cancellationToken); return resultItem; case SaveMode.Batch: - var resultBatch = await SaveChanges_Batch(commit, impersonate, bypassrules, cancellationToken); + var saver2 = new Persistance.PersistBatch(_context); + var resultBatch = await saver2.SaveChanges_Batch(commit, impersonate, bypassrules, cancellationToken); return resultBatch; case SaveMode.TwoPhases: - var resultTwoPhases = await SaveChanges_TwoPhases(commit, impersonate, bypassrules, cancellationToken); + var saver3 = new Persistance.PersistTwoPhases(_context); + var resultTwoPhases = await saver3.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); From a54b81a33eea7321168e083d2fc776dbf98b3758 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 26 Mar 2022 15:42:15 +0000 Subject: [PATCH 03/14] clean up using --- .../Authentication/ApiKeyAuthenticationHandler.cs | 1 - src/aggregator-ruleng/Extensions/WorkItemExtension.cs | 5 +---- src/aggregator-ruleng/Persistance/PersistBatch.cs | 7 +------ src/aggregator-ruleng/Persistance/PersistByItem.cs | 9 +-------- src/aggregator-ruleng/Persistance/PersistTwoPhases.cs | 2 -- src/aggregator-ruleng/Persistance/PersisterBase.cs | 6 +----- src/aggregator-ruleng/RuleObjects/WorkItemStore.cs | 2 -- src/aggregator-shared/GlobalAttributes.cs | 2 +- 8 files changed, 5 insertions(+), 29 deletions(-) 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-ruleng/Extensions/WorkItemExtension.cs b/src/aggregator-ruleng/Extensions/WorkItemExtension.cs index d1bdaa6e..f4264d9f 100644 --- a/src/aggregator-ruleng/Extensions/WorkItemExtension.cs +++ b/src/aggregator-ruleng/Extensions/WorkItemExtension.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; diff --git a/src/aggregator-ruleng/Persistance/PersistBatch.cs b/src/aggregator-ruleng/Persistance/PersistBatch.cs index 7dc0d4dc..f3cffa37 100644 --- a/src/aggregator-ruleng/Persistance/PersistBatch.cs +++ b/src/aggregator-ruleng/Persistance/PersistBatch.cs @@ -1,12 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; 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.Persistance diff --git a/src/aggregator-ruleng/Persistance/PersistByItem.cs b/src/aggregator-ruleng/Persistance/PersistByItem.cs index f617e9bf..b10129e2 100644 --- a/src/aggregator-ruleng/Persistance/PersistByItem.cs +++ b/src/aggregator-ruleng/Persistance/PersistByItem.cs @@ -1,13 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; +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.Persistance { diff --git a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs index e61f1543..467f4aff 100644 --- a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs +++ b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs @@ -3,9 +3,7 @@ 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; diff --git a/src/aggregator-ruleng/Persistance/PersisterBase.cs b/src/aggregator-ruleng/Persistance/PersisterBase.cs index 4b6bc525..e12aac9a 100644 --- a/src/aggregator-ruleng/Persistance/PersisterBase.cs +++ b/src/aggregator-ruleng/Persistance/PersisterBase.cs @@ -1,12 +1,8 @@ -using System; -using System.Collections.Generic; +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.Persistance diff --git a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs index 38964646..7436a070 100644 --- a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs +++ b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs @@ -6,8 +6,6 @@ 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 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 From c672c3e3bbac83512a4df493fd5a72b1c6eee104 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 27 Mar 2022 13:25:03 +0100 Subject: [PATCH 04/14] final refactoring --- src/aggregator-ruleng/Persistance/PersistBatch.cs | 2 +- .../Persistance/PersistByItem.cs | 2 +- .../Persistance/PersistTwoPhases.cs | 2 +- .../RuleObjects/WorkItemStore.cs | 15 +++++++-------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/aggregator-ruleng/Persistance/PersistBatch.cs b/src/aggregator-ruleng/Persistance/PersistBatch.cs index f3cffa37..0ae150ce 100644 --- a/src/aggregator-ruleng/Persistance/PersistBatch.cs +++ b/src/aggregator-ruleng/Persistance/PersistBatch.cs @@ -11,7 +11,7 @@ internal class PersistBatch : PersisterBase public PersistBatch(EngineContext context) : base(context) { } - internal async Task<(int created, int updated)> SaveChanges_Batch(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken) + 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 diff --git a/src/aggregator-ruleng/Persistance/PersistByItem.cs b/src/aggregator-ruleng/Persistance/PersistByItem.cs index b10129e2..9a3b9fc7 100644 --- a/src/aggregator-ruleng/Persistance/PersistByItem.cs +++ b/src/aggregator-ruleng/Persistance/PersistByItem.cs @@ -9,7 +9,7 @@ internal class PersistByItem : PersisterBase public PersistByItem(EngineContext context) : base(context) { } - internal async Task<(int created, int updated)> SaveChanges_ByItem(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken) + internal async Task<(int created, int updated)> PersistAsync(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken) { int createdCounter = 0; int updatedCounter = 0; diff --git a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs index 467f4aff..869962a5 100644 --- a/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs +++ b/src/aggregator-ruleng/Persistance/PersistTwoPhases.cs @@ -16,7 +16,7 @@ public PersistTwoPhases(EngineContext 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)> SaveChanges_TwoPhases(bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken) + 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 diff --git a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs index 7436a070..6fd14883 100644 --- a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs +++ b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs @@ -173,16 +173,16 @@ private void ImpersonateChanges() _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}."); goto case SaveMode.TwoPhases; case SaveMode.Item: - var saver = new Persistance.PersistByItem(_context); - var resultItem = await saver.SaveChanges_ByItem(commit, impersonate, bypassrules, cancellationToken); + var byItemPersister = new Persistance.PersistByItem(_context); + var resultItem = await byItemPersister.PersistAsync(commit, impersonate, bypassrules, cancellationToken); return resultItem; case SaveMode.Batch: - var saver2 = new Persistance.PersistBatch(_context); - var resultBatch = await saver2.SaveChanges_Batch(commit, impersonate, bypassrules, cancellationToken); + var batchPersister = new Persistance.PersistBatch(_context); + var resultBatch = await batchPersister.PersistAsync(commit, impersonate, bypassrules, cancellationToken); return resultBatch; case SaveMode.TwoPhases: - var saver3 = new Persistance.PersistTwoPhases(_context); - var resultTwoPhases = await saver3.SaveChanges_TwoPhases(commit, impersonate, bypassrules, cancellationToken); + 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}."); @@ -222,7 +222,7 @@ private async Task> GetBacklogWorkItemTyp ReferenceName = backlog.ReferenceName }; - foreach (var workItemTypeName in backlog.WorkItemTypes.Select(wt=>wt.Name)) + foreach (var workItemTypeName in backlog.WorkItemTypes.Select(wt => wt.Name)) { var states = await _clients.WitClient.GetWorkItemTypeStatesAsync(_context.ProjectName, workItemTypeName); @@ -243,7 +243,6 @@ private async Task> GetBacklogWorkItemTyp return workItemCategoryStates; } - } public class WorkItemTypeCategory From 24cc634a3837416a6a919ba5563aa2eed589ed68 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 27 Mar 2022 13:27:29 +0100 Subject: [PATCH 05/14] Extend EngineContext with DryRun and CancellationToken --- src/aggregator-ruleng/Engine/EngineContext.cs | 7 ++++- src/aggregator-ruleng/Engine/RuleEngine.cs | 6 ++--- src/unittests-ruleng/WorkItemStoreTests.cs | 27 ++++++++++--------- src/unittests-ruleng/WorkItemWrapperTests.cs | 10 +++---- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/aggregator-ruleng/Engine/EngineContext.cs b/src/aggregator-ruleng/Engine/EngineContext.cs index 68d89c0f..da7628e7 100644 --- a/src/aggregator-ruleng/Engine/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/Engine/RuleEngine.cs b/src/aggregator-ruleng/Engine/RuleEngine.cs index 3b3399cd..cc26770c 100644 --- a/src/aggregator-ruleng/Engine/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); 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 From 22341fc19fc9a9d6615b731fe68a011df4c3d8ef Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 27 Mar 2022 13:28:30 +0100 Subject: [PATCH 06/14] NEW: ruleName variable available to Rules --- src/aggregator-ruleng/Engine/RuleEngine.cs | 1 + .../Engine/RuleExecutionContext.cs | 1 + src/unittests-ruleng/RuleTests.cs | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/aggregator-ruleng/Engine/RuleEngine.cs b/src/aggregator-ruleng/Engine/RuleEngine.cs index cc26770c..2b34f87f 100644 --- a/src/aggregator-ruleng/Engine/RuleEngine.cs +++ b/src/aggregator-ruleng/Engine/RuleEngine.cs @@ -47,6 +47,7 @@ protected RuleExecutionContext CreateRuleExecutionContext(IRule rule, Guid proje var globals = new RuleExecutionContext { + ruleName = rule.Name, self = self, selfChanges = selfChanges, store = store, diff --git a/src/aggregator-ruleng/Engine/RuleExecutionContext.cs b/src/aggregator-ruleng/Engine/RuleExecutionContext.cs index f1658357..1f701824 100644 --- a/src/aggregator-ruleng/Engine/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/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() { From 4d15e3e442fb74376a1534d267af7b8c79b83d94 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 27 Mar 2022 16:41:08 +0100 Subject: [PATCH 07/14] TransitionToState method --- .../Persistance/PersistStateChange.cs | 47 +++ .../RuleObjects/WorkItemStateWorkflow.cs | 63 ++++ .../RuleObjects/WorkItemStore.cs | 91 ++++- .../RuleObjects/WorkItemWrapper.cs | 2 +- .../aggregator-ruleng.csproj | 1 + .../TransitionToStateTests.cs | 346 ++++++++++++++++++ 6 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 src/aggregator-ruleng/Persistance/PersistStateChange.cs create mode 100644 src/aggregator-ruleng/RuleObjects/WorkItemStateWorkflow.cs create mode 100644 src/unittests-ruleng/TransitionToStateTests.cs 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/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 index 6fd14883..99a8dc98 100644 --- a/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs +++ b/src/aggregator-ruleng/RuleObjects/WorkItemStore.cs @@ -18,6 +18,7 @@ public class WorkItemStore private readonly IClientsContext _clients; private readonly Lazy>> _lazyGetWorkItemCategories; private readonly Lazy>> _lazyGetBacklogWorkItemTypesAndStates; + private readonly IDictionary _stateWorkflows = new Dictionary(); private readonly IdentityRef _triggerIdentity; @@ -134,6 +135,94 @@ private static bool ChangeRecycleStatus(WorkItemWrapper workItem, RecycleStatus 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; @@ -160,7 +249,7 @@ private void ImpersonateChanges() } [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) + internal async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, bool impersonate, bool bypassrules, CancellationToken cancellationToken) { if (impersonate) { diff --git a/src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs b/src/aggregator-ruleng/RuleObjects/WorkItemWrapper.cs index 53778afb..3d584d0d 100644 --- a/src/aggregator-ruleng/RuleObjects/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/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index dedec4b4..0a674f85 100644 --- a/src/aggregator-ruleng/aggregator-ruleng.csproj +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -35,6 +35,7 @@ + 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}'"); + } + +} From f41e34e61b2670f7eb4e7a4c9e10a024eeebd82c Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Mon, 2 May 2022 18:11:40 +0100 Subject: [PATCH 08/14] NEW: integration tests for Upgrade scenario --- Next-Release-ChangeLog.md | 1 + .../End2EndScenarioBase.cs | 70 ++++++++ src/integrationtests-cli/Scenario1_Minimal.cs | 6 +- src/integrationtests-cli/Scenario2_Upgrade.cs | 165 ++++++++++++++++++ src/integrationtests-cli/TestLogonData.cs | 4 +- .../integrationtests-cli.csproj | 1 - 6 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/integrationtests-cli/Scenario2_Upgrade.cs diff --git a/Next-Release-ChangeLog.md b/Next-Release-ChangeLog.md index e1a51f69..cc0efb6e 100644 --- a/Next-Release-ChangeLog.md +++ b/Next-Release-ChangeLog.md @@ -23,6 +23,7 @@ No changes. Build, Test, Documentation ======================== * Renamed `aggregator-cli.sln` to `aggregator3.sln`. +* Added upgrade tests. File Hashes 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/TestLogonData.cs b/src/integrationtests-cli/TestLogonData.cs index d59292ef..8567f55e 100644 --- a/src/integrationtests-cli/TestLogonData.cs +++ b/src/integrationtests-cli/TestLogonData.cs @@ -23,10 +23,11 @@ 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; } private static string GetRandomString(int size, string allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789") @@ -54,5 +55,6 @@ private static string GetRandomString(int size, string allowedChars = "abcdefghi public string PAT { 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..b9456aa2 100644 --- a/src/integrationtests-cli/integrationtests-cli.csproj +++ b/src/integrationtests-cli/integrationtests-cli.csproj @@ -3,7 +3,6 @@ net6.0 integrationtests.cli - false From 385e76f5988c8003db6ae4b24e2d7615a1f23fb6 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Mon, 2 May 2022 18:12:10 +0100 Subject: [PATCH 09/14] FIX: wrong FUNCTIONS_EXTENSION_VERSION --- src/aggregator-cli/Instances/AggregatorInstances.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 74627a6e..77d20ef2 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -371,8 +371,9 @@ private static async Task> UpdateDefaultFilesAsync(Fu private async Task ForceFunctionRuntimeVersionAsync(InstanceName instance, CancellationToken cancellationToken) { - const string TargetVersion = "~3"; - // Change V2 to V3 FUNCTIONS_EXTENSION_VERSION ~3 + // HACK this must match appSettings of Microsoft.Web/sites resource in instance-template.json !!! + const string TargetVersion = "~4"; + // Change FUNCTIONS_EXTENSION_VERSION to TargetVersion var webFunctionApp = await GetWebApp(instance, cancellationToken); var currentAzureRuntimeVersion = webFunctionApp.GetAppSettings() .GetValueOrDefault("FUNCTIONS_EXTENSION_VERSION"); From f3d9db2b834f170e1ccff1d7ddcee1ebbce20d63 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Mon, 2 May 2022 18:13:18 +0100 Subject: [PATCH 10/14] FIX: capture list of rules/functions _before_ upgrading the instance --- .../Instances/AggregatorInstances.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 77d20ef2..d9270fdb 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -330,25 +330,34 @@ internal async Task ReadLogAsync(InstanceName instance, string functionN internal async Task UpdateAsync(InstanceName 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); } + // TODO replace 'aggregatorVersion' Azure tag on resources...easier said than done + + _logger.WriteInfo($"Upgrading instance '{instance.PlainName}': complete"); return true; } From 48604ef79a2baf721a8bf5fb180ed93a185d1e69 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 3 May 2022 23:56:02 +0100 Subject: [PATCH 11/14] replace 'aggregatorVersion' Azure tag on resources after upgrade --- src/aggregator-cli/AzureBaseClass.cs | 9 ++--- src/aggregator-cli/ContextBuilder.cs | 28 +++++++++++++-- .../Instances/AggregatorInstances.cs | 35 ++++++++++++++++--- .../Instances/ConfigureInstanceCommand.cs | 2 +- .../Instances/InstallInstanceCommand.cs | 2 +- .../Instances/ListInstancesCommand.cs | 2 +- .../Instances/StreamLogsCommand.cs | 2 +- .../Instances/UninstallInstanceCommand.cs | 2 +- .../Instances/UpdateInstanceCommand.cs | 5 +-- src/aggregator-cli/Logon/AzureLogon.cs | 18 ++++++++++ .../TestCommands/CreateTestCommand.cs | 2 +- 11 files changed, 89 insertions(+), 18 deletions(-) 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/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/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 362236ac..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,7 +329,7 @@ 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"); @@ -356,12 +356,39 @@ internal async Task UpdateAsync(InstanceName instance, string requiredVers await rules.UploadRuleFilesAsync(instance, ruleName, uploadFiles, cancellationToken); } - // TODO replace 'aggregatorVersion' Azure tag on resources...easier said than done + _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(); 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/TestCommands/CreateTestCommand.cs b/src/aggregator-cli/TestCommands/CreateTestCommand.cs index 96d0f48b..296b029d 100644 --- a/src/aggregator-cli/TestCommands/CreateTestCommand.cs +++ b/src/aggregator-cli/TestCommands/CreateTestCommand.cs @@ -32,7 +32,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken) .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); From 97ff88f582e16de60ab707e97d27eb6b0b5ada4a Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 3 May 2022 23:56:22 +0100 Subject: [PATCH 12/14] New algorithm to generate unique Azure resource names --- .../Naming/BuiltInNamingTemplates.cs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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)}"; } } From 2a3c3f24df650307321b8d0985862bf26904aae0 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 4 May 2022 22:01:45 +0100 Subject: [PATCH 13/14] Default version is 1.3, added Directory.Build.props to manage version --- Next-Release-ChangeLog.md | 9 ++++++--- src/Directory.Build.props | 9 +++++++++ src/aggregator-cli/aggregator-cli.csproj | 5 ----- src/aggregator-function/aggregator-function.csproj | 5 ----- src/aggregator-function/aggregator-manifest.ini | 2 +- src/aggregator-host/aggregator-host.csproj | 7 +------ src/aggregator-ruleng/aggregator-ruleng.csproj | 5 ----- src/aggregator-shared/aggregator-shared.csproj | 5 ----- src/aggregator-webshared/aggregator-webshared.csproj | 5 ----- src/aggregator3.sln | 2 ++ 10 files changed, 19 insertions(+), 35 deletions(-) create mode 100644 src/Directory.Build.props diff --git a/Next-Release-ChangeLog.md b/Next-Release-ChangeLog.md index 1a877507..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,7 +26,8 @@ No changes. Build, Test, Documentation ======================== * Renamed `aggregator-cli.sln` to `aggregator3.sln`. -* Added upgrade tests. +* 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/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/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/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index 0a674f85..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 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 From cc18cddd05786f57e8c2f1710674c33f12e12f26 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 11 May 2022 18:16:30 +0100 Subject: [PATCH 14/14] Integration tests for TransitionToState required some change to test commands in CLI --- src/aggregator-cli/CommandBase.cs | 79 +++++++--- src/aggregator-cli/DevOps/Boards.cs | 25 ++++ src/aggregator-cli/Program.cs | 55 +++---- .../TestCommands/CreateTestCommand.cs | 12 +- .../TestCommands/UpdateTestCommand.cs | 52 +++++++ src/integrationtests-cli/Scenario5_Rules.cs | 141 ++++++++++++++++++ src/integrationtests-cli/TestLogonData.cs | 5 + .../integrationtests-cli.csproj | 3 + src/integrationtests-cli/test6.rule | 17 +++ 9 files changed, 341 insertions(+), 48 deletions(-) create mode 100644 src/aggregator-cli/TestCommands/UpdateTestCommand.cs create mode 100644 src/integrationtests-cli/Scenario5_Rules.cs create mode 100644 src/integrationtests-cli/test6.rule 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/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/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 296b029d..22d49cf6 100644 --- a/src/aggregator-cli/TestCommands/CreateTestCommand.cs +++ b/src/aggregator-cli/TestCommands/CreateTestCommand.cs @@ -25,7 +25,17 @@ 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() @@ -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/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 8567f55e..cce21e98 100644 --- a/src/integrationtests-cli/TestLogonData.cs +++ b/src/integrationtests-cli/TestLogonData.cs @@ -28,6 +28,9 @@ public TestLogonData(string filename) 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") @@ -53,6 +56,8 @@ 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 b9456aa2..c5cbd999 100644 --- a/src/integrationtests-cli/integrationtests-cli.csproj +++ b/src/integrationtests-cli/integrationtests-cli.csproj @@ -32,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!"; + } + +}