diff --git a/README.md b/README.md index d691803c..6bce285d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The main scenario for Aggregator (3.x) is supporting Azure DevOps and cloud scen - use of new Azure DevOps REST API - simple deployment via CLI tool -- Rule object model similar to v2 +- Rule object model similar to Aggregator v2 @@ -48,7 +48,7 @@ If you specify the Resource Group, you can have more than one Instance in the Re After creating the Instance, you upload the code of Aggregator **Rules**. A Rule is code that reacts to one or more Azure DevOps event. Each Aggregator Rule becomes an Azure Function in the Aggregator instance i.e. the Azure Function Application. -The Rule language is C# (hopefully more in the future) and uses Aggregator Runtime and [Azure Functions Runtime](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) 2.0 +The Rule language is C# (hopefully more in the future) and uses Aggregator Runtime and [Azure Functions Runtime](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) 3.0 to do its work. When you create an Instance, a Rule or update them, CLI checks GitHub Releases to ensure that Aggregator Runtime is up-to-date or match the specified version. diff --git a/src/aggregator-cli/Extensions.cs b/src/aggregator-cli/Extensions.cs new file mode 100644 index 00000000..6303a4ac --- /dev/null +++ b/src/aggregator-cli/Extensions.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading.Tasks; + +namespace aggregator.cli +{ + internal static class Extensions + { + internal static async Task GetEmbeddedResourceContent(this Assembly assembly, string resourceName) + { + var fullName = assembly.GetManifestResourceNames() + .SingleOrDefault(str => str.EndsWith(resourceName)) + ?? throw new FileNotFoundException($"Embedded Resource '{resourceName}' not found."); + + string content; + using (var stream = assembly.GetManifestResourceStream(fullName)) + { + using (var source = new StreamReader(stream)) + { + content = await source.ReadToEndAsync(); + } + } + + return content; + } + + internal static async Task AddFunctionDefaultFiles(this IDictionary uploadFiles, Stream assemblyStream) + { + var context = new AssemblyLoadContext(null, isCollectible: true); + + using (var memoryStream = new MemoryStream()) + { + assemblyStream.CopyTo(memoryStream); + memoryStream.Position = 0; + var assembly = context.LoadFromStream(memoryStream); + + await AddFunctionDefaultFiles(uploadFiles, assembly); + } + + context.Unload(); + } + + internal static async Task AddFunctionDefaultFiles(this IDictionary uploadFiles, Assembly assembly) + { + { + var content = await assembly.GetEmbeddedResourceContent("function.json"); + uploadFiles.Add("function.json", content); + } + + { + var content = await assembly.GetEmbeddedResourceContent("run.csx"); + uploadFiles.Add("run.csx", content); + } + } + } +} \ No newline at end of file diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 05cff44a..da01b148 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; @@ -297,5 +298,54 @@ internal async Task StreamLogsAsync(InstanceName instance, CancellationTok return true; } + + internal async Task UpdateAsync(InstanceName instance, string requiredVersion, string sourceUrl, CancellationToken cancellationToken) + { + // update runtime package + var package = new FunctionRuntimePackage(_logger); + bool ok = await package.UpdateVersionAsync(requiredVersion, sourceUrl, instance, _azure, cancellationToken); + + { + // Change V2 to V3 FUNCTIONS_EXTENSION_VERSION ~3 + var webFunctionApp = await GetWebApp(instance, cancellationToken); + var currentAzureRuntimeVersion = webFunctionApp.GetAppSettings() + .GetValueOrDefault("FUNCTIONS_EXTENSION_VERSION"); + webFunctionApp.Update() + .WithAppSetting("FUNCTIONS_EXTENSION_VERSION", "~3") + .Apply(); ; + } + + { + var uploadFiles = new Dictionary(); + using (var archive = System.IO.Compression.ZipFile.OpenRead(package.RuntimePackageFile)) + { + var entry = archive.Entries + .Single(e => string.Equals("aggregator-function.dll", e.Name, StringComparison.OrdinalIgnoreCase)); + + using (var assemblyStream = entry.Open()) + { + await uploadFiles.AddFunctionDefaultFiles(assemblyStream); + } + } + //TODO handle FileNotFound Exception when trying to get resource content, and resource not found + + var rules = new AggregatorRules(_azure, _logger); + var allRules = await rules.ListAsync(instance, cancellationToken); + + foreach (var ruleName in allRules.Select(r => r.RuleName)) + { + _logger.WriteInfo($"Updating Rule '{ruleName}'"); + await rules.UploadRuleFilesAsync(instance, ruleName, uploadFiles, cancellationToken); + } + } + + return false; + } + + + private void DomainOnAssemblyLoad(object sender, AssemblyLoadEventArgs args) + { + //throw new NotImplementedException(); + } } } diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 932ea601..f7d748ae 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -28,7 +28,7 @@ internal FunctionRuntimePackage(ILogger logger) .Version; } - private string RuntimePackageFile => "FunctionRuntime.zip"; + public string RuntimePackageFile => "FunctionRuntime.zip"; internal async Task UpdateVersionAsync(string requiredVersion, string sourceUrl, InstanceName instance, IAzure azure, CancellationToken cancellationToken) { @@ -213,6 +213,26 @@ internal async Task GetDeployedRuntimeVersion(InstanceName instance, return uploadedRuntimeVer; } + internal static async Task GetDeployedFunctionEntrypoint(InstanceName instance, IAzure azure, ILogger logger, CancellationToken cancellationToken) + { + logger.WriteVerbose($"Retrieving deployed aggregator-function.dll"); + var kudu = new KuduApi(instance, azure, logger); + using (var client = new HttpClient()) + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/vfs/site/wwwroot/bin/aggregator-function.dll", cancellationToken)) + { + var response = await client.SendAsync(request, cancellationToken); + var stream = await response.Content.ReadAsStreamAsync(); + + if (response.IsSuccessStatusCode) + { + return stream; + } + + logger.WriteError($"Cannot read aggregator-function.dll: {response.ReasonPhrase}"); + return null; + } + } + private async Task GetLocalPackageVersionAsync(string runtimePackageFile) { if (!File.Exists(runtimePackageFile)) diff --git a/src/aggregator-cli/Instances/UpdateInstance.cs b/src/aggregator-cli/Instances/UpdateInstance.cs new file mode 100644 index 00000000..1efa25ee --- /dev/null +++ b/src/aggregator-cli/Instances/UpdateInstance.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using CommandLine; + + +namespace aggregator.cli.Instances +{ + [Verb("update.instance", HelpText = "Updates an existing Aggregator instance in Azure, with latest runtime binaries.")] + class UpdateInstanceCommand : CommandBase + { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] + public string Instance { get; set; } + + [Option("requiredVersion", SetName = "nourl", Required = false, HelpText = "Version of Aggregator Runtime required.")] + public string RequiredVersion { get; set; } + [Option("sourceUrl", SetName = "url", Required = false, HelpText = "URL of Aggregator Runtime.")] + public string SourceUrl { get; set; } + + internal override async Task RunAsync(CancellationToken cancellationToken) + { + var context = await Context + .WithAzureLogon() + .BuildAsync(cancellationToken); + + var instances = new AggregatorInstances(context.Azure, context.Logger); + var instance = new InstanceName(Instance, ResourceGroup); + + bool ok = await instances.UpdateAsync(instance, RequiredVersion, SourceUrl, cancellationToken); + return ok ? 0 : 1; + } + } +} diff --git a/src/aggregator-cli/Instances/instance-template.json b/src/aggregator-cli/Instances/instance-template.json index 444783e3..b7cdf386 100644 --- a/src/aggregator-cli/Instances/instance-template.json +++ b/src/aggregator-cli/Instances/instance-template.json @@ -143,7 +143,7 @@ }, { "name": "FUNCTIONS_EXTENSION_VERSION", - "value": "~2" + "value": "~3" }, { "name": "FUNCTIONS_WORKER_RUNTIME", diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index e0d3f1e2..d17afc8e 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -153,6 +153,8 @@ internal async Task AddAsync(string projectName, string @event, EventFilte { "commentPattern", null }, */ }, + // Resource Version 1.0 currently needed for WorkItems, newer Version send EMPTY Relation Information. + ResourceVersion = "1.0", }; if (!string.IsNullOrWhiteSpace(filters.AreaPath)) { diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 436f9807..dc6c2273 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -3,6 +3,9 @@ using System; using System.Threading; +using aggregator.cli.Instances; + + namespace aggregator.cli { /* @@ -60,8 +63,8 @@ void cancelEventHandler(object sender, ConsoleCancelEventArgs e) { typeof(TestCommand), typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), - typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), - typeof(ConfigureInstanceCommand), typeof(StreamLogsCommand), + typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UpdateInstanceCommand), + typeof(UninstallInstanceCommand), typeof(ConfigureInstanceCommand), typeof(StreamLogsCommand), typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(InvokeRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) @@ -75,6 +78,7 @@ void cancelEventHandler(object sender, ConsoleCancelEventArgs e) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) .WithParsed(cmd => rc = cmd.Run(cancellationToken)) diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index ddf67889..20c327b0 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -14,7 +14,6 @@ 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; @@ -135,37 +134,20 @@ internal static Uri GetInvocationUrl(InstanceName instance, string rule) internal async Task AddAsync(InstanceName instance, string ruleName, string filePath, CancellationToken cancellationToken) { _logger.WriteInfo($"Validate rule file {filePath}"); - - var engineLogger = new EngineWrapperLogger(_logger); - var (preprocessedRule, _) = await RuleFileParser.ReadFile(filePath, engineLogger, cancellationToken); - try + var preprocessedRule = await LoadAndValidateRule(ruleName, filePath, cancellationToken); + if (preprocessedRule == null) { - var rule = new Engine.ScriptedRuleWrapper(ruleName, preprocessedRule, engineLogger); - var (success, diagnostics) = rule.Verify(); - if (success) - { - _logger.WriteInfo($"Rule file is valid"); - } - else - { - _logger.WriteInfo($"Rule file is invalid"); - var messages = string.Join('\n', diagnostics.Select(d => d.ToString())); - if (!string.IsNullOrEmpty(messages)) - { - _logger.WriteError($"Errors in the rule file {filePath}:\n{messages}"); - } - - return false; - } - } - catch - { - _logger.WriteInfo($"Rule file is invalid"); + _logger.WriteError("Rule file is invalid"); return false; } + _logger.WriteInfo("Rule file is valid"); _logger.WriteVerbose($"Layout rule files"); var inMemoryFiles = await PackagingFilesAsync(ruleName, preprocessedRule); + using (var assemblyStream = await FunctionRuntimePackage.GetDeployedFunctionEntrypoint(instance, _azure, _logger, cancellationToken)) + { + await inMemoryFiles.AddFunctionDefaultFiles(assemblyStream); + } _logger.WriteInfo($"Packaging rule {ruleName} complete."); _logger.WriteVerbose($"Uploading rule files to {instance.PlainName}"); @@ -188,38 +170,48 @@ internal async Task AddAsync(InstanceName instance, string ruleName, strin return ok; } - private static async Task> PackagingFilesAsync(string name, IPreprocessedRule preprocessedRule) + private async Task LoadAndValidateRule(string ruleName, string filePath, CancellationToken cancellationToken) { - var inMemoryFiles = new Dictionary - { - { $"{name}.rule", string.Join(Environment.NewLine, RuleFileParser.Write(preprocessedRule)) } - }; - - var assembly = Assembly.GetExecutingAssembly(); - - // TODO we can deserialize a KuduFunctionConfig instead of using a fixed file... - using (var stream = assembly.GetManifestResourceStream("aggregator.cli.Rules.function.json")) + var engineLogger = new EngineWrapperLogger(_logger); + var (preprocessedRule, _) = await RuleFileParser.ReadFile(filePath, engineLogger, cancellationToken); + try { - using (var reader = new StreamReader(stream)) + var rule = new Engine.ScriptedRuleWrapper(ruleName, preprocessedRule, engineLogger); + var (success, diagnostics) = rule.Verify(); + if (!success) { - var content = await reader.ReadToEndAsync(); - inMemoryFiles.Add("function.json", content); + var messages = string.Join('\n', diagnostics.Select(d => d.ToString())); + if (!string.IsNullOrEmpty(messages)) + { + _logger.WriteError($"Errors in the rule file {filePath}:\n{messages}"); + } + + return null; } } - - using (var stream = assembly.GetManifestResourceStream("aggregator.cli.Rules.run.csx")) + catch { - using (var reader = new StreamReader(stream)) - { - var content = await reader.ReadToEndAsync(); - inMemoryFiles.Add("run.csx", content); - } + return null; } + // Rule file is valid + return preprocessedRule; + } + + private static async Task> PackagingFilesAsync(string ruleName, IPreprocessedRule preprocessedRule) + { + var inMemoryFiles = new Dictionary + { + { $"{ruleName}.rule", string.Join(Environment.NewLine, RuleFileParser.Write(preprocessedRule)) } + }; + + //var assembly = Assembly.GetExecutingAssembly(); + //await inMemoryFiles.AddFunctionDefaultFiles(assembly); + return inMemoryFiles; } - private async Task UploadRuleFilesAsync(InstanceName instance, string name, IDictionary inMemoryFiles, CancellationToken cancellationToken) + internal async Task UploadRuleFilesAsync(InstanceName instance, string ruleName, IDictionary uploadFiles, CancellationToken cancellationToken) { /* PUT /api/vfs/{path} @@ -231,7 +223,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 = GetKudu(instance); - var relativeUrl = $"api/vfs/site/wwwroot/{name}/"; + var relativeUrl = $"api/vfs/site/wwwroot/{ruleName}/"; using (var client = new HttpClient()) { @@ -240,7 +232,7 @@ Puts a file at path. // check if function already exists using (var request = await kudu.GetRequestAsync(HttpMethod.Head, relativeUrl, cancellationToken)) { - _logger.WriteVerbose($"Checking if function {name} already exists in {instance.PlainName}..."); + _logger.WriteVerbose($"Checking if function {ruleName} already exists in {instance.PlainName}..."); using (var response = await client.SendAsync(request)) { exists = response.IsSuccessStatusCode; @@ -249,7 +241,7 @@ Puts a file at path. if (!exists) { - _logger.WriteVerbose($"Creating function {name} in {instance.PlainName}..."); + _logger.WriteVerbose($"Creating function {ruleName} in {instance.PlainName}..."); using (var request = await kudu.GetRequestAsync(HttpMethod.Put, relativeUrl, cancellationToken)) { using (var response = await client.SendAsync(request, cancellationToken)) @@ -263,10 +255,10 @@ Puts a file at path. } } - _logger.WriteInfo($"Function {name} created."); + _logger.WriteInfo($"Function {ruleName} created."); } - foreach (var (fileName, fileContent) in inMemoryFiles) + foreach (var (fileName, fileContent) in uploadFiles) { _logger.WriteVerbose($"Uploading {fileName} to {instance.PlainName}..."); var fileUrl = $"{relativeUrl}{fileName}"; @@ -336,19 +328,31 @@ internal async Task ConfigureAsync(InstanceName instance, string name, boo } - internal async Task UpdateAsync(InstanceName instance, string name, string filePath, string requiredVersion, string sourceUrl, CancellationToken cancellationToken) + internal async Task UpdateAsync(InstanceName instance, string ruleName, string filePath, string requiredVersion, string sourceUrl, CancellationToken cancellationToken) { // check runtime package var package = new FunctionRuntimePackage(_logger); bool ok = await package.UpdateVersionAsync(requiredVersion, sourceUrl, instance, _azure, cancellationToken); if (ok) { - ok = await AddAsync(instance, name, filePath, cancellationToken); + ok = await AddAsync(instance, ruleName, filePath, cancellationToken); } return ok; } + internal async Task UpdateAsync(InstanceName instance, string ruleName, string filePath, CancellationToken cancellationToken) + { + bool ok = await AddAsync(instance, ruleName, filePath, cancellationToken); + // AddAsync + // - read and parse file + // - compile and validate content + // - packaging + // - upload + // - Configure App for impersonate if needed + return ok; + } + internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode, bool impersonateExecution, CancellationToken cancellationToken) { if (!File.Exists(ruleFilePath)) diff --git a/src/aggregator-cli/Rules/RuleOutputData.cs b/src/aggregator-cli/Rules/RuleOutputData.cs index 334a64c6..67b3a1f3 100644 --- a/src/aggregator-cli/Rules/RuleOutputData.cs +++ b/src/aggregator-cli/Rules/RuleOutputData.cs @@ -7,24 +7,24 @@ namespace aggregator.cli { internal class RuleOutputData : ILogDataObject { - private readonly string instanceName; - private readonly string ruleName; - private readonly string ruleLanguage; - private readonly bool isDisabled; - private readonly bool isImpersonated; + public string InstanceName { get; } + public string RuleName { get; } + public string RuleLanguage { get; } + public bool IsDisabled { get; } + public bool IsImpersonated { get; } internal RuleOutputData(InstanceName instance, IRuleConfiguration ruleConfiguration, string ruleLanguage) { - this.instanceName = instance.PlainName; - this.ruleName = ruleConfiguration.RuleName; - this.isDisabled = ruleConfiguration.IsDisabled; - this.isImpersonated = ruleConfiguration.Impersonate; - this.ruleLanguage = ruleLanguage; + this.InstanceName = instance.PlainName; + this.RuleName = ruleConfiguration.RuleName; + this.IsDisabled = ruleConfiguration.IsDisabled; + this.IsImpersonated = ruleConfiguration.Impersonate; + this.RuleLanguage = ruleLanguage; } public string AsHumanReadable() { - return $"Rule {instanceName}/{ruleName} {(isImpersonated ? "*execute impersonated*" : string.Empty)} {(isDisabled ? "(disabled)" : string.Empty)}"; + return $"Rule {InstanceName}/{RuleName} {(IsImpersonated ? "*execute impersonated*" : string.Empty)} {(IsDisabled ? "(disabled)" : string.Empty)}"; } } } diff --git a/src/aggregator-cli/Rules/UpdateRuleCommand.cs b/src/aggregator-cli/Rules/UpdateRuleCommand.cs index 55e4ed7f..81e23c53 100644 --- a/src/aggregator-cli/Rules/UpdateRuleCommand.cs +++ b/src/aggregator-cli/Rules/UpdateRuleCommand.cs @@ -40,3 +40,4 @@ internal override async Task RunAsync(CancellationToken cancellationToken) } } } + diff --git a/src/aggregator-cli/Rules/run.csx b/src/aggregator-cli/Rules/run.csx deleted file mode 100644 index afda0641..00000000 --- a/src/aggregator-cli/Rules/run.csx +++ /dev/null @@ -1,13 +0,0 @@ -#r "../bin/aggregator-function.dll" -#r "../bin/aggregator-shared.dll" - -using System.Threading; - -using aggregator; - -public static async Task Run(HttpRequestMessage req, ILogger logger, Microsoft.Azure.WebJobs.ExecutionContext context, CancellationToken cancellationToken) -{ - var handler = new AzureFunctionHandler(logger, context); - var result = await handler.RunAsync(req, cancellationToken); - return result; -} diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index c1590495..9bbab9b3 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -20,15 +20,8 @@ aggregator-cli.ruleset - - - - - - - diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index 45804495..cf1b2b3c 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -1,16 +1,20 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using System; +using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using aggregator.Engine; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; 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; using Newtonsoft.Json.Linq; using ExecutionContext = Microsoft.Azure.WebJobs.ExecutionContext; @@ -22,49 +26,51 @@ namespace aggregator public class AzureFunctionHandler { private readonly ILogger _log; - private readonly ExecutionContext _context; + private readonly ExecutionContext _executionContext; + - public AzureFunctionHandler(ILogger logger, ExecutionContext context) + public AzureFunctionHandler(ILogger logger, ExecutionContext executionContext, HttpContext httpContext) { _log = logger; - _context = context; + _executionContext = executionContext; + HttpContext = httpContext; } - public async Task RunAsync(HttpRequestMessage req, CancellationToken cancellationToken) + public async Task RunAsync(/*HttpRequest request, */WebHookEvent eventData, CancellationToken cancellationToken) { - _log.LogDebug($"Context: {_context.InvocationId} {_context.FunctionName} {_context.FunctionDirectory} {_context.FunctionAppDirectory}"); + _log.LogDebug($"Context: {_executionContext.InvocationId} {_executionContext.FunctionName} {_executionContext.FunctionDirectory} {_executionContext.FunctionAppDirectory}"); - var ruleName = _context.FunctionName; + var ruleName = _executionContext.FunctionName; var aggregatorVersion = GetCustomAttribute()?.InformationalVersion; _log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{ruleName}'"); cancellationToken.ThrowIfCancellationRequested(); - // Get request body - var eventData = await GetWebHookEvent(req); if (eventData == null) { - return req.CreateErrorResponse(HttpStatusCode.BadRequest, "Request body is empty"); + return BadRequest("Request body is empty"); } // sanity check if (!DevOpsEvents.IsValidEvent(eventData.EventType) || eventData.PublisherId != DevOpsEvents.PublisherId) { - return req.CreateErrorResponse(HttpStatusCode.BadRequest, "Not a good Azure DevOps post..."); + _log.LogDebug("return BadRequest"); + return BadRequest(new {Error = "Not a good Azure DevOps post..."}); } var eventContext = CreateContextFromEvent(eventData); if (eventContext.IsTestEvent()) { - return req.CreateTestEventResponse(aggregatorVersion, ruleName); + Response.AddCustomHeaders(aggregatorVersion, ruleName); + return RespondToTestEventMessage(aggregatorVersion, ruleName); } var configContext = GetConfigurationContext(); var configuration = AggregatorConfiguration.ReadConfiguration(configContext) - .UpdateFromUrl(ruleName, req.RequestUri); + .UpdateFromUrl(ruleName, GetRequestUri()); var logger = new ForwarderLogger(_log); - var ruleProvider = new AzureFunctionRuleProvider(logger, _context.FunctionDirectory); + var ruleProvider = new AzureFunctionRuleProvider(logger, _executionContext.FunctionDirectory); var ruleExecutor = new RuleExecutor(logger, configuration); using (_log.BeginScope($"WorkItem #{eventContext.WorkItemPayload.WorkItem.Id}")) { @@ -75,48 +81,47 @@ public async Task RunAsync(HttpRequestMessage req, Cancella if (string.IsNullOrEmpty(execResult)) { - return req.CreateResponse(HttpStatusCode.OK); + return Ok(); } else { _log.LogInformation($"Returning '{execResult}' from '{rule.Name}'"); - return req.CreateResponse(HttpStatusCode.OK, execResult); + return Ok(execResult); } } catch (Exception ex) { _log.LogWarning($"Rule '{ruleName}' failed: {ex.Message}"); - return req.CreateErrorResponse(HttpStatusCode.NotImplemented, ex); + return BadRequest(ex.Message); } } } - private IConfigurationRoot GetConfigurationContext() + private IConfiguration GetConfigurationContext() { var config = new ConfigurationBuilder() - .SetBasePath(_context.FunctionAppDirectory) + .SetBasePath(_executionContext.FunctionAppDirectory) .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables() .Build(); return config; } - - private async Task GetWebHookEvent(HttpRequestMessage req) + private Uri GetRequestUri() { - var jsonContent = await req.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(jsonContent)) - { - _log.LogWarning($"Failed parsing request body: empty"); - return null; - } + //during testing this can be null + var requestUrl = Request?.GetDisplayUrl() ?? "https://google.com/"; + return new Uri(requestUrl); + } - var data = JsonConvert.DeserializeObject(jsonContent); - return data; + + private IActionResult RespondToTestEventMessage(string aggregatorVersion, string ruleName) + { + return Ok(new { message = $"Hello from Aggregator v{aggregatorVersion} executing rule '{ruleName}'" }); } - private static WorkItemEventContext CreateContextFromEvent(WebHookEvent eventData) + protected WorkItemEventContext CreateContextFromEvent(WebHookEvent eventData) { var collectionUrl = eventData.ResourceContainers.GetValueOrDefault("collection")?.BaseUrl ?? "https://example.com"; var teamProjectId = eventData.ResourceContainers.GetValueOrDefault("project")?.Id ?? Guid.Empty; @@ -125,38 +130,179 @@ private static WorkItemEventContext CreateContextFromEvent(WebHookEvent eventDat if (ServiceHooksEventTypeConstants.WorkItemUpdated == eventData.EventType) { var workItem = resourceObject.GetValue("revision").ToObject(); + MigrateIdentityInformation(eventData.ResourceVersion, workItem); var workItemUpdate = resourceObject.ToObject(); return new WorkItemEventContext(teamProjectId, new Uri(collectionUrl), workItem, workItemUpdate); } else { var workItem = resourceObject.ToObject(); + MigrateIdentityInformation(eventData.ResourceVersion, workItem); return new WorkItemEventContext(teamProjectId, new Uri(collectionUrl), workItem); } } + /// + /// in Event Resource Version == 1.0 the Identity Information is provided in a single string "DisplayName ", in newer Revisions + /// the Identity Information as on Object of type IdentityRef + /// As we rely on IdentityRef we could switch the WebHook to use ResourceVersion > 1.0 but unfortunately there is a bug + /// as these Resources do not send the relation information in the event (although resource details is set to all). + /// Option 1: Use Resource Version > 1.0 and load work item later to get relation information + /// Option 2: Use Resource Version == 1.0 and convert string to IdentityRef + /// + /// Use Option 2, as less server round trips, write warning in case of too new Resource Version, Open ticket at Microsoft and see if they accept it as Bug. + /// + /// + /// + protected void MigrateIdentityInformation(string resourceVersion, WorkItem workItem) + { + const char UNIQUE_NAME_START_CHAR = '<'; + const char UNIQUE_NAME_END_CHAR = '>'; + + if (!resourceVersion.StartsWith("1.0")) + { + _log.LogWarning($"Mapping is using Resource Version {resourceVersion}, which can lead to some issues with e.g. not available relation information on trigger work item."); + return; + } + + IdentityRef ConvertOrDefault(string input) + { + var uniqueNameStartIndex = input.LastIndexOf(UNIQUE_NAME_START_CHAR); + var uniqueNameEndIndex = input.LastIndexOf(UNIQUE_NAME_END_CHAR); + + if (uniqueNameStartIndex < 0 || uniqueNameEndIndex != input.Length -1) + { + return null; + } + + var uniqueNameLength = uniqueNameEndIndex - uniqueNameStartIndex + 1; + + return new IdentityRef() + { + DisplayName = input.Substring(0, uniqueNameStartIndex).Trim(), + UniqueName = new string(input.Skip(uniqueNameStartIndex + 1).Take(uniqueNameLength).ToArray()) + }; + } + + // assumtion to get all string Identity Fields, normally the naming convention is: These fields ends with TO or BY (e.g. AssignedTO, CreatedBY) + var identityFieldReferenceNameEndings = new[] + { + "By", "To" + }; + + foreach (var identityField in workItem.Fields.Where(field => identityFieldReferenceNameEndings.Any(name => field.Key.EndsWith(name))).ToList()) + { + if (identityField.Value is string identityString) + { + workItem.Fields[identityField.Key] = ConvertOrDefault(identityString) ?? identityField.Value; + } + } + } + private static T GetCustomAttribute() where T : Attribute { return System.Reflection.Assembly - .GetExecutingAssembly() - .GetCustomAttributes(typeof(T), false) - .FirstOrDefault() as T; + .GetExecutingAssembly() + .GetCustomAttributes(typeof(T), false) + .FirstOrDefault() as T; } + + + #region Microsoft.AspNetCore.Mvc.ControllerBase + + /// + /// Gets the for the executing action. + /// + public HttpContext HttpContext { get; } + + /// + /// Gets the for the executing action. + /// + public HttpRequest Request => HttpContext?.Request; + + /// + /// Gets the for the executing action. + /// + public HttpResponse Response => HttpContext?.Response; + + /// + /// Creates an that produces a response. + /// + /// The created for the response. + [NonAction] + public virtual BadRequestResult BadRequest() + => new BadRequestResult(); + + /// + /// Creates an that produces a response. + /// + /// An error object to be returned to the client. + /// The created for the response. + [NonAction] + public virtual BadRequestObjectResult BadRequest(object error) + => new BadRequestObjectResult(error); + + + /// + /// Creates a object that produces an empty response. + /// + /// The created for the response. + [NonAction] + public virtual OkResult Ok() + => new OkResult(); + + /// + /// Creates an object that produces an response. + /// + /// The content value to format in the entity body. + /// The created for the response. + [NonAction] + public virtual OkObjectResult Ok(object value) + => new OkObjectResult(value); + + /// + /// Creates an that produces a response. + /// + /// The created for the response. + [NonAction] + public virtual NotFoundResult NotFound() + => new NotFoundResult(); + + /// + /// Creates an that produces a response. + /// + /// The created for the response. + [NonAction] + public virtual NotFoundObjectResult NotFound(object value) + => new NotFoundObjectResult(value); + + #endregion } - internal static class HttpResponseMessageExtensions + public static class HttpExtensions { - public static HttpResponseMessage CreateTestEventResponse(this HttpRequestMessage req, string aggregatorVersion, string ruleName) + public static HttpResponse AddCustomHeaders(this HttpResponse response, string aggregatorVersion, string ruleName) + { + response?.Headers.Add("X-Aggregator-Version", aggregatorVersion); + response?.Headers.Add("X-Aggregator-Rule", ruleName); + + return response; + } + + public static async Task GetWebHookEvent(this HttpRequest request, ILogger logger) { - 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; + using var bodyStreamReader = new StreamReader(request.Body); + var jsonContent = await bodyStreamReader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(jsonContent)) + { + logger.LogWarning($"Failed parsing request body: empty"); + return null; + } + + var data = JsonConvert.DeserializeObject(jsonContent); + return data; } } } diff --git a/src/aggregator-function/AzureFunctionHandlerExtension.cs b/src/aggregator-function/AzureFunctionHandlerExtension.cs index 81e7d27f..db50f1eb 100644 --- a/src/aggregator-function/AzureFunctionHandlerExtension.cs +++ b/src/aggregator-function/AzureFunctionHandlerExtension.cs @@ -2,6 +2,10 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + + namespace aggregator { public static class AzureFunctionHandlerExtension @@ -12,9 +16,11 @@ public static class AzureFunctionHandlerExtension /// /// /// - public static async Task Run(this AzureFunctionHandler @this, HttpRequestMessage req) + public static async Task Run(this AzureFunctionHandler @this, HttpRequestMessage request) { - return await @this.RunAsync(req, CancellationToken.None); + //var eventData = req + //return await @this.RunAsync(req, CancellationToken.None); + return new HttpResponseMessage(); } } } diff --git a/src/aggregator-cli/Rules/function.json b/src/aggregator-function/FunctionTemplate/function.json similarity index 100% rename from src/aggregator-cli/Rules/function.json rename to src/aggregator-function/FunctionTemplate/function.json diff --git a/src/aggregator-function/FunctionTemplate/run.csx b/src/aggregator-function/FunctionTemplate/run.csx new file mode 100644 index 00000000..a8481ec9 --- /dev/null +++ b/src/aggregator-function/FunctionTemplate/run.csx @@ -0,0 +1,17 @@ +#r "../bin/aggregator-function.dll" +#r "../bin/aggregator-shared.dll" +#r "../bin/Microsoft.VisualStudio.Services.ServiceHooks.WebApi.dll" + +using System.Threading; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using ExecutionContext = Microsoft.Azure.WebJobs.ExecutionContext; +using aggregator; + +public static async Task Run(HttpRequest req, ILogger logger, ExecutionContext executionContext, CancellationToken cancellationToken) +{ + var handler = new AzureFunctionHandler(logger, executionContext, req.HttpContext); + var eventData = await req.GetWebHookEvent(logger); + var result = await handler.RunAsync(eventData, cancellationToken); + return result; +} diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index 49ad1ce9..fa5505fa 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -12,6 +12,14 @@ aggregator-function.ruleset + + + + + + + + diff --git a/src/aggregator-ruleng/AssemblyInfo.cs b/src/aggregator-ruleng/AssemblyInfo.cs index 6a7dbf3c..f1a692df 100644 --- a/src/aggregator-ruleng/AssemblyInfo.cs +++ b/src/aggregator-ruleng/AssemblyInfo.cs @@ -15,4 +15,5 @@ [assembly: AssemblyTitle("Aggregator Rule Engine")] [assembly: AssemblyVersion("0.9.0.0")] -[assembly:InternalsVisibleTo("unittests-ruleng")] \ No newline at end of file +[assembly:InternalsVisibleTo("unittests-ruleng")] +[assembly: InternalsVisibleTo("unittests-function")] \ No newline at end of file diff --git a/src/aggregator-ruleng/WorkItemRelationWrapper.cs b/src/aggregator-ruleng/WorkItemRelationWrapper.cs index ed606d61..0ca07f7d 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapper.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapper.cs @@ -15,8 +15,9 @@ private WorkItemRelationWrapper(string relationUrl) if (!string.IsNullOrWhiteSpace(relationUrl)) { var relationUri = new Uri(relationUrl); - var id = int.Parse(relationUri.Segments.Last()); - LinkedId = new PermanentWorkItemId(id); + var idName = relationUri.Segments.Last(); + var id = int.TryParse(idName, out var i) ? i : (int?)null; + LinkedId = id == null ? null : new PermanentWorkItemId(id.Value); } } diff --git a/src/unittests-function/AzureFunctionHandlerTests.cs b/src/unittests-function/AzureFunctionHandlerTests.cs index 0e2c575f..a7162a9d 100644 --- a/src/unittests-function/AzureFunctionHandlerTests.cs +++ b/src/unittests-function/AzureFunctionHandlerTests.cs @@ -1,13 +1,21 @@ using System; +using System.Collections.Generic; using System.Linq; -using System.Net; using System.Net.Http; -using System.Text; using System.Threading; using aggregator; +using aggregator.Engine; + using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; +using Microsoft.VisualStudio.Services.WebApi; + +using Newtonsoft.Json; + using NSubstitute; using unittests_function.TestData; using Xunit; @@ -17,12 +25,14 @@ namespace unittests_function { public class AzureFunctionHandlerTests { - private readonly ILogger logger; - private readonly ExecutionContext context; - private readonly HttpRequestMessage request; + private readonly TestAzureFunctionHandler azureFunctionHandler; public AzureFunctionHandlerTests() { + ILogger logger; + ExecutionContext context; + HttpContext httpContext; + logger = Substitute.For(); context = Substitute.For(); context.InvocationId = Guid.Empty; @@ -30,37 +40,86 @@ public AzureFunctionHandlerTests() 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 - }); + httpContext = new DefaultHttpContext() + { + RequestServices = services, + }; + + httpContext.Request.Protocol = "http"; + httpContext.Request.Host = new HostString("localhost"); + httpContext.Request.Method = HttpMethod.Post.ToString(); + + azureFunctionHandler = new TestAzureFunctionHandler(logger, context, httpContext); } [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); + IActionResult actionResult = await azureFunctionHandler.RunAsync(ExampleEvents.TestEvent, CancellationToken.None); - Assert.True(response.IsSuccessStatusCode); - Assert.True(response.Headers.TryGetValues("X-Aggregator-Version", out var versions)); + var objectResult = actionResult as ObjectResult; + Assert.True(IsSuccessStatusCode(objectResult.StatusCode)); + Assert.True(azureFunctionHandler.Response.Headers.TryGetValue("X-Aggregator-Version", out var versions)); Assert.Single(versions); - Assert.True(response.Headers.TryGetValues("X-Aggregator-Rule", out var rules)); + + Assert.True(azureFunctionHandler.Response.Headers.TryGetValue("X-Aggregator-Rule", out var rules)); Assert.Equal("TestRule", rules.Single()); - var content = await response.Content.ReadAsStringAsync(); + var content = JsonConvert.SerializeObject(objectResult.Value); Assert.StartsWith("{\"message\":\"Hello from Aggregator v", content); Assert.EndsWith("executing rule 'TestRule'\"}", content); } + + private static bool IsSuccessStatusCode(int? statusCode) + { + return statusCode.HasValue && + (statusCode >= 200) && (statusCode <= 299); + } + + public static IEnumerable Data => + new List + { + new object[] { ExampleEvents.WorkItemUpdateEventResourceVersion10 }, + new object[] { ExampleEvents.WorkItemUpdateEventResourceVersion31Preview3 }, + new object[] { ExampleEvents.WorkItemUpdateEventResourceVersion51Preview3 }, + }; + + [Theory] + [MemberData(nameof(Data))] + public void HandleEventsWithDifferentResourceVersion_CheckIdentityConversion_Succeeds(WebHookEvent eventData) + { + var eventContext = azureFunctionHandler.InvokeCreateContextFromEvent(eventData); + + var workItem = eventContext.WorkItemPayload.WorkItem; + + Assert.IsType(workItem.Fields[CoreFieldRefNames.AssignedTo]); + Assert.IsType(workItem.Fields[CoreFieldRefNames.ChangedBy]); + Assert.IsType(workItem.Fields[CoreFieldRefNames.CreatedBy]); + } + } + + + internal class TestAzureFunctionHandler : AzureFunctionHandler + { + /// + public TestAzureFunctionHandler(ILogger logger, ExecutionContext executionContext, HttpContext httpContext) : base(logger, executionContext, httpContext) { } + + + internal WorkItemEventContext InvokeCreateContextFromEvent(WebHookEvent eventData) + { + return CreateContextFromEvent(eventData); + } + + internal void InvokeMigrateIdentityInformation(string resourceVersion, WorkItem workItem) + { + MigrateIdentityInformation(resourceVersion, workItem); + } } } diff --git a/src/unittests-function/TestData/ExampleTestData.cs b/src/unittests-function/TestData/ExampleTestData.cs index 17697e08..08bdc784 100644 --- a/src/unittests-function/TestData/ExampleTestData.cs +++ b/src/unittests-function/TestData/ExampleTestData.cs @@ -48,6 +48,11 @@ internal static string[] GetFromResource(string resourceName) public static class ExampleEvents { + public static WebHookEvent WorkItemUpdateEventResourceVersion10 => Helper.GetFromResource("ResourceVersion-1.0.json"); + public static WebHookEvent WorkItemUpdateEventResourceVersion31Preview3 => Helper.GetFromResource("ResourceVersion-3.1-preview.3.json"); + public static WebHookEvent WorkItemUpdateEventResourceVersion51Preview3 => Helper.GetFromResource("ResourceVersion-5.1-preview.3.json"); + + public static WebHookEvent TestEvent => Helper.GetFromResource("TestEvent.json"); public static string TestEventAsString => Helper.GetEmbeddedResourceContent("TestEvent.json"); } diff --git a/src/unittests-function/TestData/ResourceVersion-1.0.json b/src/unittests-function/TestData/ResourceVersion-1.0.json new file mode 100644 index 00000000..9948427b --- /dev/null +++ b/src/unittests-function/TestData/ResourceVersion-1.0.json @@ -0,0 +1,143 @@ +{ + "subscriptionId": "98bd70de-8422-4fb4-a4fb-d2f8d9e50ee9", + "notificationId": 4, + "id": "5497320c-a2d3-4a44-9cf8-94bae49a821d", + "eventType": "workitem.updated", + "publisherId": "tfs", + "message": null, + "detailedMessage": null, + "resource": { + "id": 18, + "workItemId": 30, + "rev": 5, + "revisedBy": { + "id": "968df890-725c-49a7-807c-37a98a6ab1f1", + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "uniqueName": "bob@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.NzA3MGYyMmQtNjM5Ni03ZTA2LWI4ZTAtMTM4YzMwOWI1YzUw" + }, + "revisedDate": "9999-01-01T00:00:00Z", + "fields": { + "System.Rev": { + "oldValue": 4, + "newValue": 5 + }, + "System.AuthorizedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.RevisedDate": { + "oldValue": "2020-01-07T13:16:04.837Z", + "newValue": "9999-01-01T00:00:00Z" + }, + "System.ChangedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.Watermark": { + "oldValue": 10734, + "newValue": 10735 + }, + "System.Description": { + "oldValue": "
add description
", + "newValue": "
added description
" + } + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemUpdates": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates" + }, + "html": { + "href": "https://dev.azure.com/web/wi.aspx?pcguid=478ee4b3-565c-4216-92b9-f220c8704e79&id=30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates/5", + "revision": { + "id": 30, + "rev": 5, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": "Bob Silent (Its Me) ", + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": "Alice Silent (Its Someone Else) ", + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": "Carol Silent (Its Another one) ", + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": "Bob Silent (Its Me) ", + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications", + }, + "relations": [ + { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/160", + "attributes": { + "isLocked": false, + "name": "Parent" + } + }, + { + "rel": "System.LinkTypes.Hierarchy-Forward", + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/184", + "attributes": { + "isLocked": false, + "name": "Child" + } + } + ], + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + } + }, + "resourceVersion": "1.0", + "resourceContainers": { + "collection": { + "id": "478ee4b3-565c-4216-92b9-f220c8704e79", + "baseUrl": "https://dev.azure.com/" + }, + "account": { + "id": "85eb50cc-a440-4c4f-b535-26e419e03044", + "baseUrl": "https://dev.azure.com/" + }, + "project": { + "id": "4cfab79e-7973-4d9d-b9df-253531404bba", + "baseUrl": "https://dev.azure.com/" + } + }, + "createdDate": "2020-01-07T13:18:27.1073456Z" +} diff --git a/src/unittests-function/TestData/ResourceVersion-3.1-preview.3.json b/src/unittests-function/TestData/ResourceVersion-3.1-preview.3.json new file mode 100644 index 00000000..960684c8 --- /dev/null +++ b/src/unittests-function/TestData/ResourceVersion-3.1-preview.3.json @@ -0,0 +1,134 @@ +{ + "SubscriptionId": "b3038ecc-97eb-423f-ba25-1fcc5dc7b3c9", + "NotificationId": 161, + "Id": "f406f6e5-401c-4efe-8868-3d978d9bc840", + "EventType": "workitem.updated", + "PublisherId": "tfs", + "Message": null, + "DetailedMessage": null, + "Resource": { + "id": 18, + "workItemId": 30, + "rev": 5, + "revisedBy": { + "id": "968df890-725c-49a7-807c-37a98a6ab1f1", + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me)", + "url": "https://dev.azure.com/", + "uniqueName": "bob@zzz.com", + "imageUrl": "https://dev.azure.com/", + }, + "revisedDate": "9999-01-01T00:00:00Z", + "fields": { + "System.Rev": { + "oldValue": 4, + "newValue": 5 + }, + "System.AuthorizedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.RevisedDate": { + "oldValue": "2020-01-07T13:16:04.837Z", + "newValue": "9999-01-01T00:00:00Z" + }, + "System.ChangedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.Watermark": { + "oldValue": 10734, + "newValue": 10735 + }, + "System.Description": { + "oldValue": "
add description
", + "newValue": "
added description
" + } + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemUpdates": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates" + }, + "html": { + "href": "https://dev.azure.com/web/wi.aspx?pcguid=478ee4b3-565c-4216-92b9-f220c8704e79&id=30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates/5", + "revision": { + "id": 30, + "rev": 5, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " + }, + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": { + "name": "Alice Silent (Its Someone Else) ", + "displayName": "Alice Silent (Its Someone Else) ", + "uniqueName": "Alice Silent (Its Someone Else) " + }, + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": { + "name": "Carol Silent (Its Another one) ", + "displayName": "Carol Silent (Its Another one) ", + "uniqueName": "Carol Silent (Its Another one) " + }, + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " }, + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications", + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + } + }, + "ResourceVersion": "3.1-preview.3", + "ResourceContainers": { + "collection": { + "Id": "478ee4b3-565c-4216-92b9-f220c8704e79", + "BaseUrl": "https://dev.azure.com/" + }, + "account": { + "Id": "85eb50cc-a440-4c4f-b535-26e419e03044", + "BaseUrl": "https://dev.azure.com/" + }, + "project": { + "Id": "4cfab79e-7973-4d9d-b9df-253531404bba", + "BaseUrl": "https://dev.azure.com/" + } + }, + "CreatedDate": "2020-01-07T12:00:37.1947845Z" +} \ No newline at end of file diff --git a/src/unittests-function/TestData/ResourceVersion-5.1-preview.3.json b/src/unittests-function/TestData/ResourceVersion-5.1-preview.3.json new file mode 100644 index 00000000..52e7e0ef --- /dev/null +++ b/src/unittests-function/TestData/ResourceVersion-5.1-preview.3.json @@ -0,0 +1,164 @@ +{ + "subscriptionId": "98bd70de-8422-4fb4-a4fb-d2f8d9e50ee9", + "notificationId": 3, + "id": "344f94e3-fe47-47ce-80cd-d5d6ea097179", + "eventType": "workitem.updated", + "publisherId": "tfs", + "message": null, + "detailedMessage": null, + "resource": { + "id": 7, + "workItemId": 30, + "rev": 5, + "revisedBy": { + "id": "968df890-725c-49a7-807c-37a98a6ab1f1", + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "uniqueName": "bob@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "revisedDate": "9999-01-01T00:00:00Z", + "fields": { + "System.Rev": { + "oldValue": 4, + "newValue": 5 + }, + "System.AuthorizedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.RevisedDate": { + "oldValue": "2020-01-07T13:16:04.837Z", + "newValue": "9999-01-01T00:00:00Z" + }, + "System.ChangedDate": { + "oldValue": "2020-01-07T13:14:28.067Z", + "newValue": "2020-01-07T13:16:04.837Z" + }, + "System.Watermark": { + "oldValue": 10734, + "newValue": 10735 + }, + "System.Description": { + "oldValue": "
add description
", + "newValue": "
added description
" + } + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemUpdates": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates" + }, + "html": { + "href": "https://dev.azure.com/web/wi.aspx?pcguid=478ee4b3-565c-4216-92b9-f220c8704e79&id=30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/updates/5", + "revision": { + "id": 30, + "rev": 5, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": { + "displayName": "Bob Silent (Its Me)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "b84d3a27-a755-4075-93e6-da5879b72f9a", + "uniqueName": "bob@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": { + "displayName": "Alice Silent (Its Someone Else)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "425ec2f4-10ee-409f-90a1-cbfceb896f84", + "uniqueName": "alice@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": { + "displayName": "Carol Silent (Its Another one)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "cd380640-87c3-4422-af75-b24c98f6ed11", + "uniqueName": "carol@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " }, + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications", + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + } + }, + "resourceVersion": "5.1-preview.3", + "resourceContainers": { + "collection": { + "id": "478ee4b3-565c-4216-92b9-f220c8704e79", + "baseUrl": "https://dev.azure.com/" + }, + "account": { + "id": "85eb50cc-a440-4c4f-b535-26e419e03044", + "baseUrl": "https://dev.azure.com/" + }, + "project": { + "id": "4cfab79e-7973-4d9d-b9df-253531404bba", + "baseUrl": "https://dev.azure.com/" + } + }, + "createdDate": "2020-01-07T13:16:12.4828759Z" +} \ No newline at end of file diff --git a/src/unittests-function/unittests-function.csproj b/src/unittests-function/unittests-function.csproj index 3f045ca6..8a8a6189 100644 --- a/src/unittests-function/unittests-function.csproj +++ b/src/unittests-function/unittests-function.csproj @@ -8,10 +8,15 @@ + + + + + @@ -34,4 +39,8 @@
+ + + + diff --git a/src/unittests-ruleng/TestData/ExampleTestData.cs b/src/unittests-ruleng/TestData/ExampleTestData.cs index 3a130e07..bcdc7747 100644 --- a/src/unittests-ruleng/TestData/ExampleTestData.cs +++ b/src/unittests-ruleng/TestData/ExampleTestData.cs @@ -60,6 +60,11 @@ internal static class ExampleTestData public static WorkItemUpdate WorkItemUpdateLinks => Helper.GetFromResource("WorkItem.22.UpdateLinks.json"); + public static WorkItem WorkItemResourceVersion10 => Helper.GetFromResource("WorkItem.30.ResourceVersion-1.0.json"); + public static WorkItem WorkItemResourceVersion31preview3 => Helper.GetFromResource("WorkItem.30.ResourceVersion-3.1-preview.3.json"); + public static WorkItem WorkItemResourceVersion51preview3 => Helper.GetFromResource("WorkItem.30.ResourceVersion-5.1-preview.3.json"); + + public static WorkItem BacklogFeatureOneChild => Helper.GetFromResource("Backlog.Feature1.OneChild.json"); public static WorkItem BacklogFeatureTwoChildren => Helper.GetFromResource("Backlog.Feature1.TwoChildren.json"); public static WorkItem BacklogUserStoryNew => Helper.GetFromResource("Backlog.UserStory2_New.json"); diff --git a/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-1.0.json b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-1.0.json new file mode 100644 index 00000000..1ca85547 --- /dev/null +++ b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-1.0.json @@ -0,0 +1,60 @@ +{ + "id": 30, + "rev": 2, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": "Bob Silent (Its Me) ", + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": "Alice Silent (Its Someone Else) ", + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": "Carol Silent (Its Another one) ", + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": "Bob Silent (Its Me) ", + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications" + }, + "relations": [ + { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/160", + "attributes": { + "isLocked": false, + "name": "Parent" + } + }, + { + "rel": "System.LinkTypes.Hierarchy-Forward", + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/184", + "attributes": { + "isLocked": false, + "name": "Child" + } + } + ], + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" +} \ No newline at end of file diff --git a/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-3.1-preview.3.json b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-3.1-preview.3.json new file mode 100644 index 00000000..1439736e --- /dev/null +++ b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-3.1-preview.3.json @@ -0,0 +1,58 @@ +{ + "id": 30, + "rev": 5, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " + }, + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": { + "name": "Alice Silent (Its Someone Else) ", + "displayName": "Alice Silent (Its Someone Else) ", + "uniqueName": "Alice Silent (Its Someone Else) " + }, + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": { + "name": "Carol Silent (Its Another one) ", + "displayName": "Carol Silent (Its Another one) ", + "uniqueName": "Carol Silent (Its Another one) " + }, + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " + }, + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications" + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" +} \ No newline at end of file diff --git a/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-5.1-preview.3.json b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-5.1-preview.3.json new file mode 100644 index 00000000..0ffb504a --- /dev/null +++ b/src/unittests-ruleng/TestData/WorkItem.30.ResourceVersion-5.1-preview.3.json @@ -0,0 +1,82 @@ +{ + "id": 30, + "rev": 2, + "fields": { + "System.AreaPath": "ExampleProject\\Area 1", + "System.TeamProject": "ExampleProject", + "System.IterationPath": "ExampleProject\\Path 1", + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Reason": "Implementation started", + "System.AssignedTo": { + "displayName": "Bob Silent (Its Me)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "b84d3a27-a755-4075-93e6-da5879b72f9a", + "uniqueName": "bob@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.CreatedDate": "2019-07-09T12:48:37.253Z", + "System.CreatedBy": { + "displayName": "Alice Silent (Its Someone Else)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "425ec2f4-10ee-409f-90a1-cbfceb896f84", + "uniqueName": "alice@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.ChangedDate": "2020-01-07T13:16:04.837Z", + "System.ChangedBy": { + "displayName": "Carol Silent (Its Another one)", + "url": "https://dev.azure.com/", + "_links": { + "avatar": { + "href": "https://dev.azure.com/" + } + }, + "id": "cd380640-87c3-4422-af75-b24c98f6ed11", + "uniqueName": "carol@zzz.com", + "imageUrl": "https://dev.azure.com/", + "descriptor": "msa.fsgfdgfdgfdgfd" + }, + "System.CommentCount": 0, + "System.Title": "Implement a framework that migrates legacy to portable frameworks", + "System.BoardColumn": "Active", + "System.BoardColumnDone": false, + "Microsoft.VSTS.Common.ActivatedDate": "2019-07-22T09:18:03.733Z", + "Microsoft.VSTS.Common.ActivatedBy": { + "name": "Bob Silent (Its Me) ", + "displayName": "Bob Silent (Its Me) ", + "uniqueName": "Bob Silent (Its Me) " + }, + "Microsoft.VSTS.Common.Priority": 2, + "Microsoft.VSTS.Common.StateChangeDate": "2016-12-18T20:41:39.98Z", + "Microsoft.VSTS.Common.ValueArea": "Architectural", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column": "Active", + "WEF_EA18760EB7614A508782E4DBFFBAF4E9_Kanban.Column.Done": false, + "System.Description": "
add description 
", + "System.Tags": "universal applications" + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" + }, + "workItemRevisions": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/30/revisions/5" +} \ No newline at end of file diff --git a/src/unittests-ruleng/unittests-ruleng.csproj b/src/unittests-ruleng/unittests-ruleng.csproj index f02ef9a6..137d6242 100644 --- a/src/unittests-ruleng/unittests-ruleng.csproj +++ b/src/unittests-ruleng/unittests-ruleng.csproj @@ -23,6 +23,9 @@ + + +
@@ -46,8 +49,11 @@ - + + + +