diff --git a/doc/command-examples.md b/doc/command-examples.md index 59a2f63e..c3ffd5df 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -64,7 +64,7 @@ This is the last step: gluing Azure DevOps to the Rule hosted in Azure Functions ```Batchfile map.rule --verbose --project SampleProject --event workitem.created --instance my1 --rule test1 -map.rule --verbose --project SampleProject --event workitem.updated --instance my1 --rule test2 +map.rule --verbose --project SampleProject --event workitem.updated --instance my1 --rule test2 --impersonate map.rule --verbose --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 ``` The same rule can be triggered by multiple events from different Azure DevOps projects. Currently only these events are supported: @@ -82,13 +82,29 @@ list.mappings --instance my1 --project SampleProject ``` -### Disable and enable rules -Disabling a broken rule leaves any mappings in place. +### Configuring rules + +Configuring a rule leaves any mappings in place. + +#### Disable and enable rules + +e.g. disabling a broken rule ```Batchfile configure.rule --verbose --instance my1 --name test1 --disable configure.rule --verbose --instance my1 --name test1 --enable ``` +#### Execute Impersonated + +configure a rule to run impersonated +**Attention:** To use this the identify accessing Azure DevOps needs special permissions, +see [Rule Examples](setup.md#azure-devops-personal-access-token--PAT-). + +```Batchfile +configure.rule --verbose --instance my1 --name test1 --disableImpersonate +configure.rule --verbose --instance my1 --name test1 --enableImpersonate +``` + ### Update the code and runtime of a rule This command updates the code and potentially the Aggregator runtime diff --git a/doc/rule-language.md b/doc/rule-language.md index d4995d57..6a0e1b44 100644 --- a/doc/rule-language.md +++ b/doc/rule-language.md @@ -7,7 +7,9 @@ They are parsed by Aggregator and removed before compiling the code. `.lang=C#` `.language=Csharp` -Currently the only supported language is C#. You can use the `.lang` directive to specify the programming language used by the rule. +Currently the only supported language is C#. +You can use the `.lang` directive to specify the programming language used by the rule. +If no language is specified: C# is default. ## reference directive Loads the specified assembly in the Rule execution context @@ -19,8 +21,16 @@ Example Equivalent to C# namespace `.import=System.Collections.Generic` +## impersonate directive +Aggregator uses credentials for accessing Azure DevOps. By default the changes which +were saved back to Azure DevOps are done with the credentials provided for accessing +Azure DevOps. +In order to do the changes on behalf of the account who initiated an event, which Aggregator is going to handle, +specify +`.impersonate=onBehalfOfInitiator` - +**Attention:** To use this the identify accessing Azure DevOps needs special permissions, +see [Rule Examples](setup.md#azure-devops-personal-access-token--PAT-). # WorkItem Object diff --git a/doc/setup.md b/doc/setup.md index a476b1cd..3042001d 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -64,8 +64,15 @@ In Azure Portal you can check the permissons in the IAM menu for the selected Re ## Azure DevOps Personal Access Token (PAT) -A PAT has the same or less permissions than the person that creates it. -We recommend that the PAT is issued by an Azure DevOps Organization Administrator. +A PAT has the same or less permissions than the person/identity that creates it. +We recommend that the PAT is issued by an Azure DevOps Organization Administrator Identity. + +When using the [impersonate directive](rule-language.md#impersonate-directive), +[mapping a rule](command-examples.md#adds-two-service-hooks-to-azure-devops--each-invoking-a-different-rule) +to execute impersonated or +[configuring a rule impersonated](command-examples.md#), +the used identity for creating the PAT must have the permission: +"Bypass rules on work item updates" Aggregator needs the following Scopes: diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..0f6a7bac --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,197 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async + +# Code-block preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = false:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:suggestion + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = no_change +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index 65141d68..6e4d8dc2 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -29,12 +29,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-ruleng", "unittes EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{020591FD-B34A-49E6-98E5-D8DD2A838997}" ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig ..\azure-pipelines-CI.yaml = ..\azure-pipelines-CI.yaml ..\azure-pipelines-Release.yaml = ..\azure-pipelines-Release.yaml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-core", "unittests-core\unittests-core.csproj", "{6AD42A49-2EA4-4659-855D-0C011D368794}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-function", "unittests-function\unittests-function.csproj", "{A875638A-8E95-47BE-AEDD-BAD5113692B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +72,10 @@ Global {6AD42A49-2EA4-4659-855D-0C011D368794}.Debug|Any CPU.Build.0 = Debug|Any CPU {6AD42A49-2EA4-4659-855D-0C011D368794}.Release|Any CPU.ActiveCfg = Release|Any CPU {6AD42A49-2EA4-4659-855D-0C011D368794}.Release|Any CPU.Build.0 = Release|Any CPU + {A875638A-8E95-47BE-AEDD-BAD5113692B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A875638A-8E95-47BE-AEDD-BAD5113692B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A875638A-8E95-47BE-AEDD-BAD5113692B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A875638A-8E95-47BE-AEDD-BAD5113692B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/aggregator-cli/AzureBaseClass.cs b/src/aggregator-cli/AzureBaseClass.cs new file mode 100644 index 00000000..6e29bbed --- /dev/null +++ b/src/aggregator-cli/AzureBaseClass.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.Management.AppService.Fluent; +using Microsoft.Azure.Management.Fluent; + + +namespace aggregator.cli { + internal abstract class AzureBaseClass + { + protected IAzure _azure; + + protected ILogger _logger; + + protected AzureBaseClass(IAzure azure, ILogger logger) + { + _azure = azure; + _logger = logger; + } + + + protected async Task GetWebApp(InstanceName instance, CancellationToken cancellationToken) + { + var webFunctionApp = await _azure + .AppServices + .WebApps + .GetByResourceGroupAsync( + instance.ResourceGroupName, + instance.FunctionAppName, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return webFunctionApp; + } + + + protected KuduApi GetKudu(InstanceName instance) + { + var kudu = new KuduApi(instance, _azure, _logger); + return kudu; + } + } +} \ No newline at end of file diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 0ee62345..b41992d9 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -12,16 +12,13 @@ namespace aggregator.cli { - class AggregatorInstances + class AggregatorInstances : AzureBaseClass { private readonly IAzure azure; private readonly ILogger logger; - public AggregatorInstances(IAzure azure, ILogger logger) - { - this.azure = azure; - this.logger = logger; - } + public AggregatorInstances(IAzure azure, ILogger logger) : base(azure, logger) + { } public async Task> ListAllAsync(CancellationToken cancellationToken) { @@ -218,19 +215,14 @@ private async Task DeployArmTemplateAsync(InstanceName instance, string lo internal async Task ChangeAppSettingsAsync(InstanceName instance, DevOpsLogon devopsLogonData, SaveMode saveMode, CancellationToken cancellationToken) { - var webFunctionApp = await azure - .AppServices - .WebApps - .GetByResourceGroupAsync( - instance.ResourceGroupName, - instance.FunctionAppName, cancellationToken); - var configuration = new AggregatorConfiguration - { - DevOpsTokenType = devopsLogonData.Mode, - DevOpsToken = devopsLogonData.Token, - SaveMode = saveMode - }; - configuration.Write(webFunctionApp); + var webFunctionApp = await GetWebApp(instance, cancellationToken); + var configuration = await AggregatorConfiguration.ReadConfiguration(webFunctionApp); + + configuration.DevOpsTokenType = devopsLogonData.Mode; + configuration.DevOpsToken = devopsLogonData.Token; + configuration.SaveMode = saveMode; + + configuration.WriteConfiguration(webFunctionApp); return true; } @@ -297,7 +289,7 @@ internal async Task ChangeAppSettingsAsync(InstanceName instance, string l internal async Task StreamLogsAsync(InstanceName instance, CancellationToken cancellationToken) { - var kudu = new KuduApi(instance, azure, logger); + var kudu = GetKudu(instance); logger.WriteVerbose($"Connecting to {instance.PlainName}..."); // Main takes care of resetting color diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 740ffa51..932ea601 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -215,9 +215,14 @@ internal async Task GetDeployedRuntimeVersion(InstanceName instance, private async Task GetLocalPackageVersionAsync(string runtimePackageFile) { - if (File.Exists(runtimePackageFile)) + if (!File.Exists(runtimePackageFile)) + { + // this default allows SemVer to parse and compare + return new SemVersion(0, 0); + } + + using (var zip = ZipFile.OpenRead(runtimePackageFile)) { - var zip = ZipFile.OpenRead(runtimePackageFile); var manifestEntry = zip.GetEntry("aggregator-manifest.ini"); using (var byteStream = manifestEntry.Open()) using (var reader = new StreamReader(byteStream)) @@ -227,9 +232,6 @@ private async Task GetLocalPackageVersionAsync(string runtimePackage return info.Version; } } - - // this default allows SemVer to parse and compare - return new SemVersion(0, 0); } private async Task<(string name, DateTimeOffset? when, string url)> FindVersionInGitHubAsync(string tag = "latest") diff --git a/src/aggregator-cli/Instances/InstanceName.cs b/src/aggregator-cli/Instances/InstanceName.cs index 6d73b107..d75adbee 100644 --- a/src/aggregator-cli/Instances/InstanceName.cs +++ b/src/aggregator-cli/Instances/InstanceName.cs @@ -35,9 +35,9 @@ public static InstanceName FromFunctionAppName(string appName, string resourceGr } // used only in mappings.ListAsync - public static InstanceName FromFunctionAppUrl(string url) + public static InstanceName FromFunctionAppUrl(Uri url) { - string host = new Uri(url).Host; + string host = url.Host; host = host.Substring(0, host.IndexOf('.')); return new InstanceName(host.Remove(host.Length - functionAppSuffix.Length), null); } diff --git a/src/aggregator-cli/Logon/DevOpsLogon.cs b/src/aggregator-cli/Logon/DevOpsLogon.cs index b52a2e1e..b83cfe4d 100644 --- a/src/aggregator-cli/Logon/DevOpsLogon.cs +++ b/src/aggregator-cli/Logon/DevOpsLogon.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; + namespace aggregator.cli { class DevOpsLogon : LogonDataBase diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index cb2e0425..2a642b82 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -8,12 +8,13 @@ using Microsoft.Azure.Management.Fluent; using System.Threading; using Microsoft.VisualStudio.Services.FormInput; +using aggregator; namespace aggregator.cli { internal class AggregatorMappings { - private VssConnection devops; + private readonly VssConnection devops; private readonly IAzure azure; private readonly ILogger logger; @@ -53,11 +54,11 @@ internal async Task> ListAsync(InstanceName insta continue; } // HACK need to factor the URL<->rule_name - string ruleUrl = subscription.ConsumerInputs.GetValue("url","/"); - string ruleName = ruleUrl.Substring(ruleUrl.LastIndexOf('/')); - string ruleFullName = InstanceName.FromFunctionAppUrl(ruleUrl).PlainName + ruleName; + Uri ruleUrl = new Uri(subscription.ConsumerInputs.GetValue("url","https://google.com")); + string ruleName = ruleUrl.Segments.LastOrDefault() ?? string.Empty; + string ruleFullName = $"{InstanceName.FromFunctionAppUrl(ruleUrl).PlainName}/{ruleName}"; result.Add( - new MappingOutputData(instance, ruleFullName, foundProject.Name, subscription.EventType, subscription.Status.ToString()) + new MappingOutputData(instance, ruleFullName, ruleUrl.IsImpersonationEnabled(), foundProject.Name, subscription.EventType, subscription.Status.ToString()) ); } return result; @@ -71,7 +72,7 @@ internal class EventFilters public IEnumerable Fields { get; set; } } - internal async Task AddAsync(string projectName, string @event, EventFilters filters, InstanceName instance, string ruleName, CancellationToken cancellationToken) + internal async Task AddAsync(string projectName, string @event, EventFilters filters, InstanceName instance, string ruleName, bool impersonateExecution, CancellationToken cancellationToken) { logger.WriteVerbose($"Reading Azure DevOps project data..."); var projectClient = devops.GetClient(); @@ -80,10 +81,10 @@ internal async Task AddAsync(string projectName, string @event, EventFilte var rules = new AggregatorRules(azure, logger); logger.WriteVerbose($"Retrieving {ruleName} Function Key..."); - (string ruleUrl, string ruleKey) = await rules.GetInvocationUrlAndKey(instance, ruleName, cancellationToken); + (Uri ruleUrl, string ruleKey) = await rules.GetInvocationUrlAndKey(instance, ruleName, cancellationToken); logger.WriteInfo($"{ruleName} Function Key retrieved."); - var serviceHooksClient = devops.GetClient(); + ruleUrl = ruleUrl.AddToUrl(impersonate: impersonateExecution); // check if the subscription already exists and bail out var query = new SubscriptionsQuery { @@ -108,7 +109,7 @@ internal async Task AddAsync(string projectName, string @event, EventFilte new InputFilterCondition { InputId = "url", - InputValue = ruleUrl, + InputValue = ruleUrl.ToString(), Operator = InputFilterOperator.Equals, CaseSensitive = false } @@ -118,6 +119,7 @@ internal async Task AddAsync(string projectName, string @event, EventFilte }; cancellationToken.ThrowIfCancellationRequested(); + var serviceHooksClient = devops.GetClient(); var queryResult = await serviceHooksClient.QuerySubscriptionsAsync(query); if (queryResult.Results.Any()) { @@ -132,7 +134,7 @@ internal async Task AddAsync(string projectName, string @event, EventFilte ConsumerActionId = "httpRequest", ConsumerInputs = new Dictionary { - { "url", ruleUrl }, + { "url", ruleUrl.ToString() }, { "httpHeaders", $"x-functions-key:{ruleKey}" }, // careful with casing! { "resourceDetailsToSend", "all" }, @@ -201,7 +203,7 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta instance.FunctionAppUrl)); if (@event != "*") { - ruleSubs = ruleSubs.Where(s => s.EventType == @event); + ruleSubs = ruleSubs.Where(s => string.Equals(s.EventType, @event, StringComparison.OrdinalIgnoreCase)); } if (projectName != "*") @@ -211,14 +213,15 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta var project = await projectClient.GetProject(projectName); logger.WriteInfo($"Project {projectName} data read."); - ruleSubs = ruleSubs.Where(s => s.PublisherInputs["projectId"] == project.Id.ToString()); + ruleSubs = ruleSubs.Where(s => string.Equals(s.PublisherInputs["projectId"], project.Id.ToString(), StringComparison.OrdinalIgnoreCase)); } if (rule != "*") { - ruleSubs = ruleSubs - .Where(s => s.ConsumerInputs.GetValue("url", "").StartsWith( - AggregatorRules.GetInvocationUrl(instance, rule))); + var invocationUrl = AggregatorRules.GetInvocationUrl(instance, rule).ToString(); + ruleSubs = ruleSubs.Where(s => s.ConsumerInputs + .GetValue("url", "") + .StartsWith(invocationUrl, StringComparison.OrdinalIgnoreCase)); } foreach (var ruleSub in ruleSubs) diff --git a/src/aggregator-cli/Mappings/MapRuleCommand.cs b/src/aggregator-cli/Mappings/MapRuleCommand.cs index b6a836fb..0fab3290 100644 --- a/src/aggregator-cli/Mappings/MapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/MapRuleCommand.cs @@ -25,6 +25,9 @@ class MapRuleCommand : CommandBase [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } + [Option("impersonate", Required = false, HelpText = "Do rule changes on behalf of the person triggered the rule execution. See wiki for details, requires special account privileges.")] + public bool ImpersonateExecution { get; set; } + // event filters: but cannot make AreaPath & Tag work //[Option("filterAreaPath", Required = false, HelpText = "Filter Azure DevOps event to include only Work Items under the specified Area Path.")] public string FilterAreaPath { get; set; } @@ -58,7 +61,7 @@ internal override async Task RunAsync(CancellationToken cancellationToken) }; var instance = new InstanceName(Instance, ResourceGroup); - var id = await mappings.AddAsync(Project, Event, filters, instance, Rule, cancellationToken); + var id = await mappings.AddAsync(Project, Event, filters, instance, Rule, ImpersonateExecution, cancellationToken); return id.Equals(Guid.Empty) ? 1 : 0; } } diff --git a/src/aggregator-cli/Mappings/MappingOutputData.cs b/src/aggregator-cli/Mappings/MappingOutputData.cs index d9ccbbfd..0bbf31f6 100644 --- a/src/aggregator-cli/Mappings/MappingOutputData.cs +++ b/src/aggregator-cli/Mappings/MappingOutputData.cs @@ -2,16 +2,18 @@ { internal class MappingOutputData : ILogDataObject { - string instanceName; - string rule; - string project; - string @event; - string status; + private string instanceName; + private string rule; + private bool executeImpersonated; + private string project; + private string @event; + private string status; - internal MappingOutputData(InstanceName instance, string rule, string project, string @event, string status) + internal MappingOutputData(InstanceName instance, string rule, bool executeImpersonated, string project, string @event, string status) { this.instanceName = instance.PlainName; this.rule = rule; + this.executeImpersonated = executeImpersonated; this.project = project; this.@event = @event; this.status = status; @@ -19,7 +21,7 @@ internal MappingOutputData(InstanceName instance, string rule, string project, s public string AsHumanReadable() { - return $"Project {project} invokes rule {rule} for {@event} (status {status})"; + return $"Project {project} invokes rule {rule}{(executeImpersonated ? " (impersonated)" : string.Empty)} for {@event} (status {status})"; } } } diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index 6d743dcf..30ae5f70 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -8,62 +8,102 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using aggregator.Engine.Language; + using Microsoft.Azure.Management.Fluent; using Microsoft.TeamFoundation.Core.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; +using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; using Microsoft.VisualStudio.Services.WebApi; using Newtonsoft.Json; namespace aggregator.cli { - internal class AggregatorRules + internal class AggregatorRules : AzureBaseClass { - private static readonly Random Randomizer = new Random((int)DateTime.UtcNow.Ticks); - private readonly IAzure _azure; - private readonly ILogger _logger; + public AggregatorRules(IAzure azure, ILogger logger) : base(azure, logger) + { } - public AggregatorRules(IAzure azure, ILogger logger) + internal async Task> ListAsync(InstanceName instance, CancellationToken cancellationToken) { - _azure = azure; - _logger = logger; - } - - internal async Task> ListAsync(InstanceName instance, CancellationToken cancellationToken) - { - var kudu = new KuduApi(instance, _azure, _logger); + var kudu = GetKudu(instance); _logger.WriteInfo($"Retrieving Functions in {instance.PlainName}..."); + + var webFunctionApp = await GetWebApp(instance, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var configuration = await AggregatorConfiguration.ReadConfiguration(webFunctionApp); + using (var client = new HttpClient()) - using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/functions", cancellationToken)) - using (var response = await client.SendAsync(request, cancellationToken)) { - var stream = await response.Content.ReadAsStreamAsync(); - - if (response.IsSuccessStatusCode) + KuduFunction[] kuduFunctions; + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/functions", cancellationToken)) + using (var response = await client.SendAsync(request, cancellationToken)) { + var stream = await response.Content.ReadAsStreamAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.WriteError($"{response.ReasonPhrase} {await response.Content.ReadAsStringAsync()}"); + return Enumerable.Empty(); + } + using (var sr = new StreamReader(stream)) using (var jtr = new JsonTextReader(sr)) { var js = new JsonSerializer(); - var functionList = js.Deserialize(jtr); - return functionList; + kuduFunctions = js.Deserialize(jtr); } } - _logger.WriteError($"{response.ReasonPhrase} {await response.Content.ReadAsStringAsync()}"); - return new KuduFunction[0]; + List ruleData = new List(); + foreach (var kuduFunction in kuduFunctions) + { + var ruleName = kuduFunction.Name; + var ruleFileUrl = $"api/vfs/site/wwwroot/{ruleName}/{ruleName}.rule"; + _logger.WriteInfo($"Retrieving Function Rule Details {ruleName}..."); + + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, ruleFileUrl, cancellationToken)) + using (var response = await client.SendAsync(request, cancellationToken)) + { + var stream = await response.Content.ReadAsStreamAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.WriteError($"{response.ReasonPhrase} {await response.Content.ReadAsStringAsync()}"); + continue; + } + + + var ruleCode = new List(); + using (var sr = new StreamReader(stream)) + { + string line; + while ((line = sr.ReadLine()) != null) + { + ruleCode.Add(line); + } + } + + var ruleConfiguration = configuration.GetRuleConfiguration(ruleName); + var (ruleDirectives, _) = RuleFileParser.Read(ruleCode.ToArray()); + ruleData.Add(new RuleOutputData(instance, ruleConfiguration, ruleDirectives.LanguageAsString())); + } + } + + return ruleData; } } - internal static string GetInvocationUrl(InstanceName instance, string rule) + internal static Uri GetInvocationUrl(InstanceName instance, string rule) { - return $"{instance.FunctionAppUrl}/api/{rule}"; + var url = $"{instance.FunctionAppUrl}/api/{rule}"; + return new Uri(url); } - internal async Task<(string url, string key)> GetInvocationUrlAndKey(InstanceName instance, string rule, CancellationToken cancellationToken) + internal async Task<(Uri url, string key)> GetInvocationUrlAndKey(InstanceName instance, string rule, CancellationToken cancellationToken = default) { - var instances = new AggregatorInstances(_azure, _logger); - var kudu = new KuduApi(instance, _azure, _logger); + var kudu = GetKudu(instance); // see https://github.com/projectkudu/kudu/wiki/Functions-API using (var client = new HttpClient()) @@ -80,7 +120,7 @@ internal static string GetInvocationUrl(InstanceName instance, string rule) var js = new JsonSerializer(); var secret = js.Deserialize(jtr); - (string url, string key) invocation = (GetInvocationUrl(instance, rule), secret.Key); + (Uri url, string key) invocation = (GetInvocationUrl(instance, rule), secret.Key); return invocation; } } @@ -92,17 +132,16 @@ internal static string GetInvocationUrl(InstanceName instance, string rule) } } - internal async Task AddAsync(InstanceName instance, string name, string filePath, CancellationToken cancellationToken) + internal async Task AddAsync(InstanceName instance, string ruleName, string filePath, CancellationToken cancellationToken) { _logger.WriteInfo($"Validate rule file {filePath}"); - var ruleContent = await File.ReadAllLinesAsync(filePath); - var engineLogger = new EngineWrapperLogger(_logger); + var (ruleDirectives, _) = await RuleFileParser.ReadFile(filePath, engineLogger, cancellationToken); try { - var ruleEngine = new Engine.RuleEngine(engineLogger, ruleContent, SaveMode.Batch, true); - (var success, var diagnostics) = ruleEngine.VerifyRule(); + var rule = new Engine.ScriptedRuleWrapper(ruleName, ruleDirectives, engineLogger); + var (success, diagnostics) = rule.Verify(); if (success) { _logger.WriteInfo($"Rule file is valid"); @@ -126,41 +165,55 @@ internal async Task AddAsync(InstanceName instance, string name, string fi } _logger.WriteVerbose($"Layout rule files"); - var inMemoryFiles = await PackagingFilesAsync(name, filePath); - _logger.WriteInfo($"Packaging {filePath} into rule {name} complete."); + var inMemoryFiles = await PackagingFilesAsync(ruleName, ruleDirectives); + _logger.WriteInfo($"Packaging rule {ruleName} complete."); _logger.WriteVerbose($"Uploading rule files to {instance.PlainName}"); - bool ok = await UploadRuleFilesAsync(instance, name, inMemoryFiles, cancellationToken); + bool ok = await UploadRuleFilesAsync(instance, ruleName, inMemoryFiles, cancellationToken); if (ok) { - _logger.WriteInfo($"All {name} files successfully uploaded to {instance.PlainName}."); + _logger.WriteInfo($"All {ruleName} files successfully uploaded to {instance.PlainName}."); + } + + if (ruleDirectives.Impersonate) + { + _logger.WriteInfo($"Configure {ruleName} to execute impersonated."); + ok &= await ConfigureAsync(instance, ruleName, impersonate: true, cancellationToken: cancellationToken); + if (ok) + { + _logger.WriteInfo($"Updated {ruleName} configuration successfully."); + } } return ok; } - private static async Task> PackagingFilesAsync(string name, string filePath) + private static async Task> PackagingFilesAsync(string name, IRuleDirectives ruleDirectives) { - var inMemoryFiles = new Dictionary(); - - var ruleContent = await File.ReadAllTextAsync(filePath); - inMemoryFiles.Add($"{name}.rule", ruleContent); + var inMemoryFiles = new Dictionary + { + { $"{name}.rule", string.Join(Environment.NewLine, RuleFileParser.Write(ruleDirectives)) } + }; var assembly = Assembly.GetExecutingAssembly(); - using (var stream = assembly.GetManifestResourceStream("aggregator.cli.Rules.function.json")) // TODO we can deserialize a KuduFunctionConfig instead of using a fixed file... + using (var stream = assembly.GetManifestResourceStream("aggregator.cli.Rules.function.json")) { - var reader = new StreamReader(stream); - var content = await reader.ReadToEndAsync(); - inMemoryFiles.Add("function.json", content); + using (var reader = new StreamReader(stream)) + { + var content = await reader.ReadToEndAsync(); + inMemoryFiles.Add("function.json", content); + } } using (var stream = assembly.GetManifestResourceStream("aggregator.cli.Rules.run.csx")) { - var reader = new StreamReader(stream); - var content = await reader.ReadToEndAsync(); - inMemoryFiles.Add("run.csx", content); + using (var reader = new StreamReader(stream)) + { + var content = await reader.ReadToEndAsync(); + inMemoryFiles.Add("run.csx", content); + } } return inMemoryFiles; @@ -177,7 +230,7 @@ Puts a file at path. Note: when updating or deleting a file, ETag behavior will apply. You can pass a If-Match: "*" header to disable the ETag check. */ - var kudu = new KuduApi(instance, _azure, _logger); + var kudu = GetKudu(instance); var relativeUrl = $"api/vfs/site/wwwroot/{name}/"; using (var client = new HttpClient()) @@ -242,7 +295,7 @@ Puts a file at path. internal async Task RemoveAsync(InstanceName instance, string name, CancellationToken cancellationToken) { - var kudu = new KuduApi(instance, _azure, _logger); + var kudu = GetKudu(instance); // undocumented but works, see https://github.com/projectkudu/kudu/wiki/Functions-API _logger.WriteInfo($"Removing Function {name} in {instance.PlainName}..."); using (var client = new HttpClient()) @@ -257,25 +310,32 @@ internal async Task RemoveAsync(InstanceName instance, string name, Cancel return ok; } + + //TODO BobSilent remove configuration (Enable/Disable or Impersonate) } - internal async Task EnableAsync(InstanceName instance, string name, bool disable, CancellationToken cancellationToken) + internal async Task ConfigureAsync(InstanceName instance, string name, bool? disable = null, bool? impersonate = null, CancellationToken cancellationToken = default) { - var webFunctionApp = await _azure - .AppServices - .WebApps - .GetByResourceGroupAsync( - instance.ResourceGroupName, - instance.FunctionAppName, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - webFunctionApp - .Update() - .WithAppSetting($"AzureWebJobs.{name}.Disabled", disable.ToString().ToLower()) - .Apply(); + var webFunctionApp = await GetWebApp(instance, cancellationToken); + var configuration = await AggregatorConfiguration.ReadConfiguration(webFunctionApp); + var ruleConfig = configuration.GetRuleConfiguration(name); + + if (disable.HasValue) + { + ruleConfig.IsDisabled = disable.Value; + } + + if (impersonate.HasValue) + { + ruleConfig.Impersonate = impersonate.Value; + } + + ruleConfig.WriteConfiguration(webFunctionApp); return true; } + internal async Task UpdateAsync(InstanceName instance, string name, string filePath, string requiredVersion, string sourceUrl, CancellationToken cancellationToken) { // check runtime package @@ -289,7 +349,7 @@ internal async Task UpdateAsync(InstanceName instance, string name, string return ok; } - internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode, CancellationToken cancellationToken) + internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode, bool impersonateExecution, CancellationToken cancellationToken) { if (!File.Exists(ruleFilePath)) { @@ -329,13 +389,17 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in using (var clientsContext = new AzureDevOpsClientsContext(devops)) { _logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - var ruleCode = await File.ReadAllLinesAsync(ruleFilePath, cancellationToken); + var (ruleDirectives, _) = await RuleFileParser.ReadFile(ruleFilePath, cancellationToken); + var rule = new Engine.ScriptedRuleWrapper(Path.GetFileNameWithoutExtension(ruleFilePath), ruleDirectives) + { + ImpersonateExecution = impersonateExecution + }; var engineLogger = new EngineWrapperLogger(_logger); - var engine = new Engine.RuleEngine(engineLogger, ruleCode, saveMode, dryRun: dryRun); + var engine = new Engine.RuleEngine(engineLogger, saveMode, dryRun: dryRun); var workItem = await clientsContext.WitClient.GetWorkItemAsync(projectName, workItemId, expand: WorkItemExpand.All, cancellationToken: cancellationToken); - string result = await engine.ExecuteAsync(teamProjectId, workItem, clientsContext, cancellationToken); + string result = await engine.RunAsync(rule, teamProjectId, workItem, clientsContext, cancellationToken); _logger.WriteInfo($"Rule returned '{result}'"); return true; @@ -343,14 +407,14 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in } } - internal async Task InvokeRemoteAsync(string account, string project, string @event, int workItemId, InstanceName instance, string ruleName, bool dryRun, SaveMode saveMode, CancellationToken cancellationToken) + internal async Task InvokeRemoteAsync(string account, string project, string @event, int workItemId, InstanceName instance, string ruleName, bool dryRun, SaveMode saveMode, bool impersonateExecution, CancellationToken cancellationToken) { // build the request ... _logger.WriteVerbose($"Retrieving {ruleName} Function Key..."); var (ruleUrl, ruleKey) = await GetInvocationUrlAndKey(instance, ruleName, cancellationToken); _logger.WriteInfo($"{ruleName} Function Key retrieved."); - ruleUrl = InvokeOptions.AppendToUrl(ruleUrl, dryRun, saveMode); + ruleUrl = ruleUrl.AddToUrl(dryRun, saveMode, impersonateExecution); string baseUrl = $"https://dev.azure.com/{account}"; Guid teamProjectId = Guid.Empty; @@ -384,24 +448,21 @@ internal async Task InvokeRemoteAsync(string account, string project, stri using (var client = new HttpClient()) { - using (var request = new HttpRequestMessage(HttpMethod.Post, ruleUrl)) - { - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("aggregator", "3.0")); - request.Headers.Add("x-functions-key", ruleKey); - request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("aggregator", "3.0")); + client.DefaultRequestHeaders.Add("x-functions-key", ruleKey); + var content = new StringContent(body, Encoding.UTF8, "application/json"); - using (var response = await client.SendAsync(request, cancellationToken)) + using (var response = await client.PostAsync(ruleUrl, content, cancellationToken)) + { + if (response.IsSuccessStatusCode) { - if (response.IsSuccessStatusCode) - { - string result = await response.Content.ReadAsStringAsync(); - _logger.WriteInfo($"{result}"); - return true; - } - - _logger.WriteError($"Failed with {response.ReasonPhrase}"); - return false; + string result = await response.Content.ReadAsStringAsync(); + _logger.WriteInfo($"{result}"); + return true; } + + _logger.WriteError($"Failed with {response.ReasonPhrase}"); + return false; } } } diff --git a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs index 51526555..8d763349 100644 --- a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs +++ b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs @@ -20,9 +20,15 @@ class ConfigureRuleCommand : CommandBase public string Name { get; set; } [Option('d', "disable", SetName = "disable", HelpText = "Disable the rule.")] - public bool Disable { get; set; } + public bool? Disable { get; set; } [Option('e', "enable", SetName = "enable", HelpText = "Enable the rule.")] - public bool Enable { get; set; } + public bool? Enable { get; set; } + + [Option("disableImpersonate", Required = false, HelpText = "Disable do rule changes impersonated.")] + public bool? DisableImpersonateExecution { get; set; } + + [Option("enableImpersonate", Required = false, HelpText = "Enable do rule changes on behalf of the person triggered the rule execution. See wiki for details, requires special account privileges.")] + public bool? EnableImpersonateExecution { get; set; } internal override async Task RunAsync(CancellationToken cancellationToken) { @@ -31,12 +37,42 @@ internal override async Task RunAsync(CancellationToken cancellationToken) .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); - bool ok = false; - if (Disable || Enable) - { - ok = await rules.EnableAsync(instance, Name, Disable, cancellationToken); - } + + var disable = GetDisableStatus(Disable, Enable); + var impersonate = GetEnableStatus(DisableImpersonateExecution, EnableImpersonateExecution); + + var ok = await rules.ConfigureAsync(instance, Name, disable, impersonate, cancellationToken); return ok ? 0 : 1; } + + + /// + /// in case of neither disableSetting nor enableSetting is set return null + /// Otherwise return value of disableSetting + /// + /// + /// + /// + private static bool? GetDisableStatus(bool? disableSetting, bool? enableSetting) + { + return GetEnableDisableStatus(disableSetting, enableSetting, disableSetting); + } + + /// + /// in case of neither disableSetting nor enableSetting is set return null + /// Otherwise return value of enableSetting + /// + /// + /// + /// + private static bool? GetEnableStatus(bool? disableSetting, bool? enableSetting) + { + return GetEnableDisableStatus(disableSetting, enableSetting, enableSetting); + } + + private static bool? GetEnableDisableStatus(bool? disableSetting, bool? enableSetting, bool? defaultSetting) + { + return disableSetting.HasValue || enableSetting.HasValue ? (bool?)(defaultSetting ?? false) : null; + } } } diff --git a/src/aggregator-cli/Rules/InvokeRuleCommand.cs b/src/aggregator-cli/Rules/InvokeRuleCommand.cs index a7dc53de..45dd3e27 100644 --- a/src/aggregator-cli/Rules/InvokeRuleCommand.cs +++ b/src/aggregator-cli/Rules/InvokeRuleCommand.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; + namespace aggregator.cli { [Verb("invoke.rule", HelpText = "Executes a rule locally or in an existing Aggregator instance.")] @@ -31,6 +32,9 @@ class InvokeRuleCommand : CommandBase [Option('m', "saveMode", Required = false, HelpText = "Save behaviour.")] public SaveMode SaveMode { get; set; } + [Option("impersonate", Required = false, HelpText = "Do rule changes on behalf of the person triggered the rule execution. See wiki for details, requires special account privileges.")] + public bool ImpersonateExecution { get; set; } + [Option('a', "account", SetName = "Remote", Required = true, HelpText = "Azure DevOps account name.")] public string Account { get; set; } @@ -53,14 +57,14 @@ internal override async Task RunAsync(CancellationToken cancellationToken) var rules = new AggregatorRules(context.Azure, context.Logger); if (Local) { - bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun, SaveMode, cancellationToken); + bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun, SaveMode, ImpersonateExecution, cancellationToken); return ok ? 0 : 1; } else { var instance = new InstanceName(Instance, ResourceGroup); context.Logger.WriteWarning("Untested feature!"); - bool ok = await rules.InvokeRemoteAsync(Account, Project, Event, WorkItemId, instance, Name, DryRun, SaveMode, cancellationToken); + bool ok = await rules.InvokeRemoteAsync(Account, Project, Event, WorkItemId, instance, Name, DryRun, SaveMode, ImpersonateExecution, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/ListRulesCommand.cs b/src/aggregator-cli/Rules/ListRulesCommand.cs index 1e4ecc57..2cb92e39 100644 --- a/src/aggregator-cli/Rules/ListRulesCommand.cs +++ b/src/aggregator-cli/Rules/ListRulesCommand.cs @@ -24,10 +24,10 @@ internal override async Task RunAsync(CancellationToken cancellationToken) var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool any = false; - foreach (var item in await rules.ListAsync(instance, cancellationToken)) + foreach (var ruleInformation in await rules.ListAsync(instance, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); - context.Logger.WriteOutput(new RuleOutputData(instance, item)); + context.Logger.WriteOutput(ruleInformation); any = true; } diff --git a/src/aggregator-cli/Rules/RuleOutputData.cs b/src/aggregator-cli/Rules/RuleOutputData.cs index cf50e526..334a64c6 100644 --- a/src/aggregator-cli/Rules/RuleOutputData.cs +++ b/src/aggregator-cli/Rules/RuleOutputData.cs @@ -2,22 +2,29 @@ using System.Collections.Generic; using System.Text; + namespace aggregator.cli { internal class RuleOutputData : ILogDataObject { - string instanceName; - KuduFunction function; + private readonly string instanceName; + private readonly string ruleName; + private readonly string ruleLanguage; + private readonly bool isDisabled; + private readonly bool isImpersonated; - internal RuleOutputData(InstanceName instance, KuduFunction function) + internal RuleOutputData(InstanceName instance, IRuleConfiguration ruleConfiguration, string ruleLanguage) { this.instanceName = instance.PlainName; - this.function = function; + this.ruleName = ruleConfiguration.RuleName; + this.isDisabled = ruleConfiguration.IsDisabled; + this.isImpersonated = ruleConfiguration.Impersonate; + this.ruleLanguage = ruleLanguage; } public string AsHumanReadable() { - return $"Rule {instanceName}/{function.Name} {(function.Config.Disabled ? "(disabled)" : string.Empty)}"; + return $"Rule {instanceName}/{ruleName} {(isImpersonated ? "*execute impersonated*" : string.Empty)} {(isDisabled ? "(disabled)" : string.Empty)}"; } } } diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index d78d8043..45804495 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -34,107 +34,91 @@ public async Task RunAsync(HttpRequestMessage req, Cancella { _log.LogDebug($"Context: {_context.InvocationId} {_context.FunctionName} {_context.FunctionDirectory} {_context.FunctionAppDirectory}"); + var ruleName = _context.FunctionName; var aggregatorVersion = GetCustomAttribute()?.InformationalVersion; - - try - { - var rule = _context.FunctionName; - _log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{rule}'"); - } - catch (Exception ex) - { - _log.LogWarning($"Failed parsing request headers: {ex.Message}"); - } - + _log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{ruleName}'"); cancellationToken.ThrowIfCancellationRequested(); // Get request body - var jsonContent = await req.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(jsonContent)) + var eventData = await GetWebHookEvent(req); + if (eventData == null) { - _log.LogWarning($"Failed parsing request body: empty"); - - var resp = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("Request body is empty") - }; - return resp; + return req.CreateErrorResponse(HttpStatusCode.BadRequest, "Request body is empty"); } - var data = JsonConvert.DeserializeObject(jsonContent); - // sanity check - if (!DevOpsEvents.IsValidEvent(data.EventType) - || data.PublisherId != DevOpsEvents.PublisherId) + if (!DevOpsEvents.IsValidEvent(eventData.EventType) + || eventData.PublisherId != DevOpsEvents.PublisherId) { - return req.CreateResponse(HttpStatusCode.BadRequest, new - { - error = "Not a good Azure DevOps post..." - }); + return req.CreateErrorResponse(HttpStatusCode.BadRequest, "Not a good Azure DevOps post..."); } - var eventContext = CreateContextFromEvent(data); + var eventContext = CreateContextFromEvent(eventData); if (eventContext.IsTestEvent()) { - return RespondToTestEventMessage(req, aggregatorVersion); + return req.CreateTestEventResponse(aggregatorVersion, ruleName); } - var config = new ConfigurationBuilder() - .SetBasePath(_context.FunctionAppDirectory) - .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables() - .Build(); - var configuration = AggregatorConfiguration.Read(config); - configuration = InvokeOptions.ExtendFromUrl(configuration, req.RequestUri); + var configContext = GetConfigurationContext(); + var configuration = AggregatorConfiguration.ReadConfiguration(configContext) + .UpdateFromUrl(ruleName, req.RequestUri); var logger = new ForwarderLogger(_log); - var wrapper = new RuleWrapper(configuration, logger, _context.FunctionName, _context.FunctionDirectory); - try + var ruleProvider = new AzureFunctionRuleProvider(logger, _context.FunctionDirectory); + var ruleExecutor = new RuleExecutor(logger, configuration); + using (_log.BeginScope($"WorkItem #{eventContext.WorkItemPayload.WorkItem.Id}")) { - string execResult = await wrapper.ExecuteAsync(eventContext, cancellationToken); - - if (string.IsNullOrEmpty(execResult)) + try { - var resp = new HttpResponseMessage(HttpStatusCode.OK); - return resp; - } - else - { - _log.LogInformation($"Returning '{execResult}' from '{_context.FunctionName}'"); + var rule = await ruleProvider.GetRule(ruleName); + var execResult = await ruleExecutor.ExecuteAsync(rule, eventContext, cancellationToken); - var resp = new HttpResponseMessage(HttpStatusCode.OK) + if (string.IsNullOrEmpty(execResult)) + { + return req.CreateResponse(HttpStatusCode.OK); + } + else { - Content = new StringContent(execResult) - }; - return resp; + _log.LogInformation($"Returning '{execResult}' from '{rule.Name}'"); + return req.CreateResponse(HttpStatusCode.OK, execResult); + } } - } - catch (Exception ex) - { - _log.LogWarning($"Rule '{_context.FunctionName}' failed: {ex.Message}"); - - var resp = new HttpResponseMessage(HttpStatusCode.NotImplemented) + catch (Exception ex) { - Content = new StringContent(ex.Message) - }; - return resp; + _log.LogWarning($"Rule '{ruleName}' failed: {ex.Message}"); + return req.CreateErrorResponse(HttpStatusCode.NotImplemented, ex); + } } } - private HttpResponseMessage RespondToTestEventMessage(HttpRequestMessage req, string aggregatorVersion) + + private IConfigurationRoot GetConfigurationContext() { - var resp = req.CreateResponse(HttpStatusCode.OK, new - { - message = $"Hello from Aggregator v{aggregatorVersion} executing rule '{_context.FunctionName}'" - }); - resp.Headers.Add("X-Aggregator-Version", aggregatorVersion); - resp.Headers.Add("X-Aggregator-Rule", _context.FunctionName); - return resp; + var config = new ConfigurationBuilder() + .SetBasePath(_context.FunctionAppDirectory) + .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + return config; + } + + + private async Task GetWebHookEvent(HttpRequestMessage req) + { + var jsonContent = await req.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(jsonContent)) + { + _log.LogWarning($"Failed parsing request body: empty"); + return null; + } + + var data = JsonConvert.DeserializeObject(jsonContent); + return data; } private static WorkItemEventContext CreateContextFromEvent(WebHookEvent eventData) { - var collectionUrl = eventData.ResourceContainers.GetValueOrDefault("collection")?.BaseUrl; + var collectionUrl = eventData.ResourceContainers.GetValueOrDefault("collection")?.BaseUrl ?? "https://example.com"; var teamProjectId = eventData.ResourceContainers.GetValueOrDefault("project")?.Id ?? Guid.Empty; var resourceObject = eventData.Resource as JObject; @@ -160,4 +144,19 @@ private static T GetCustomAttribute() .FirstOrDefault() as T; } } + + + internal static class HttpResponseMessageExtensions + { + public static HttpResponseMessage CreateTestEventResponse(this HttpRequestMessage req, string aggregatorVersion, string ruleName) + { + var resp = req.CreateResponse(HttpStatusCode.OK, new + { + message = $"Hello from Aggregator v{aggregatorVersion} executing rule '{ruleName}'" + }); + resp.Headers.Add("X-Aggregator-Version", aggregatorVersion); + resp.Headers.Add("X-Aggregator-Rule", ruleName); + return resp; + } + } } diff --git a/src/aggregator-function/AzureFunctionRuleProvider.cs b/src/aggregator-function/AzureFunctionRuleProvider.cs new file mode 100644 index 00000000..54c6f210 --- /dev/null +++ b/src/aggregator-function/AzureFunctionRuleProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using aggregator.Engine; +using aggregator.Engine.Language; + +namespace aggregator +{ + internal class AzureFunctionRuleProvider : IRuleProvider + { + private const string SCRIPT_RULE_NAME_PATTERN = "*.rule"; + + private readonly IAggregatorLogger _logger; + private readonly string _rulesPath; + + public AzureFunctionRuleProvider(IAggregatorLogger logger, string functionDirectory) + { + _logger = logger; + _rulesPath = functionDirectory; + } + + /// + public async Task GetRule(string ruleName) + { + var ruleFilePath = GetRuleFilePath(ruleName); + var (ruleDirectives, _) = await RuleFileParser.ReadFile(ruleFilePath); + + return new ScriptedRuleWrapper(ruleName, ruleDirectives); + } + + private string GetRuleFilePath(string ruleName) + { + bool IsRequestedRule(string filePath) + { + return string.Equals(ruleName, Path.GetFileNameWithoutExtension(filePath), StringComparison.OrdinalIgnoreCase); + } + + var ruleFilePath = Directory.EnumerateFiles(_rulesPath, SCRIPT_RULE_NAME_PATTERN, SearchOption.TopDirectoryOnly) + .First(IsRequestedRule); + + if (ruleFilePath == null) + { + var errorMsg = $"Rule code file '{ruleName}.rule' not found at expected Path {_rulesPath}"; + _logger.WriteError(errorMsg); + throw new FileNotFoundException(errorMsg); + } + + _logger.WriteVerbose($"Rule code found at {ruleFilePath}"); + return ruleFilePath; + } + } +} \ No newline at end of file diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs deleted file mode 100644 index 9e163b35..00000000 --- a/src/aggregator-function/RuleWrapper.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using aggregator.Engine; -using Microsoft.VisualStudio.Services.Common; -using Microsoft.VisualStudio.Services.WebApi; - -namespace aggregator -{ - /// - /// Contains Aggregator specific code with no reference to Rule triggering - /// - internal class RuleWrapper - { - private readonly AggregatorConfiguration configuration; - private readonly IAggregatorLogger logger; - private readonly string ruleName; - private readonly string functionDirectory; - - public RuleWrapper(AggregatorConfiguration configuration, IAggregatorLogger logger, string ruleName, string functionDirectory) - { - this.configuration = configuration; - this.logger = logger; - this.ruleName = ruleName; - this.functionDirectory = functionDirectory; - } - - internal async Task ExecuteAsync(WorkItemEventContext eventContext, CancellationToken cancellationToken) - { - logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); - var clientCredentials = default(VssCredentials); - if (configuration.DevOpsTokenType == DevOpsTokenType.PAT) - { - clientCredentials = new VssBasicCredential(configuration.DevOpsTokenType.ToString(), configuration.DevOpsToken); - } - else - { - logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); - throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - // TODO improve from https://github.com/Microsoft/vsts-work-item-migrator - using (var devops = new VssConnection(eventContext.CollectionUri, clientCredentials)) - { - await devops.ConnectAsync(cancellationToken); - logger.WriteInfo($"Connected to Azure DevOps"); - using (var clientsContext = new AzureDevOpsClientsContext(devops)) - { - string ruleFilePath = Path.Combine(functionDirectory, $"{ruleName}.rule"); - if (!File.Exists(ruleFilePath)) - { - logger.WriteError($"Rule code not found at {ruleFilePath}"); - return "Rule file not found!"; - } - - logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - string[] ruleCode; - using (var fileStream = File.OpenRead(ruleFilePath)) - { - var reader = new StreamReader(fileStream); - ruleCode = await ReadAllLinesAsync(reader); - } - - var engine = new Engine.RuleEngine(logger, ruleCode, configuration.SaveMode, configuration.DryRun); - - return await engine.ExecuteAsync(eventContext.ProjectId, eventContext.WorkItemPayload, clientsContext, cancellationToken); - } - } - } - - private static async Task ReadAllLinesAsync(TextReader streamReader) - { - var lines = new List(); - string line; - while ((line = await streamReader.ReadLineAsync()) != null) - { - lines.Add(line); - } - - return lines.ToArray(); - } - } -} diff --git a/src/aggregator-ruleng/CoreFieldRefNames.cs b/src/aggregator-ruleng/CoreFieldRefNames.cs index 337c5247..b3a96536 100644 --- a/src/aggregator-ruleng/CoreFieldRefNames.cs +++ b/src/aggregator-ruleng/CoreFieldRefNames.cs @@ -1,5 +1,5 @@ /* - * TODO replace this class with references to + * TODO replace this class with references to using Microsoft.TeamFoundation.WorkItemTracking.Common; using Microsoft.TeamFoundation.WorkItemTracking.Common.Constants; */ @@ -31,6 +31,7 @@ internal class CoreFieldRefNames public const string IterationPath = "System.IterationPath"; public const string Reason = "System.Reason"; public const string RelatedLinkCount = "System.RelatedLinkCount"; + public const string RevisedBy = "System.RevisedBy"; public const string RevisedDate = "System.RevisedDate"; public const string AuthorizedDate = "System.AuthorizedDate"; public const string Tags = "System.Tags"; diff --git a/src/aggregator-ruleng/DirectivesParser.cs b/src/aggregator-ruleng/DirectivesParser.cs deleted file mode 100644 index d10ccb9f..00000000 --- a/src/aggregator-ruleng/DirectivesParser.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace aggregator.Engine -{ - /// - /// Scan the top lines of a script looking for directives, whose lines start with a . - /// - internal class DirectivesParser - { - int firstCodeLine = 0; - private readonly IAggregatorLogger logger; - string[] ruleCode; - List references = new List(); - List imports = new List(); - - internal DirectivesParser(IAggregatorLogger logger, string[] ruleCode) - { - this.ruleCode = ruleCode; - this.logger = logger; - - //defaults - Language = Languages.Csharp; - References = references; - Imports = imports; - } - - /// - /// Grab directives - /// - internal bool Parse() - { - while (firstCodeLine < ruleCode.Length - && ruleCode[firstCodeLine].Length > 0 - && ruleCode[firstCodeLine][0] == '.') - { - string directive = ruleCode[firstCodeLine].Substring(1); - var parts = directive.Split('='); - - switch (parts[0].ToLowerInvariant()) - { - case "lang": - case "language": - if (parts.Length < 2) - { - logger.WriteWarning($"Unrecognized directive {directive}"); - return false; - } - else - { - switch (parts[1].ToUpperInvariant()) - { - case "C#": - case "CS": - case "CSHARP": - Language = Languages.Csharp; - break; - default: - logger.WriteWarning($"Unrecognized language {parts[1]}"); - return false; - } - } - break; - - case "r": - case "ref": - case "reference": - if (parts.Length < 2) - { - logger.WriteWarning($"Invalid reference directive {directive}"); - return false; - } - else - { - references.Add(parts[1]); - } - break; - - case "import": - case "imports": - case "namespace": - if (parts.Length < 2) - { - logger.WriteWarning($"Invalid import directive {directive}"); - return false; - } - else - { - imports.Add(parts[1]); - } - break; - - default: - logger.WriteWarning($"Unrecognized directive {directive}"); - return false; - }//switch - - firstCodeLine++; - }//while - return true; - } - - internal string GetRuleCode() - { - StringBuilder sb = new StringBuilder(); - // Keep directive lines commented out, to maintain source location of rule code for diagnostics. - for(int i=0; i References { get; private set; } - internal IReadOnlyList Imports { get; private set; } - } -} diff --git a/src/aggregator-ruleng/IAggregatorLogger.cs b/src/aggregator-ruleng/IAggregatorLogger.cs index c7214022..454872a1 100644 --- a/src/aggregator-ruleng/IAggregatorLogger.cs +++ b/src/aggregator-ruleng/IAggregatorLogger.cs @@ -14,4 +14,63 @@ public interface IAggregatorLogger void WriteError(string message); } -} + + /// + /// an emtpy logger implementation + /// + public class NullLogger : IAggregatorLogger + { + /// + public void WriteVerbose(string message) + { + } + + + /// + public void WriteInfo(string message) + { + } + + + /// + public void WriteWarning(string message) + { + } + + + /// + public void WriteError(string message) + { + } + } + + public static class AggregatorLoggerExtensions + { + public static void WriteVerbose(this IAggregatorLogger logger, string[] messages) + { + WriteMultiple(logger.WriteVerbose, messages); + } + public static void WriteInfo(this IAggregatorLogger logger, string[] messages) + { + WriteMultiple(logger.WriteInfo, messages); + } + + public static void WriteWarning(this IAggregatorLogger logger, string[] messages) + { + WriteMultiple(logger.WriteWarning, messages); + } + + public static void WriteError(this IAggregatorLogger logger, string[] messages) + { + WriteMultiple(logger.WriteError, messages); + } + + private static void WriteMultiple(Action logAction, string[] messages) + { + foreach (var message in messages) + { + logAction(message); + } + } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/IRule.cs b/src/aggregator-ruleng/IRule.cs new file mode 100644 index 00000000..c2dd4371 --- /dev/null +++ b/src/aggregator-ruleng/IRule.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + + +namespace aggregator.Engine { + public interface IRule + { + /// + /// RuleName + /// + string Name { get; } + + /// + /// The history will show the changes made by person who triggered the event + /// Assumes PAT or Account Permission is high enough + /// + bool ImpersonateExecution { get; set; } + + /// + /// Apply the rule to executionContext + /// + /// + /// + /// + Task ApplyAsync(RuleExecutionContext executionContext, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/IRuleProvider.cs b/src/aggregator-ruleng/IRuleProvider.cs new file mode 100644 index 00000000..633f8b25 --- /dev/null +++ b/src/aggregator-ruleng/IRuleProvider.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace aggregator.Engine +{ + public interface IRuleProvider + { + Task GetRule(string name); + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/IRuleResult.cs b/src/aggregator-ruleng/IRuleResult.cs new file mode 100644 index 00000000..bd54792e --- /dev/null +++ b/src/aggregator-ruleng/IRuleResult.cs @@ -0,0 +1,31 @@ +namespace aggregator.Engine +{ + public enum RuleExecutionOutcome + { + Unknown, + Success, + Error + } + + public interface IRuleResult + { + /// + /// Result Value Message + /// + string Value { get; } + + /// + /// Execution Outcome + /// + RuleExecutionOutcome Outcome { get; } + } + + public class RuleResult : IRuleResult + { + /// + public string Value { get; set; } + + /// + public RuleExecutionOutcome Outcome { get; set; } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/Language/IRuleDirectives.cs b/src/aggregator-ruleng/Language/IRuleDirectives.cs new file mode 100644 index 00000000..423ff67a --- /dev/null +++ b/src/aggregator-ruleng/Language/IRuleDirectives.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + + +namespace aggregator.Engine.Language { + public interface IRuleDirectives + { + bool Impersonate { get; set; } + RuleLanguage Language { get; } + IReadOnlyList References { get; } + IReadOnlyList Imports { get; } + int RuleCodeOffset { get; } + IReadOnlyList RuleCode { get; } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/Language/RuleDirectives.cs b/src/aggregator-ruleng/Language/RuleDirectives.cs new file mode 100644 index 00000000..eb25795d --- /dev/null +++ b/src/aggregator-ruleng/Language/RuleDirectives.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + + +namespace aggregator.Engine.Language { + internal class RuleDirectives : IRuleDirectives + { + public RuleDirectives() + { + Impersonate = false; + Language = RuleLanguage.Unknown; + References = new List(); + Imports = new List(); + RuleCodeOffset = 0; + RuleCode = new List(); + } + + public bool Impersonate { get; set; } + + public RuleLanguage Language { get; internal set; } + + IReadOnlyList IRuleDirectives.References => new ReadOnlyCollection(References); + + IReadOnlyList IRuleDirectives.Imports => new ReadOnlyCollection(Imports); + + IReadOnlyList IRuleDirectives.RuleCode => new ReadOnlyCollection(RuleCode); + + + public IList References { get; } + + public IList Imports { get; } + + public int RuleCodeOffset { get; set; } + + public IList RuleCode { get; } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/Language/RuleDirectivesExtensions.cs b/src/aggregator-ruleng/Language/RuleDirectivesExtensions.cs new file mode 100644 index 00000000..0bd462cb --- /dev/null +++ b/src/aggregator-ruleng/Language/RuleDirectivesExtensions.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + + +namespace aggregator.Engine.Language { + public static class RuleDirectivesExtensions + { + internal static string GetRuleCode(this IRuleDirectives ruleDirectives) + { + return string.Join(Environment.NewLine, ruleDirectives.RuleCode); + } + + internal static bool IsValid(this IRuleDirectives ruleDirectives) + { + return ruleDirectives.IsSupportedLanguage() && ruleDirectives.RuleCode.Any(); + } + + internal static bool IsCSharp(this IRuleDirectives ruleDirectives) + { + return ruleDirectives.Language == RuleLanguage.Csharp; + } + + internal static bool IsSupportedLanguage(this IRuleDirectives ruleDirectives) + { + return ruleDirectives.Language != RuleLanguage.Unknown; + } + + + public static string LanguageAsString(this IRuleDirectives ruleDirectives) + { + switch (ruleDirectives.Language) + { + case RuleLanguage.Csharp: + return "C#"; + case RuleLanguage.Unknown: + return "Unknown"; + default: + throw new ArgumentOutOfRangeException(); + } + } + + internal static IEnumerable LoadAssemblyReferences(this IRuleDirectives ruleDirectives) + { + return ruleDirectives.References + .Select(reference => new AssemblyName(reference)) + .Select(Assembly.Load); + } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/Language/RuleFileParser.cs b/src/aggregator-ruleng/Language/RuleFileParser.cs new file mode 100644 index 00000000..49e860fd --- /dev/null +++ b/src/aggregator-ruleng/Language/RuleFileParser.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.Services.Common; + + +namespace aggregator.Engine.Language +{ + /// + /// Scan the top lines of a script looking for directives, whose lines start with a dot + /// + public static class RuleFileParser + { + public static async Task<(IRuleDirectives ruleDirectives, bool result)> ReadFile(string ruleFilePath, CancellationToken cancellationToken = default) + { + return await ReadFile(ruleFilePath, new NullLogger(), cancellationToken); + } + + public static async Task<(IRuleDirectives ruleDirectives, bool result)> ReadFile(string ruleFilePath, IAggregatorLogger logger, CancellationToken cancellationToken = default) + { + var content = await ReadAllLinesAsync(ruleFilePath, cancellationToken); + return Read(content, logger); + } + + /// + /// Grab directives + /// + public static (IRuleDirectives ruleDirectives, bool parseSuccess) Read(string[] ruleCode, IAggregatorLogger logger = default) + { + var parsingIssues = false; + void FailParsingWithMessage(string message) + { + logger?.WriteWarning(message); + parsingIssues = true; + } + + var directiveLineIndex = 0; + var ruleDirectives = new RuleDirectives() + { + Language = RuleLanguage.Csharp + }; + + while (directiveLineIndex < ruleCode.Length + && ruleCode[directiveLineIndex].Length > 0 + && ruleCode[directiveLineIndex][0] == '.') + { + string directive = ruleCode[directiveLineIndex].Substring(1); + var parts = directive.Split('='); + + switch (parts[0].ToLowerInvariant()) + { + case "lang": + case "language": + if (parts.Length < 2) + { + FailParsingWithMessage($"Invalid language directive {directive}"); + } + else + { + switch (parts[1].ToUpperInvariant()) + { + case "C#": + case "CS": + case "CSHARP": + ruleDirectives.Language = RuleLanguage.Csharp; + break; + default: + { + FailParsingWithMessage($"Unrecognized language {parts[1]}"); + ruleDirectives.Language = RuleLanguage.Unknown; + break; + } + } + } + break; + + case "r": + case "ref": + case "reference": + if (parts.Length < 2) + { + FailParsingWithMessage($"Invalid reference directive {directive}"); + } + else + { + ruleDirectives.References.Add(parts[1]); + } + break; + + case "import": + case "imports": + case "namespace": + if (parts.Length < 2) + { + FailParsingWithMessage($"Invalid import directive {directive}"); + } + else + { + ruleDirectives.Imports.Add(parts[1]); + } + break; + + case "impersonate": + if (parts.Length < 2) + { + FailParsingWithMessage($"Invalid impersonate directive {directive}"); + } + else + { + ruleDirectives.Impersonate = string.Equals("onBehalfOfInitiator", parts[1].TrimEnd(), StringComparison.OrdinalIgnoreCase); + } + break; + + default: + { + FailParsingWithMessage($"Unrecognized directive {directive}"); + break; + } + }//switch + + ruleDirectives.RuleCode.Add($"//{directive}"); + directiveLineIndex++; + }//while + + ruleDirectives.RuleCodeOffset = directiveLineIndex; + ruleDirectives.RuleCode.AddRange(ruleCode.Skip(directiveLineIndex)); + var parseSuccessful = !parsingIssues; + return (ruleDirectives, parseSuccessful); + } + + public static async Task WriteFile(string ruleFilePath, IRuleDirectives ruleDirectives, CancellationToken cancellationToken = default) + { + var content = Write(ruleDirectives); + + await WriteAllLinesAsync(ruleFilePath, content, cancellationToken); + } + + public static string[] Write(IRuleDirectives ruleDirectives) + { + var content = new List + { + $".language={ruleDirectives.LanguageAsString()}" + }; + + if (ruleDirectives.Impersonate) + { + content.Add($".impersonate=onBehalfOfInitiator"); + } + + content.AddRange(ruleDirectives.References.Select(reference => $".reference={reference}")); + content.AddRange(ruleDirectives.Imports.Select(import => $".import={import}")); + content.AddRange(ruleDirectives.RuleCode.Skip(ruleDirectives.RuleCodeOffset)); + + return content.ToArray(); + } + + private static async Task ReadAllLinesAsync(string ruleFilePath, CancellationToken cancellationToken) + { + using (var fileStream = File.OpenRead(ruleFilePath)) + { + using (var streamReader = new StreamReader(fileStream)) + { + var lines = new List(); + string line; + while ((line = await streamReader.ReadLineAsync().ConfigureAwait(false)) != null) + { + cancellationToken.ThrowIfCancellationRequested(); + lines.Add(line); + } + + return lines.ToArray(); + } + } + } + + private static async Task WriteAllLinesAsync(string ruleFilePath, IEnumerable ruleContent, CancellationToken cancellationToken) + { + using (var fileStream = File.OpenWrite(ruleFilePath)) + { + using (var streamWriter = new StreamWriter(fileStream)) + { + foreach (var line in ruleContent) + { + cancellationToken.ThrowIfCancellationRequested(); + await streamWriter.WriteLineAsync(line).ConfigureAwait(false); + } + } + } + } + } +} diff --git a/src/aggregator-ruleng/Language/RuleLanguage.cs b/src/aggregator-ruleng/Language/RuleLanguage.cs new file mode 100644 index 00000000..dbe2ebce --- /dev/null +++ b/src/aggregator-ruleng/Language/RuleLanguage.cs @@ -0,0 +1,7 @@ +namespace aggregator.Engine.Language { + public enum RuleLanguage + { + Csharp, + Unknown + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index ffb09873..f05dabfe 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -5,112 +5,48 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; + +using aggregator.Engine.Language; + using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; namespace aggregator.Engine { - public enum EngineState + internal interface IRuleEngine { - Unknown, - Success, - Error + Task RunAsync(IRule rule, Guid projectId, WorkItemData workItemPayload, IClientsContext clients, CancellationToken cancellationToken = default); } - /// - /// Entry point to execute rules, independent of environment - /// - public class RuleEngine + public abstract class RuleEngineBase : IRuleEngine { - private readonly IAggregatorLogger logger; - private readonly Script roslynScript; - private readonly SaveMode saveMode; + protected IAggregatorLogger logger; - public RuleEngine(IAggregatorLogger logger, string[] ruleCode, SaveMode mode, bool dryRun) - { - State = EngineState.Unknown; + protected SaveMode saveMode; - this.logger = logger; - this.saveMode = mode; - this.DryRun = dryRun; + protected bool dryRun; - var directives = new DirectivesParser(logger, ruleCode); - if (!directives.Parse()) - { - State = EngineState.Error; - return; - } - if (directives.Language == DirectivesParser.Languages.Csharp) - { - var references = LoadReferences(directives); - var imports = GetImports(directives); - - var scriptOptions = ScriptOptions.Default - .WithEmitDebugInformation(true) - .WithReferences(references) - // Add namespaces - .WithImports(imports); - - this.roslynScript = CSharpScript.Create( - code: directives.GetRuleCode(), - options: scriptOptions, - globalsType: typeof(Globals)); - } - else - { - logger.WriteError($"Cannot execute rule: language is not supported."); - State = EngineState.Error; - } + protected RuleEngineBase(IAggregatorLogger logger, SaveMode saveMode, bool dryRun) + { + this.logger = logger; + this.saveMode = saveMode; + this.dryRun = dryRun; } - private static IEnumerable LoadReferences(DirectivesParser directives) + public async Task RunAsync(IRule rule, Guid projectId, WorkItemData workItemPayload, IClientsContext clients, CancellationToken cancellationToken = default) { - var types = new List() { - typeof(object), - typeof(System.Linq.Enumerable), - typeof(System.Collections.Generic.CollectionExtensions), - typeof(Microsoft.VisualStudio.Services.WebApi.IdentityRef), - typeof(WorkItemWrapper) - }; - var references = types.ConvertAll(t => t.Assembly); - // user references - foreach (var reference in directives.References) - { - var name = new AssemblyName(reference); - references.Add(Assembly.Load(name)); - } + var executionContext = CreateRuleExecutionContext(projectId, workItemPayload, clients); - return references.Distinct(); - } + var result = await ExecuteRuleAsync(rule, executionContext, cancellationToken); - private static IEnumerable GetImports(DirectivesParser directives) - { - var imports = new List - { - "System", - "System.Linq", - "System.Collections.Generic", - "Microsoft.VisualStudio.Services.WebApi", - "aggregator.Engine" - }; - imports.AddRange(directives.Imports); - return imports.Distinct(); + return result; } - /// - /// State is used by unit tests - /// - public EngineState State { get; private set; } - public bool DryRun { get; } + protected abstract Task ExecuteRuleAsync(IRule rule, RuleExecutionContext executionContext, CancellationToken cancellationToken = default); - public async Task ExecuteAsync(Guid projectId, WorkItemData workItemPayload, IClientsContext clients, CancellationToken cancellationToken) + protected RuleExecutionContext CreateRuleExecutionContext(Guid projectId, WorkItemData workItemPayload, IClientsContext clients) { - if (State == EngineState.Error) - { - return string.Empty; - } - var workItem = workItemPayload.WorkItem; var context = new EngineContext(clients, projectId, workItem.GetTeamProject(), logger); var store = new WorkItemStore(context, workItem); @@ -118,57 +54,40 @@ public async Task ExecuteAsync(Guid projectId, WorkItemData workItemPayl var selfChanges = new WorkItemUpdateWrapper(workItemPayload.WorkItemUpdate); logger.WriteInfo($"Initial WorkItem {self.Id} retrieved from {clients.WitClient.BaseAddress}"); - var globals = new Globals + var globals = new RuleExecutionContext { self = self, selfChanges = selfChanges, store = store, logger = logger }; + return globals; + } + } - logger.WriteInfo($"Executing Rule..."); - var result = await roslynScript.RunAsync(globals, cancellationToken); - if (result.Exception != null) - { - logger.WriteError($"Rule failed with {result.Exception}"); - State = EngineState.Error; - } - else - { - logger.WriteInfo($"Rule succeeded with {result.ReturnValue ?? "no return value"}"); - State = EngineState.Success; - } - - logger.WriteVerbose($"Post-execution, save any change (mode {saveMode})..."); - var saveRes = await store.SaveChanges(saveMode, !DryRun, cancellationToken); - if (saveRes.created + saveRes.updated > 0) - { - logger.WriteInfo($"Changes saved to Azure DevOps (mode {saveMode}): {saveRes.created} created, {saveRes.updated} updated."); - } - else - { - logger.WriteInfo($"No changes saved to Azure DevOps."); - } - return result.ReturnValue; + public class RuleEngine : RuleEngineBase + { + public RuleEngine(IAggregatorLogger logger, SaveMode saveMode, bool dryRun) : base(logger, saveMode, dryRun) + { } - public (bool success, ImmutableArray diagnostics) VerifyRule() + protected override async Task ExecuteRuleAsync(IRule rule, RuleExecutionContext executionContext, CancellationToken cancellationToken = default) { - ImmutableArray diagnostics = roslynScript.Compile(); - (bool, ImmutableArray) result; - if (diagnostics.Any()) + var result = await rule.ApplyAsync(executionContext, cancellationToken); + + var store = executionContext.store; + var (created, updated) = await store.SaveChanges(saveMode, !dryRun, rule.ImpersonateExecution, cancellationToken); + if (created + updated > 0) { - State = EngineState.Error; - result = (false, diagnostics); + logger.WriteInfo($"Changes saved to Azure DevOps (mode {saveMode}): {created} created, {updated} updated."); } else { - State = EngineState.Success; - result = (true, ImmutableArray.Create()); + logger.WriteInfo($"No changes saved to Azure DevOps."); } - return result; + return result.Value; } } } diff --git a/src/aggregator-ruleng/Globals.cs b/src/aggregator-ruleng/RuleExecutionContext.cs similarity index 88% rename from src/aggregator-ruleng/Globals.cs rename to src/aggregator-ruleng/RuleExecutionContext.cs index 37719d6a..e1bd49c9 100644 --- a/src/aggregator-ruleng/Globals.cs +++ b/src/aggregator-ruleng/RuleExecutionContext.cs @@ -4,7 +4,7 @@ namespace aggregator.Engine { - public class Globals + public class RuleExecutionContext { public WorkItemWrapper self; public WorkItemUpdateWrapper selfChanges; diff --git a/src/aggregator-ruleng/RuleExecutor.cs b/src/aggregator-ruleng/RuleExecutor.cs new file mode 100644 index 00000000..3dae7084 --- /dev/null +++ b/src/aggregator-ruleng/RuleExecutor.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Services.Common; +using Microsoft.VisualStudio.Services.WebApi; + +namespace aggregator.Engine +{ + public class RuleExecutor + { + protected readonly IAggregatorConfiguration configuration; + + protected readonly IAggregatorLogger logger; + + public RuleExecutor(IAggregatorLogger logger, IAggregatorConfiguration configuration) + { + this.configuration = configuration; + this.logger = logger; + } + + public async Task ExecuteAsync(IRule rule, WorkItemEventContext eventContext, CancellationToken cancellationToken) + { + logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); + var clientCredentials = default(VssCredentials); + if (configuration.DevOpsTokenType == DevOpsTokenType.PAT) + { + clientCredentials = new VssBasicCredential(configuration.DevOpsTokenType.ToString(), configuration.DevOpsToken); + } + else + { + logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); + throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // TODO improve from https://github.com/Microsoft/vsts-work-item-migrator + using (var devops = new VssConnection(eventContext.CollectionUri, clientCredentials)) + { + await devops.ConnectAsync(cancellationToken); + logger.WriteInfo($"Connected to Azure DevOps"); + using (var clientsContext = new AzureDevOpsClientsContext(devops)) + { + var engine = new RuleEngine(logger, configuration.SaveMode, configuration.DryRun); + + var ruleResult = await engine.RunAsync(rule, eventContext.ProjectId, eventContext.WorkItemPayload, clientsContext, cancellationToken); + logger.WriteInfo(ruleResult); + return ruleResult; + } + } + } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/ScriptedRuleWrapper.cs b/src/aggregator-ruleng/ScriptedRuleWrapper.cs new file mode 100644 index 00000000..dbbe1c4b --- /dev/null +++ b/src/aggregator-ruleng/ScriptedRuleWrapper.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using aggregator.Engine.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace aggregator.Engine +{ + /// + /// CSharp Scripted Rule Facade + /// + public class ScriptedRuleWrapper : IRule + { + private Script _roslynScript; + private readonly IAggregatorLogger _logger; + private readonly bool? _ruleFileParseSuccess; + + /// + public string Name { get; } + + /// + public bool ImpersonateExecution { get; set; } + + internal IRuleDirectives RuleDirectives { get; set; } + + private ScriptedRuleWrapper(string ruleName, IAggregatorLogger logger) + { + _logger = logger; + Name = ruleName; + } + + internal ScriptedRuleWrapper(string ruleName, string[] ruleCode) : this(ruleName, new NullLogger()) + { + (IRuleDirectives ruleDirectives, bool parseSuccess) = RuleFileParser.Read(ruleCode); + _ruleFileParseSuccess = parseSuccess; + + Initialize(ruleDirectives); + } + + public ScriptedRuleWrapper(string ruleName, IRuleDirectives ruleDirectives) : this(ruleName, ruleDirectives, new NullLogger()) + { + } + + public ScriptedRuleWrapper(string ruleName, IRuleDirectives ruleDirectives, IAggregatorLogger logger) : this(ruleName, logger) + { + Initialize(ruleDirectives); + } + + private void Initialize(IRuleDirectives ruleDirectives) + { + RuleDirectives = ruleDirectives; + ImpersonateExecution = RuleDirectives.Impersonate; + + var references = new HashSet(DefaultAssemblyReferences().Concat(RuleDirectives.LoadAssemblyReferences())); + var imports = new HashSet(DefaultImports().Concat(RuleDirectives.Imports)); + + var scriptOptions = ScriptOptions.Default + .WithEmitDebugInformation(true) + .WithReferences(references) + // Add namespaces + .WithImports(imports); + + if (RuleDirectives.IsCSharp()) + { + _roslynScript = CSharpScript.Create( + code: RuleDirectives.GetRuleCode(), + options: scriptOptions, + globalsType: typeof(RuleExecutionContext)); + } + else + { + _logger.WriteError($"Cannot execute rule: language is not supported."); + } + } + + private static IEnumerable DefaultAssemblyReferences() + { + var types = new List() + { + typeof(object), + typeof(System.Linq.Enumerable), + typeof(System.Collections.Generic.CollectionExtensions), + typeof(Microsoft.VisualStudio.Services.WebApi.IdentityRef), + typeof(WorkItemWrapper) + }; + + return types.Select(t => t.Assembly); + } + + private static IEnumerable DefaultImports() + { + var imports = new List + { + "System", + "System.Linq", + "System.Collections.Generic", + "Microsoft.VisualStudio.Services.WebApi", + "aggregator.Engine" + }; + + return imports; + } + + /// + public async Task ApplyAsync(RuleExecutionContext executionContext, CancellationToken cancellationToken) + { + var result = await _roslynScript.RunAsync(executionContext, cancellationToken); + + if (result.Exception != null) + { + _logger.WriteError($"Rule failed with {result.Exception}"); + return new RuleResult() + { + Outcome = RuleExecutionOutcome.Error, + Value = result.Exception.ToString() + }; + } + + _logger.WriteInfo($"Rule succeeded with {result.ReturnValue ?? "no return value"}"); + return new RuleResult() + { + Outcome = RuleExecutionOutcome.Success, + Value = result.ReturnValue // ?? string.Empty + }; + } + + /// + /// Verify the script rule code by trying to compile and return compile errors + /// if parsing rule code already fails, success will also be false + /// + /// + public (bool success, IReadOnlyList diagnostics) Verify() + { + if (_ruleFileParseSuccess.HasValue && !_ruleFileParseSuccess.Value) + { + return (false, ImmutableArray.Create()); + } + + // if parsing succeeded try to compile the script and look for errors + var diagnostics = _roslynScript.Compile(); + var result = diagnostics.Any() ? (false, diagnostics) : (true, ImmutableArray.Create()); + + return result; + } + + } +} + diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index 9a9ab51f..4a793e38 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.TeamFoundation.Work.WebApi.Contracts; +using Microsoft.VisualStudio.Services.WebApi; using Microsoft.VisualStudio.Services.WebApi.Patch.Json; @@ -20,6 +21,9 @@ public class WorkItemStore private readonly Lazy>> _lazyGetWorkItemCategories; private readonly Lazy>> _lazyGetBacklogWorkItemTypesAndStates; + private readonly IdentityRef _triggerIdentity; + + public WorkItemStore(EngineContext context) { _context = context; @@ -31,7 +35,9 @@ public WorkItemStore(EngineContext context) public WorkItemStore(EngineContext context, WorkItem workItem) : this(context) { //initialize tracker with initial work item - _ = new WorkItemWrapper(_context, workItem); + var wrapper = new WorkItemWrapper(_context, workItem); + //store event initiator identity + _triggerIdentity = wrapper.ChangedBy; } public WorkItemWrapper GetWorkItem(int id) @@ -130,28 +136,46 @@ public async Task> GetBacklogWorkItemType return await _lazyGetBacklogWorkItemTypesAndStates.Value; } - public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, CancellationToken cancellationToken) + + private void ImpersonateChanges() + { + var (created, updated, _, _) = _context.Tracker.GetChangedWorkItems(); + + var changedWorkItems = created.Concat(updated); + + foreach (var workItem in changedWorkItems) + { + workItem.ChangedBy = _triggerIdentity; + } + } + + public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, bool impersonate, CancellationToken cancellationToken) { + if (impersonate) + { + ImpersonateChanges(); + } + switch (mode) { case SaveMode.Default: _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}."); goto case SaveMode.TwoPhases; case SaveMode.Item: - var resultItem = await SaveChanges_ByItem(commit, cancellationToken); + var resultItem = await SaveChanges_ByItem(commit, impersonate, cancellationToken); return resultItem; case SaveMode.Batch: - var resultBatch = await SaveChanges_Batch(commit, cancellationToken); + var resultBatch = await SaveChanges_Batch(commit, impersonate, cancellationToken); return resultBatch; case SaveMode.TwoPhases: - var resultTwoPhases = await SaveChanges_TwoPhases(commit, cancellationToken); + var resultTwoPhases = await SaveChanges_TwoPhases(commit, impersonate, cancellationToken); return resultTwoPhases; default: throw new InvalidOperationException($"Unsupported save mode: {mode}."); } } - private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit, CancellationToken cancellationToken) + private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit, bool impersonate, CancellationToken cancellationToken) { int created = 0; int updated = 0; @@ -166,6 +190,7 @@ public async Task> GetBacklogWorkItemType item.Changes, _context.ProjectName, item.WorkItemType, + bypassRules: impersonate, cancellationToken: cancellationToken ); } @@ -198,6 +223,7 @@ public async Task> GetBacklogWorkItemType _ = await _clients.WitClient.UpdateWorkItemAsync( item.Changes, item.Id, + bypassRules: impersonate, cancellationToken: cancellationToken ); } @@ -212,7 +238,7 @@ public async Task> GetBacklogWorkItemType return (created, updated); } - private async Task<(int created, int updated)> SaveChanges_Batch(bool commit, CancellationToken cancellationToken) + private async Task<(int created, int updated)> SaveChanges_Batch(bool commit, bool impersonate, 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 @@ -230,7 +256,7 @@ public async Task> GetBacklogWorkItemType var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName, item.WorkItemType, item.Changes, - bypassRules: false, + bypassRules: impersonate, suppressNotifications: false); batchRequests.Add(request); } @@ -241,7 +267,7 @@ public async Task> GetBacklogWorkItemType var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id, item.Changes, - bypassRules: false, + bypassRules: impersonate, suppressNotifications: false); batchRequests.Add(request); } @@ -270,7 +296,7 @@ private static bool IsSuccessStatusCode(int statusCode) //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, CancellationToken cancellationToken) + private async Task<(int created, int updated)> SaveChanges_TwoPhases(bool commit, bool impersonate, 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 @@ -298,7 +324,7 @@ private static bool IsSuccessStatusCode(int statusCode) var request = _clients.WitClient.CreateWorkItemBatchRequest(_context.ProjectName, item.WorkItemType, document, - bypassRules: false, + bypassRules: impersonate, suppressNotifications: false); batchRequests.Add(request); } @@ -330,7 +356,7 @@ private static bool IsSuccessStatusCode(int statusCode) var request = _clients.WitClient.CreateWorkItemBatchRequest(item.Id, item.Changes, - bypassRules: false, + bypassRules: impersonate, suppressNotifications: false); batchRequests.Add(request); } @@ -347,6 +373,7 @@ private static bool IsSuccessStatusCode(int statusCode) return (created, updated); } + private async Task> ExecuteBatchRequest(IList batchRequests, CancellationToken cancellationToken) { if (!batchRequests.Any()) return Enumerable.Empty(); diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 16bf9dfe..a62b4fcd 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -292,6 +292,12 @@ public int RelatedLinkCount set => SetFieldValue(CoreFieldRefNames.RelatedLinkCount, value); } + public IdentityRef RevisedBy + { + get => GetFieldValue(CoreFieldRefNames.RevisedBy); + set => SetFieldValue(CoreFieldRefNames.RevisedBy, value); + } + public DateTime? RevisedDate { get => GetFieldValue(CoreFieldRefNames.RevisedDate); diff --git a/src/aggregator-shared/AggregatorConfiguration.cs b/src/aggregator-shared/AggregatorConfiguration.cs index 6e19a24c..c0c31e6a 100644 --- a/src/aggregator-shared/AggregatorConfiguration.cs +++ b/src/aggregator-shared/AggregatorConfiguration.cs @@ -1,4 +1,12 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using aggregator.Model; + +using Microsoft.VisualStudio.Services.Common; + namespace aggregator { @@ -16,14 +24,67 @@ public enum SaveMode TwoPhases = 3 } - /// - /// This class tracks the configuration data that CLI writes and Function runtime reads - /// - public class AggregatorConfiguration + public interface IAggregatorConfiguration + { + DevOpsTokenType DevOpsTokenType { get; set; } + string DevOpsToken { get; set; } + SaveMode SaveMode { get; set; } + bool DryRun { get; set; } + + IDictionary RulesConfiguration { get; } + } + + public interface IRuleConfiguration { - public static AggregatorConfiguration Read(Microsoft.Extensions.Configuration.IConfiguration config) + string RuleName { get; } + bool IsDisabled { get; set; } + bool Impersonate { get; set; } + } + + + public static class AggregatorConfiguration + { + const string RULE_SETTINGS_PREFIX = "AzureWebJobs."; + + public static async Task ReadConfiguration(Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) { - var ac = new AggregatorConfiguration(); + (string ruleName, string key) SplitRuleNameKey(string input) + { + int idx = input.LastIndexOf('.'); + return (input.Substring(0, idx), input.Substring(idx + 1)); + } + + var settings = await webApp.GetAppSettingsAsync(); + var ac = new Model.AggregatorConfiguration(); + foreach (var ruleSetting in settings.Where(kvp => kvp.Key.StartsWith(RULE_SETTINGS_PREFIX)).Select(kvp => new { ruleNameKey = kvp.Key.Substring(RULE_SETTINGS_PREFIX.Length), value = kvp.Value.Value })) + { + var (ruleName, key) = SplitRuleNameKey(ruleSetting.ruleNameKey); + + var ruleConfig = ac.GetRuleConfiguration(ruleName); + if (string.Equals("Disabled", key, StringComparison.OrdinalIgnoreCase)) + { + ruleConfig.IsDisabled = Boolean.TryParse(ruleSetting.value, out bool result) && result; + } + + if (string.Equals("Impersonate", key, StringComparison.OrdinalIgnoreCase)) + { + ruleConfig.Impersonate = string.Equals("onBehalfOfInitiator", ruleSetting.value, StringComparison.OrdinalIgnoreCase); + } + } + + Enum.TryParse(settings.GetValueOrDefault("Aggregator_VstsTokenType")?.Value, out DevOpsTokenType vtt); + ac.DevOpsTokenType = vtt; + ac.DevOpsToken = settings.GetValueOrDefault("Aggregator_VstsToken")?.Value; + ac.SaveMode = Enum.TryParse(settings.GetValueOrDefault("Aggregator_SaveMode")?.Value, out SaveMode sm) + ? sm + : SaveMode.Default; + ac.DryRun = false; + return ac; + } + + public static IAggregatorConfiguration ReadConfiguration(Microsoft.Extensions.Configuration.IConfiguration config) + { + var ac = new Model.AggregatorConfiguration(); Enum.TryParse(config["Aggregator_VstsTokenType"], out DevOpsTokenType vtt); ac.DevOpsTokenType = vtt; ac.DevOpsToken = config["Aggregator_VstsToken"]; @@ -34,19 +95,67 @@ public static AggregatorConfiguration Read(Microsoft.Extensions.Configuration.IC return ac; } - public void Write(Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) + public static void WriteConfiguration(this IAggregatorConfiguration config, Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) + { + var settings = new Dictionary() + { + {"Aggregator_VstsTokenType", config.DevOpsTokenType.ToString()}, + {"Aggregator_VstsToken", config.DevOpsToken}, + {"Aggregator_SaveMode", config.SaveMode.ToString()}, + }; + + foreach (var ruleSetting in config.RulesConfiguration.Select(kvp => kvp.Value)) + { + settings.AddRuleSettings(ruleSetting); + } + + webApp.ApplyWithAppSettings(settings); + } + + public static void WriteConfiguration(this IRuleConfiguration config, Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) + { + var settings = new Dictionary(); + + settings.AddRuleSettings(config); + + webApp.ApplyWithAppSettings(settings); + } + + public static void Delete(this IRuleConfiguration config, Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) + { + var settings = new Dictionary(); + + settings.AddRuleSettings(config); + + var update = webApp.Update(); + + foreach (var key in settings.Keys) + { + update.WithoutAppSetting(key); + } + + update.Apply(); + } + + public static IRuleConfiguration GetRuleConfiguration(this IAggregatorConfiguration config, string ruleName) + { + var ruleConfig = config.RulesConfiguration.GetValueOrDefault(ruleName) ?? (config.RulesConfiguration[ruleName] = new RuleConfiguration(ruleName)); + + return ruleConfig; + } + + private static void AddRuleSettings(this Dictionary settings, IRuleConfiguration ruleSetting) + { + settings[$"{RULE_SETTINGS_PREFIX}{ruleSetting.RuleName}.Disabled"] = ruleSetting.IsDisabled.ToString(); + settings[$"{RULE_SETTINGS_PREFIX}{ruleSetting.RuleName}.Impersonate"] = ruleSetting.Impersonate ? "onBehalfOfInitiator" : "false"; + } + + private static void ApplyWithAppSettings(this Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp, Dictionary settings) { webApp .Update() - .WithAppSetting("Aggregator_VstsTokenType", DevOpsTokenType.ToString()) - .WithAppSetting("Aggregator_VstsToken", DevOpsToken) - .WithAppSetting("Aggregator_SaveMode", SaveMode.ToString()) + .WithAppSettings(settings) .Apply(); } - - public DevOpsTokenType DevOpsTokenType { get; set; } - public string DevOpsToken { get; set; } - public SaveMode SaveMode { get; set; } - public bool DryRun { get; internal set; } } } diff --git a/src/aggregator-shared/InvokeOptions.cs b/src/aggregator-shared/InvokeOptions.cs index 1c31b0a2..16b3c45b 100644 --- a/src/aggregator-shared/InvokeOptions.cs +++ b/src/aggregator-shared/InvokeOptions.cs @@ -1,27 +1,83 @@ using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; + +using Microsoft.WindowsAzure.Storage.Core; + namespace aggregator { public static class InvokeOptions { - public static string AppendToUrl(string ruleUrl, bool dryRun, SaveMode saveMode) + /// + /// extend Url with configuration information + /// + /// + /// + /// + /// + /// + public static Uri AddToUrl(this Uri ruleUrl, bool dryRun = false, SaveMode saveMode = SaveMode.Default, bool impersonate = false) { - return ruleUrl + FormattableString.Invariant($"?dryRun={dryRun}&saveMode={saveMode}"); + var queryBuilder = new UriQueryBuilder(); + + queryBuilder.AddIfNotDefault("dryRun", dryRun) + .AddIfNotDefault("saveMode", saveMode) + .AddIfNotDefault("execute", impersonate, valueString: "impersonated"); + + return queryBuilder.AddToUri(ruleUrl); } - public static AggregatorConfiguration ExtendFromUrl(AggregatorConfiguration configuration, Uri requestUri) + /// + /// + /// + /// + /// + /// + /// + public static IAggregatorConfiguration UpdateFromUrl(this IAggregatorConfiguration configuration, string ruleName, Uri requestUri) { var parameters = System.Web.HttpUtility.ParseQueryString(requestUri.Query); + configuration.DryRun = IsDryRunEnabled(parameters); + configuration.SaveMode = GetSaveMode(parameters); + configuration.GetRuleConfiguration(ruleName).Impersonate = IsImpersonationEnabled(parameters); + + return configuration; + } + + public static bool IsImpersonationEnabled(this Uri ruleUrl) + { + var parameters = System.Web.HttpUtility.ParseQueryString(ruleUrl.Query); + + return IsImpersonationEnabled(parameters); + } + + private static bool IsImpersonationEnabled(NameValueCollection parameters) + { + return string.Equals(parameters["execute"], "impersonated", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDryRunEnabled(NameValueCollection parameters) + { bool dryRun = bool.TryParse(parameters["dryRun"], out dryRun) && dryRun; - configuration.DryRun = dryRun; + return dryRun; + } - if (Enum.TryParse(parameters["saveMode"], out SaveMode saveMode)) + private static SaveMode GetSaveMode(NameValueCollection parameters) + { + return Enum.TryParse(parameters["saveMode"], out SaveMode saveMode) ? saveMode : SaveMode.Default; + } + + private static UriQueryBuilder AddIfNotDefault(this UriQueryBuilder queryBuilder, string name, T value, T defaultValue = default, string valueString = null) + { + if (!EqualityComparer.Default.Equals(defaultValue, value)) { - configuration.SaveMode = saveMode; + queryBuilder.Add(name, valueString ?? Convert.ToString(value, CultureInfo.InvariantCulture)); } - return configuration; + return queryBuilder; } } } diff --git a/src/aggregator-shared/Model/AggregatorConfiguration.cs b/src/aggregator-shared/Model/AggregatorConfiguration.cs new file mode 100644 index 00000000..0de68f9e --- /dev/null +++ b/src/aggregator-shared/Model/AggregatorConfiguration.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; + + +namespace aggregator.Model +{ + /// + /// This class tracks the configuration data that CLI writes and Function runtime reads + /// + [DebuggerDisplay("SaveMode={SaveMode,nq}, DryRun={DryRun}")] + internal class AggregatorConfiguration : IAggregatorConfiguration + { + public AggregatorConfiguration() + { + RulesConfiguration = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + public DevOpsTokenType DevOpsTokenType { get; set; } + public string DevOpsToken { get; set; } + public SaveMode SaveMode { get; set; } + public bool DryRun { get; set; } + + public IDictionary RulesConfiguration { get; } + } +} \ No newline at end of file diff --git a/src/aggregator-shared/Model/RuleConfiguration.cs b/src/aggregator-shared/Model/RuleConfiguration.cs new file mode 100644 index 00000000..7cac2e85 --- /dev/null +++ b/src/aggregator-shared/Model/RuleConfiguration.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; + + +namespace aggregator.Model +{ + [DebuggerDisplay("{RuleName,nq}, Disabled={IsDisabled}, Impersonate={Impersonate}")] + internal class RuleConfiguration : IRuleConfiguration + { + public RuleConfiguration(string ruleName) + { + RuleName = ruleName; + } + public string RuleName { get; } + public bool IsDisabled { get; set; } + public bool Impersonate { get; set; } + } +} \ No newline at end of file diff --git a/src/unittests-function/AzureFunctionHandlerTests.cs b/src/unittests-function/AzureFunctionHandlerTests.cs new file mode 100644 index 00000000..e9f26ab3 --- /dev/null +++ b/src/unittests-function/AzureFunctionHandlerTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using aggregator; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using unittests_ruleng.TestData; +using Xunit; +using ExecutionContext = Microsoft.Azure.WebJobs.ExecutionContext; + +namespace unittests_function +{ + public class AzureFunctionHandlerTests + { + private readonly ILogger logger; + private readonly ExecutionContext context; + private readonly HttpRequestMessage request; + + public AzureFunctionHandlerTests() + { + logger = Substitute.For(); + context = Substitute.For(); + context.InvocationId = Guid.Empty; + context.FunctionName = "TestRule"; + context.FunctionDirectory = ""; + context.FunctionAppDirectory = ""; + + request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/"); + var services = new ServiceCollection() + .AddMvc() + .AddWebApiConventions() + .Services + .BuildServiceProvider(); + + request.Properties.Add(nameof(HttpContext), new DefaultHttpContext + { + RequestServices = services + }); + } + + [Fact] + public async void HandleTestEvent_ReturnAggregatorInformation_Succeeds() + { + request.Content = new StringContent(ExampleEvents.TestEventAsString, Encoding.UTF8, "application/json"); + + var handler = new AzureFunctionHandler(logger, context); + var response = await handler.RunAsync(request, CancellationToken.None); + + Assert.True(response.IsSuccessStatusCode); + Assert.True(response.Headers.TryGetValues("X-Aggregator-Version", out var versions)); + Assert.Single(versions); + + Assert.True(response.Headers.TryGetValues("X-Aggregator-Rule", out var rules)); + Assert.Equal("TestRule", rules.Single()); + + var content = await response.Content.ReadAsStringAsync(); + Assert.StartsWith("{\"message\":\"Hello from Aggregator v", content); + Assert.EndsWith("executing rule 'TestRule'\"}", content); + } + } +} diff --git a/src/unittests-function/unittests-function.csproj b/src/unittests-function/unittests-function.csproj new file mode 100644 index 00000000..904ac5ef --- /dev/null +++ b/src/unittests-function/unittests-function.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp2.1 + unittests_function + + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/unittests-ruleng/RuleEngineTests.cs b/src/unittests-ruleng/RuleEngineTests.cs deleted file mode 100644 index 876e9fac..00000000 --- a/src/unittests-ruleng/RuleEngineTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using aggregator; -using aggregator.Engine; -using NSubstitute; -using Xunit; - -namespace unittests_ruleng -{ - public class RuleEngineTests - { - [Fact] - public void GivenAnValidRule_WhenTheRuleIsVerified_ThenTheResult_ShouldBeSuccessfull() - { - //Given - var engine = new RuleEngine(Substitute.For(), new [] { "" }, SaveMode.Batch, true); - - //When - var result = engine.VerifyRule(); - - //Then - Assert.True(result.success); - Assert.Empty(result.diagnostics); - } - - [Fact] - public void GivenAnInvalidRule_WhenTheRuleIsVerified_ThenTheResult_ShouldNotBeSuccessfull() - { - //Given - var engine = new RuleEngine(Substitute.For(), new [] { "(" }, SaveMode.Batch, true); - - //When - var result = engine.VerifyRule(); - - //Then - Assert.False(result.success); - Assert.NotEmpty(result.diagnostics); - } - } -} \ No newline at end of file diff --git a/src/unittests-ruleng/RuleFileParserTests.cs b/src/unittests-ruleng/RuleFileParserTests.cs new file mode 100644 index 00000000..fab13e27 --- /dev/null +++ b/src/unittests-ruleng/RuleFileParserTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using aggregator.Engine.Language; + +using Xunit; + + +namespace unittests_ruleng +{ + public class RuleFileParserTests + { + [Fact] + public void RuleLanguageDefaultsCSharp_Succeeds() + { + string ruleCode = @" +return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; +"; + + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.Empty(directives.References); + Assert.Empty(directives.Imports); + Assert.False(directives.Impersonate); + Assert.NotEmpty(directives.RuleCode); + Assert.Equal(RuleLanguage.Csharp, directives.Language); + Assert.True(parsingSuccess); + } + + [Theory] + [InlineData(".language=CSharp")] + [InlineData(".language=C#")] + [InlineData(".language=CS")] + public void RuleLanguageDirectiveParse_Succeeds(string ruleCode, RuleLanguage expectedLanguage = RuleLanguage.Csharp) + { + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.True(parsingSuccess); + Assert.Equal(expectedLanguage, directives.Language); + } + + [Theory] + [InlineData(".language=")] + [InlineData(".lang=WHAT")] + [InlineData(".lang=C#\r\n.unrecognized=directive\r\nreturn string.Empty;\r\n", RuleLanguage.Csharp)] + public void RuleLanguageDirectiveParse_Fails(string ruleCode, RuleLanguage expectedLanguage = RuleLanguage.Unknown) + { + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.False(parsingSuccess); + Assert.Equal(expectedLanguage, directives.Language); + } + + + [Theory] + [InlineData(".r=System.Xml.XDocument", 1)] + [InlineData(".ref=System.Xml.XDocument", 1)] + [InlineData(".reference=System.Xml.XDocument", 1)] + public void RuleReferenceDirectiveParse_Succeeds(string ruleCode, int expectedReferenceCount) + { + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.True(parsingSuccess); + Assert.Equal(expectedReferenceCount, directives.References.Count); + } + + [Theory] + [InlineData(".import=System.Diagnostics", 1)] + [InlineData(".imports=System.Diagnostics", 1)] + [InlineData(".namespace=System.Diagnostics", 1)] + public void RuleImportDirectiveParse_Succeeds(string ruleCode, int expectedImportCount) + { + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.True(parsingSuccess); + Assert.Equal(expectedImportCount, directives.Imports.Count); + } + + [Fact] + public void RuleImpersonateDirectiveParse_Succeeds() + { + string ruleCode = @".impersonate=onBehalfOfInitiator +"; + + (IRuleDirectives directives, bool parsingSuccess) = RuleFileParser.Read(ruleCode.Mince()); + + Assert.True(parsingSuccess); + Assert.True(directives.Impersonate); + } + + [Fact] + public void RuleLanguageReadWrite_Succeeds() + { + string ruleCode = @".language=C# +.reference=System.Xml.XDocument +.import=System.Diagnostics + +return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; +"; + + (IRuleDirectives directives, _) = RuleFileParser.Read(ruleCode.Mince()); + + var ruleCode2 = RuleFileParser.Write(directives); + + Assert.Equal(ruleCode, string.Join(Environment.NewLine, ruleCode2), StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index 44a834d3..630e53a6 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -29,6 +29,7 @@ public class RuleTests private readonly IAggregatorLogger logger; private readonly WorkHttpClient workClient; private readonly WorkItemTrackingHttpClient witClient; + private readonly RuleEngine engine; private readonly TestClientsContext clientsContext; public RuleTests() @@ -40,6 +41,8 @@ public RuleTests() workClient = clientsContext.WorkClient; witClient = clientsContext.WitClient; witClient.ExecuteBatchRequest(default).ReturnsForAnyArgs(info => new List()); + + engine = new RuleEngine(logger, SaveMode.Default, dryRun: true); } [Fact] @@ -61,65 +64,13 @@ public async Task HelloWorldRule_Succeeds() return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("Hello Bug #42 - Hello!", result); await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); } - [Fact] - public async Task LanguageDirective_Succeeds() - { - 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 = @".lang=CS -return string.Empty; -"; - - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); - - Assert.Equal(EngineState.Success, engine.State); - Assert.Equal(string.Empty, result); - await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); - } - - [Fact] - public async Task LanguageDirective_Fails() - { - 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 = @".lang=WHAT -return string.Empty; -"; - - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); - - Assert.Equal(EngineState.Error, engine.State); - } - [Fact] public async Task Parent_Succeeds() { @@ -171,8 +122,8 @@ public async Task Parent_Succeeds() return message; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("Parent is 1", result); await witClient.Received(1).GetWorkItemAsync(Arg.Is(workItemId1), expand: Arg.Is(WorkItemExpand.All)); @@ -198,8 +149,8 @@ public async Task New_Succeeds() wi.Title = ""Brand new""; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Null(result); logger.Received().WriteInfo($"Found a request for a new Task workitem in {clientsContext.ProjectName}"); @@ -229,8 +180,8 @@ public async Task AddChild_Succeeds() parent.Relations.AddChild(newChild); "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Null(result); logger.Received().WriteInfo($"Found a request for a new Task workitem in {clientsContext.ProjectName}"); @@ -258,8 +209,8 @@ public async Task TouchDescription_Succeeds() return self.Description; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("Hello.", result); logger.Received().WriteInfo($"Found a request to update workitem {workItemId} in {clientsContext.ProjectName}"); @@ -287,10 +238,9 @@ public async Task ReferenceDirective_Succeeds() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); - Assert.Equal(EngineState.Success, engine.State); Assert.Equal(string.Empty, result); } @@ -314,10 +264,9 @@ public async Task ImportDirective_Succeeds() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); - Assert.Equal(EngineState.Success, engine.State); Assert.Equal(string.Empty, result); } @@ -341,9 +290,9 @@ public async Task ImportDirective_Fail() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); await Assert.ThrowsAsync( - () => engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None) + () => engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None) ); } @@ -355,8 +304,8 @@ public void Diagnostic_Location_Returned_Correctly() return string.Empty "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - var (success, diagnostics) = engine.VerifyRule(); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + var (success, diagnostics) = rule.Verify(); Assert.False(success); Assert.Single(diagnostics); Assert.Equal(2, diagnostics[0].Location.GetLineSpan().StartLinePosition.Line); @@ -382,9 +331,9 @@ public async Task DeleteWorkItem() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); await Assert.ThrowsAsync( - () => engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None) + () => engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None) ); } @@ -398,8 +347,8 @@ public async Task HelloWorldRuleOnUpdate_Succeeds() return $""Hello #{ selfChanges.WorkItemId } - Update { selfChanges.Id } changed Title from { selfChanges.Fields[""System.Title""].OldValue } to { selfChanges.Fields[""System.Title""].NewValue }!""; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, new WorkItemData(workItem, workItemUpdate), clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, new WorkItemData(workItem, workItemUpdate), clientsContext, CancellationToken.None); Assert.Equal("Hello #22 - Update 3 changed Title from Initial Title to Hello!", result); await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); @@ -424,8 +373,8 @@ public async Task DocumentationRule_OnUpdateExample_Succeeds() } "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, new WorkItemData(workItem, workItemUpdate), clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, new WorkItemData(workItem, workItemUpdate), clientsContext, CancellationToken.None); Assert.Equal("Title was changed from 'Initial Title' to 'Hello'", result); await witClient.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); @@ -452,8 +401,8 @@ public async Task CustomStringField_HasValue_Succeeds() return customField; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("some value", result); } @@ -477,8 +426,8 @@ public async Task CustomStringField_NoValue_ReturnsDefault() return customField; "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("MyDefault", result); } @@ -503,8 +452,8 @@ public async Task CustomNumericField_HasValue_Succeeds() return customField.ToString(""N"", System.Globalization.CultureInfo.InvariantCulture); "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("42.00", result); } @@ -528,8 +477,8 @@ public async Task CustomNumericField_NoValue_ReturnsDefault() return customField.ToString(""N"", System.Globalization.CultureInfo.InvariantCulture); "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, workItem, clientsContext, CancellationToken.None); Assert.Equal("3.00", result); } @@ -571,8 +520,8 @@ public async Task SuccesorLink_Test() } "; - var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, predecessor, clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ruleCode.Mince()); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, predecessor, clientsContext, CancellationToken.None); Assert.Equal("Successor", result); } @@ -588,8 +537,8 @@ public async Task DocumentationRule_BacklogWorkItemsActivateParent_Succeeds() workClient.GetProcessConfigurationAsync(clientsContext.ProjectName).Returns(ExampleTestData.ProcessConfigDefaultAgile); witClient.GetWorkItemAsync(workItemFeature.Id.Value, expand: WorkItemExpand.All).Returns(workItemFeature); - var engine = new RuleEngine(logger, ExampleRuleCode.ActivateParent, SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, new WorkItemData(workItemUS, workItemUpdate), clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ExampleRuleCode.ActivateParent); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, new WorkItemData(workItemUS, workItemUpdate), clientsContext, CancellationToken.None); Assert.Equal("updated Parent Feature #1 to State='Active'", result); } @@ -606,8 +555,8 @@ public async Task DocumentationRule_BacklogWorkItemsResolveParent_Succeeds() workClient.GetProcessConfigurationAsync(clientsContext.ProjectName).Returns(ExampleTestData.ProcessConfigDefaultAgile); witClient.GetWorkItemAsync(workItemFeature.Id.Value, expand: WorkItemExpand.All).Returns(workItemFeature); - var engine = new RuleEngine(logger, ExampleRuleCode.ResolveParent, SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, new WorkItemData(workItemUS, workItemUpdate), clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ExampleRuleCode.ResolveParent); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, new WorkItemData(workItemUS, workItemUpdate), clientsContext, CancellationToken.None); Assert.Equal("updated Parent #1 to State='Resolved'", result); } @@ -617,7 +566,7 @@ public async Task DocumentationRule_BacklogWorkItemsResolveParent_FailsDueToChil { var workItemFeature = ExampleTestData.BacklogFeatureTwoChildren; var workItemUS2 = ExampleTestData.BacklogUserStoryClosed; - var workItemUS3= ExampleTestData.BacklogUserStoryActive; + var workItemUS3 = ExampleTestData.BacklogUserStoryActive; workItemUS3.Id = 3; var workItemUpdate = ExampleTestData.WorkItemUpdateFields; @@ -627,8 +576,8 @@ public async Task DocumentationRule_BacklogWorkItemsResolveParent_FailsDueToChil witClient.GetWorkItemAsync(workItemFeature.Id.Value, expand: WorkItemExpand.All).Returns(workItemFeature); witClient.GetWorkItemsAsync(Arg.Is>(ints => ints.Single() == workItemUS3.Id.Value), expand: WorkItemExpand.All).Returns(new List() { workItemUS3 }); - var engine = new RuleEngine(logger, ExampleRuleCode.ResolveParent, SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(clientsContext.ProjectId, new WorkItemData(workItemUS2, workItemUpdate), clientsContext, CancellationToken.None); + var rule = new ScriptedRuleWrapper("Test", ExampleRuleCode.ResolveParent); + string result = await engine.RunAsync(rule, clientsContext.ProjectId, new WorkItemData(workItemUS2, workItemUpdate), clientsContext, CancellationToken.None); Assert.Equal("Not all child work items or : #2=Closed,#3=Active", result); } diff --git a/src/unittests-ruleng/ScriptedRuleWrapperTests.cs b/src/unittests-ruleng/ScriptedRuleWrapperTests.cs new file mode 100644 index 00000000..5bda1fb3 --- /dev/null +++ b/src/unittests-ruleng/ScriptedRuleWrapperTests.cs @@ -0,0 +1,53 @@ +using aggregator.Engine; +using Xunit; + +namespace unittests_ruleng +{ + public class ScriptedRuleWrapperTests + { + [Fact] + public void GivenAnValidRule_WhenTheRuleIsVerified_ThenTheResult_ShouldBeSuccessfull() + { + //Given + var rule = new ScriptedRuleWrapper("dummy", new[] { "" }); + + + //When + var result = rule.Verify(); + + //Then + Assert.True(result.success); + Assert.Empty(result.diagnostics); + } + + [Fact] + public void GivenAnNotCompilableRule_WhenTheRuleIsVerified_ThenTheResult_ShouldNotBeSuccessfull() + { + //Given + var rule = new ScriptedRuleWrapper("dummy", new[] { "(" }); + + + //When + var result = rule.Verify(); + + //Then + Assert.False(result.success); + Assert.NotEmpty(result.diagnostics); + } + + [Fact] + public void GivenAnNotParsableRule_WhenTheRuleIsVerified_ThenTheResult_ShouldNotBeSuccessfull() + { + //Given + var rule = new ScriptedRuleWrapper("dummy", new[] { ".invalid=directive" }); + + + //When + var result = rule.Verify(); + + //Then + Assert.False(result.success); + Assert.Empty(result.diagnostics); + } + } +} \ No newline at end of file diff --git a/src/unittests-ruleng/TestData/ExampleTestData.cs b/src/unittests-ruleng/TestData/ExampleTestData.cs index dcc3cc33..841ba7ae 100644 --- a/src/unittests-ruleng/TestData/ExampleTestData.cs +++ b/src/unittests-ruleng/TestData/ExampleTestData.cs @@ -4,15 +4,16 @@ using System.Reflection; using Microsoft.TeamFoundation.Work.WebApi.Contracts; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; using Newtonsoft.Json; namespace unittests_ruleng.TestData { - class Helper + internal static class Helper { - private static string GetEmbeddedResourceContent(string resourceName) + internal static string GetEmbeddedResourceContent(string resourceName) { Assembly assembly = Assembly.GetExecutingAssembly(); var fullName = assembly.GetManifestResourceNames() @@ -43,13 +44,13 @@ internal static string[] GetFromResource(string resourceName) } } - static class ExampleRuleCode + internal static class ExampleRuleCode { public static string[] ActivateParent => Helper.GetFromResource("advanced.activate-parent.rulecode"); public static string[] ResolveParent => Helper.GetFromResource("advanced.resolve-parent.rulecode"); } - static class ExampleTestData + internal static class ExampleTestData { public static WorkItem DeltedWorkItem => Helper.GetFromResource("DeletedWorkItem.json"); public static WorkItem WorkItem => Helper.GetFromResource("WorkItem.22.json"); @@ -68,4 +69,11 @@ static class ExampleTestData public static ProcessConfiguration ProcessConfigDefaultScrum => Helper.GetFromResource("WorkClient.ProcessConfiguration.Scrum.json"); public static WorkItemStateColor[] WorkItemStateColorDefault => Helper.GetFromResource("WitClient.WorkItemStateColor.EpicFeatureUserStory.json"); } + + + public static class ExampleEvents + { + public static WebHookEvent TestEvent => Helper.GetFromResource("TestEvent.json"); + public static string TestEventAsString => Helper.GetEmbeddedResourceContent("TestEvent.json"); + } } diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 62d652af..c9b12153 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -90,7 +90,7 @@ public async Task NewWorkItem_Succeeds() var wi = sut.NewWorkItem("Task"); wi.Title = "Brand new"; - var save = await sut.SaveChanges(SaveMode.Default, false, CancellationToken.None); + var save = await sut.SaveChanges(SaveMode.Default, false, false, CancellationToken.None); Assert.NotNull(wi); Assert.True(wi.IsNew); diff --git a/src/unittests-ruleng/unittests-ruleng.csproj b/src/unittests-ruleng/unittests-ruleng.csproj index f9faff2b..4000457b 100644 --- a/src/unittests-ruleng/unittests-ruleng.csproj +++ b/src/unittests-ruleng/unittests-ruleng.csproj @@ -21,6 +21,7 @@ + @@ -40,12 +41,16 @@ - + + + + + @@ -56,10 +61,4 @@ - - - - - -