diff --git a/doc/rule-examples.md b/doc/rule-examples.md index 6c12fe13..c5b7c1ef 100644 --- a/doc/rule-examples.md +++ b/doc/rule-examples.md @@ -11,7 +11,7 @@ $"Hello { self.WorkItemType } #{ self.Id } - { self.Title }!" ## Auto-close parent This is more similar to classic TFS Aggregator. -It move a parent work item to Closed state, if all children are closed. +It moves a parent work item to Closed state, if all children are closed. The major difference is the navigation: `Parent` and `Children` properties do not returns work items but relation. You have to explicitly query Azure DevOps to retrieve the referenced work items. ``` @@ -34,6 +34,22 @@ if (parent != null) return message; ``` +## Work item update + +Check if a work item was updated and execute actions based on the changes, e.g. if work item Title was updated. + +``` +if (selfChanges.Fields.ContainsKey("System.Title")) +{ + var titleUpdate = selfChanges.Fields["System.Title"]; + return $"Title was changed from '{titleUpdate.OldValue}' to '{titleUpdate.NewValue}'"; +} +else +{ + return "Title was not updated"; +} +``` + ## History `PreviousRevision` is different because retrieves a read-only version of the work item. diff --git a/doc/rule-language.md b/doc/rule-language.md index d2864b07..e36f268d 100644 --- a/doc/rule-language.md +++ b/doc/rule-language.md @@ -174,6 +174,60 @@ in recycle bin. Returns the number of attached files. +# WorkItem Changes +If the rule was triggered by the `workitem.updated` event, the changes +which were made to the WorkItem object, are contained in the `selfChanges` variable. + +## Fields +Data fields of the work item update. + +`int Id` Read-only. +The unique identifier of the _Update_. +Each change leads to an increased update id, but not necessarily to an updated revision number. +Changing only relations, without changing any other information does not increase revision number. + +`int WorkItemId` Read-only. +The unique identifier of the _work item_. + +`int Rev` Read-only. +The revision number of work item update. + +`IdentityRef RevisedBy` Read-only. +The Identity of the team member who updated the work item. + +`DateTime RevisedDate` Read-only. +The date and time when the work item updates revision date. + +`WorkItemFieldUpdate Fields[string field]` Read-only. +Access to the list of updated fields. +Must use reference name, like _System.Title_, instead of language specific, like _Titolo_, _Titel_ or _Title_. + +`WorkItemRelationUpdates Relations` Read-only. +Returns the information about updated relations + +## WorkItemFieldUpdate +Updated Field Information containing old and new value. + +`object OldValue` Read-only. +Returns the previous value of the field or `null` + +`object NewValue` Read-only. +Returns the new value of the field + + +## WorkItemRelationUpdates +Groups the changes of the relations + +`ICollection Added` Read-only. +Returns the added relations as `WorkItemRelation`. + +`ICollection Removed` Read-only. +Returns the removed relations as `WorkItemRelation`. + +`ICollection Updated` Read-only. +Returns the updated relations as `WorkItemRelation`. + + # WorkItemStore Object The WorkItemStore object allows retrieval, creation and removal of work items. diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index 79075b82..d78d8043 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -7,7 +7,9 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using aggregator.Engine; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; using Newtonsoft.Json.Linq; using ExecutionContext = Microsoft.Azure.WebJobs.ExecutionContext; @@ -60,10 +62,9 @@ public async Task RunAsync(HttpRequestMessage req, Cancella } var data = JsonConvert.DeserializeObject(jsonContent); - string eventType = data.EventType; // sanity check - if (!DevOpsEvents.IsValidEvent(eventType) + if (!DevOpsEvents.IsValidEvent(data.EventType) || data.PublisherId != DevOpsEvents.PublisherId) { return req.CreateResponse(HttpStatusCode.BadRequest, new @@ -72,29 +73,12 @@ public async Task RunAsync(HttpRequestMessage req, Cancella }); } - var resourceObject = data.Resource as JObject; - WorkItem workItem; - if (ServiceHooksEventTypeConstants.WorkItemUpdated == eventType) + var eventContext = CreateContextFromEvent(data); + if (eventContext.IsTestEvent()) { - workItem = resourceObject.GetValue("revision").ToObject(); - } - else - { - workItem = resourceObject.ToObject(); + return RespondToTestEventMessage(req, aggregatorVersion); } - if (workItem.Url.StartsWith("http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/")) - { - 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; - } - string collectionUrl = data.ResourceContainers["collection"].BaseUrl; - var config = new ConfigurationBuilder() .SetBasePath(_context.FunctionAppDirectory) .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) @@ -107,8 +91,7 @@ public async Task RunAsync(HttpRequestMessage req, Cancella var wrapper = new RuleWrapper(configuration, logger, _context.FunctionName, _context.FunctionDirectory); try { - Guid teamProjectId = data.ResourceContainers["project"].Id; - string execResult = await wrapper.ExecuteAsync(new Uri(collectionUrl), teamProjectId, workItem, cancellationToken); + string execResult = await wrapper.ExecuteAsync(eventContext, cancellationToken); if (string.IsNullOrEmpty(execResult)) { @@ -138,6 +121,36 @@ public async Task RunAsync(HttpRequestMessage req, Cancella } } + private HttpResponseMessage RespondToTestEventMessage(HttpRequestMessage req, string aggregatorVersion) + { + 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; + } + + private static WorkItemEventContext CreateContextFromEvent(WebHookEvent eventData) + { + var collectionUrl = eventData.ResourceContainers.GetValueOrDefault("collection")?.BaseUrl; + var teamProjectId = eventData.ResourceContainers.GetValueOrDefault("project")?.Id ?? Guid.Empty; + + var resourceObject = eventData.Resource as JObject; + if (ServiceHooksEventTypeConstants.WorkItemUpdated == eventData.EventType) + { + var workItem = resourceObject.GetValue("revision").ToObject(); + var workItemUpdate = resourceObject.ToObject(); + return new WorkItemEventContext(teamProjectId, new Uri(collectionUrl), workItem, workItemUpdate); + } + else + { + var workItem = resourceObject.ToObject(); + return new WorkItemEventContext(teamProjectId, new Uri(collectionUrl), workItem); + } + } + private static T GetCustomAttribute() where T : Attribute { diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index c95a9b4b..06e9c073 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -3,10 +3,9 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using aggregator.Engine; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; -using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; -using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; using Microsoft.VisualStudio.Services.WebApi; namespace aggregator @@ -29,7 +28,7 @@ public RuleWrapper(AggregatorConfiguration configuration, IAggregatorLogger logg this.functionDirectory = functionDirectory; } - internal async Task ExecuteAsync(Uri collectionUri, Guid teamProjectId, WorkItem workItem, CancellationToken cancellationToken) + internal async Task ExecuteAsync(WorkItemEventContext eventContext, CancellationToken cancellationToken) { logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); var clientCredentials = default(VssCredentials); @@ -46,7 +45,7 @@ internal async Task ExecuteAsync(Uri collectionUri, Guid teamProjectId, cancellationToken.ThrowIfCancellationRequested(); // TODO improve from https://github.com/Microsoft/vsts-work-item-migrator - using (var devops = new VssConnection(collectionUri, clientCredentials)) + using (var devops = new VssConnection(eventContext.CollectionUri, clientCredentials)) { await devops.ConnectAsync(cancellationToken); logger.WriteInfo($"Connected to Azure DevOps"); @@ -69,7 +68,7 @@ internal async Task ExecuteAsync(Uri collectionUri, Guid teamProjectId, var engine = new Engine.RuleEngine(logger, ruleCode, configuration.SaveMode, configuration.DryRun); - return await engine.ExecuteAsync(teamProjectId, workItem, witClient, cancellationToken); + return await engine.ExecuteAsync(eventContext.ProjectId, eventContext.WorkItemPayload, witClient, cancellationToken); } } } diff --git a/src/aggregator-ruleng/BatchRequest.cs b/src/aggregator-ruleng/BatchRequest.cs deleted file mode 100644 index 18c6e7fa..00000000 --- a/src/aggregator-ruleng/BatchRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace aggregator.Engine -{ - public class BatchRequest - { - public string method { get; set; } - public Dictionary headers { get; set; } - public object[] body { get; set; } - public string uri { get; set; } - } -} diff --git a/src/aggregator-ruleng/Globals.cs b/src/aggregator-ruleng/Globals.cs index 439a30d8..37719d6a 100644 --- a/src/aggregator-ruleng/Globals.cs +++ b/src/aggregator-ruleng/Globals.cs @@ -7,6 +7,7 @@ namespace aggregator.Engine public class Globals { public WorkItemWrapper self; + public WorkItemUpdateWrapper selfChanges; public WorkItemStore store; public IAggregatorLogger logger; } diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index cf0a669b..b40a8df8 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -52,8 +52,7 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode, SaveMode mode, bo .WithEmitDebugInformation(true) .WithReferences(references) // Add namespaces - .WithImports(imports) - ; + .WithImports(imports); this.roslynScript = CSharpScript.Create( code: directives.GetRuleCode(), @@ -107,21 +106,24 @@ private static IEnumerable GetImports(DirectivesParser directives) public EngineState State { get; private set; } public bool DryRun { get; } - public async Task ExecuteAsync(Guid projectId, WorkItem workItem, WorkItemTrackingHttpClient witClient, CancellationToken cancellationToken) + public async Task ExecuteAsync(Guid projectId, WorkItemData workItemPayload, WorkItemTrackingHttpClient witClient, CancellationToken cancellationToken) { if (State == EngineState.Error) { return string.Empty; } + var workItem = workItemPayload.WorkItem; var context = new EngineContext(witClient, projectId, workItem.GetTeamProject(), logger); var store = new WorkItemStore(context, workItem); var self = store.GetWorkItem(workItem.Id.Value); + var selfChanges = new WorkItemUpdateWrapper(workItemPayload.WorkItemUpdate); logger.WriteInfo($"Initial WorkItem {self.Id} retrieved from {witClient.BaseAddress}"); var globals = new Globals { self = self, + selfChanges = selfChanges, store = store, logger = logger }; diff --git a/src/aggregator-ruleng/WorkItemBatchPostResponse.cs b/src/aggregator-ruleng/WorkItemBatchPostResponse.cs deleted file mode 100644 index bb024f8f..00000000 --- a/src/aggregator-ruleng/WorkItemBatchPostResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Newtonsoft.Json; - -namespace aggregator.Engine -{ - public class WorkItemBatchPostResponse - { - public int count { get; set; } - [JsonProperty("value")] - public List values { get; set; } - - public class Value - { - public int code { get; set; } - public Dictionary headers { get; set; } - public string body { get; set; } - } - } -} diff --git a/src/aggregator-ruleng/WorkItemData.cs b/src/aggregator-ruleng/WorkItemData.cs new file mode 100644 index 00000000..bbc65245 --- /dev/null +++ b/src/aggregator-ruleng/WorkItemData.cs @@ -0,0 +1,21 @@ +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; + + +namespace aggregator.Engine +{ + public class WorkItemData + { + + public WorkItemData(WorkItem workItem, WorkItemUpdate workItemUpdate = null) + { + WorkItem = workItem; + WorkItemUpdate = workItemUpdate ?? new WorkItemUpdate(); + } + + public WorkItem WorkItem { get; } + + public WorkItemUpdate WorkItemUpdate { get; } + + public static implicit operator WorkItemData(WorkItem workItem) => new WorkItemData(workItem); + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/WorkItemEventContext.cs b/src/aggregator-ruleng/WorkItemEventContext.cs new file mode 100644 index 00000000..1e34c06e --- /dev/null +++ b/src/aggregator-ruleng/WorkItemEventContext.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; + +namespace aggregator.Engine +{ + + public class WorkItemEventContext + { + public WorkItemEventContext(Guid projectId, Uri collectionUri, WorkItem workItem, WorkItemUpdate workItemUpdate = null) + { + ProjectId = projectId; + CollectionUri = collectionUri; + WorkItemPayload = new WorkItemData(workItem, workItemUpdate); + } + + public WorkItemData WorkItemPayload { get; } + public Guid ProjectId { get; } + public Uri CollectionUri { get; } + } + + public static class WorkItemEventContextExtension + { + public static bool IsTestEvent(this WorkItemEventContext eventContext) + { + const string TEST_EVENT_COLLECTION_URL = "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/"; + + var workItem = eventContext.WorkItemPayload.WorkItem; + return workItem.Url.StartsWith(TEST_EVENT_COLLECTION_URL, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/aggregator-ruleng/WorkItemRelationWrapper.cs b/src/aggregator-ruleng/WorkItemRelationWrapper.cs index 102ba2ce..2fa58ef2 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapper.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapper.cs @@ -7,18 +7,15 @@ namespace aggregator.Engine { public class WorkItemRelationWrapper { - private WorkItemRelation _relation; - private readonly WorkItemWrapper _item; + private readonly WorkItemRelation _relation; - internal WorkItemRelationWrapper(WorkItemWrapper item, WorkItemRelation relation) + internal WorkItemRelationWrapper(WorkItemRelation relation) { - _item = item; _relation = relation; } - internal WorkItemRelationWrapper(WorkItemWrapper item, string type, string url, string comment) + internal WorkItemRelationWrapper(string type, string url, string comment) { - _item = item; _relation = new WorkItemRelation() { Rel = type, @@ -27,33 +24,12 @@ internal WorkItemRelationWrapper(WorkItemWrapper item, string type, string url, }; } - public string Title - { - get - { - return _relation.Title; - } - } + public string Title => _relation.Title; - public string Rel - { - get - { - return _relation.Rel; - } - } + public string Rel => _relation.Rel; - public string Url - { - get - { - return _relation.Url; - } - } + public string Url => _relation.Url; - public IDictionary Attributes - { - get { return _relation.Attributes; } - } + public IDictionary Attributes => _relation.Attributes; } } diff --git a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs index 93b47dff..a36434a6 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs @@ -20,8 +20,9 @@ internal WorkItemRelationWrapperCollection(WorkItemWrapper workItem, IList() - : new List(relations.Select(relation => - new WorkItemRelationWrapper(_pivotWorkItem, relation))); + : relations.Select(relation => new WorkItemRelationWrapper(relation)) + .ToList(); + // do we need deep cloning? _current = new List(_original); } @@ -43,7 +44,7 @@ private void AddRelation(WorkItemRelationWrapper item) { rel = item.Rel, url = item.Url, - attributes = item.Attributes != null && item.Attributes.TryGetValue("comment", out var value) + attributes = item.Attributes != null && item.Attributes.TryGetValue("comment", out object value) ? new { comment = value } : null } @@ -91,20 +92,19 @@ public void Add(WorkItemRelationWrapper item) public void AddChild(WorkItemWrapper child) { - var r = new WorkItemRelationWrapper(child, CoreRelationRefNames.Children, child.Url, string.Empty); + var r = new WorkItemRelationWrapper(CoreRelationRefNames.Children, child.Url, string.Empty); AddRelation(r); } public void AddParent(WorkItemWrapper parent) { - var r = new WorkItemRelationWrapper(parent, CoreRelationRefNames.Parent, parent.Url, string.Empty); + var r = new WorkItemRelationWrapper(CoreRelationRefNames.Parent, parent.Url, string.Empty); AddRelation(r); } public void AddLink(string type, string url, string comment) { AddRelation(new WorkItemRelationWrapper( - _pivotWorkItem, type, url, comment diff --git a/src/aggregator-ruleng/WorkItemUpdateWrapper.cs b/src/aggregator-ruleng/WorkItemUpdateWrapper.cs new file mode 100644 index 00000000..db8c1b4e --- /dev/null +++ b/src/aggregator-ruleng/WorkItemUpdateWrapper.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.WebApi; + + +namespace aggregator.Engine +{ + public class WorkItemUpdateWrapper + { + private static readonly WorkItemUpdate _noUpdate = new WorkItemUpdate(); + private readonly WorkItemUpdate _workItemUpdate; + + public WorkItemUpdateWrapper(WorkItemUpdate workItemUpdate) + { + _workItemUpdate = workItemUpdate ?? _noUpdate; + + Relations = new WorkItemRelationUpdatesWrapper() + { + Added = _workItemUpdate.Relations?.Added?.Select(relation => new WorkItemRelationWrapper(relation)).ToList() ?? new List(), + Removed = _workItemUpdate.Relations?.Removed?.Select(relation => new WorkItemRelationWrapper(relation)).ToList() ?? new List(), + Updated = _workItemUpdate.Relations?.Updated?.Select(relation => new WorkItemRelationWrapper(relation)).ToList() ?? new List(), + }; + + Fields = _workItemUpdate.Fields? + .ToDictionary(kvp => kvp.Key, + kvp => new WorkItemFieldUpdateWrapper() + { + NewValue = kvp.Value.NewValue, + OldValue = kvp.Value.OldValue, + }) + ?? new Dictionary(); + } + + + /// ID of update. + public int Id => _workItemUpdate.Id; + + /// The work item ID. + public int WorkItemId => _workItemUpdate.WorkItemId; + + /// The revision number of work item update. + public int Rev => _workItemUpdate.Rev; + + /// Identity for the work item update. + public IdentityRef RevisedBy => _workItemUpdate.RevisedBy; + + /// The work item updates revision date. + public DateTime RevisedDate => _workItemUpdate.RevisedDate; + + /// List of updates to fields. + public IDictionary Fields { get; } + + /// List of updates to relations. + public WorkItemRelationUpdatesWrapper Relations { get; } + + public string Url => _workItemUpdate.Url; + } + + public class WorkItemFieldUpdateWrapper + { + /// The old value of the field. + public object OldValue { get; set; } + + /// The new value of the field. + public object NewValue { get; set; } + } + + public class WorkItemRelationUpdatesWrapper + { + public WorkItemRelationUpdatesWrapper() + { + Added = new List(); + Removed = new List(); + Updated = new List(); + } + + /// + /// List of newly added relations. + /// + public IReadOnlyCollection Added { get; set; } + + /// + /// List of removed relations. + /// + public IReadOnlyCollection Removed { get; set; } + + /// + /// List of updated relations. + /// + public IReadOnlyCollection Updated { get; set; } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 6e184cec..a66c0852 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -10,15 +10,14 @@ namespace aggregator.Engine { public class WorkItemWrapper { - private EngineContext _context; - private WorkItem _item; - private WorkItemRelationWrapperCollection _relationCollection; + private readonly EngineContext _context; + private readonly WorkItem _item; internal WorkItemWrapper(EngineContext context, WorkItem item) { _context = context; _item = item; - _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); + Relations = new WorkItemRelationWrapperCollection(this, _item.Relations); if (item.Id.HasValue) { @@ -31,6 +30,8 @@ internal WorkItemWrapper(EngineContext context, WorkItem item) }); //for simplify testing: item.Url can be null IsDeleted = item.Url?.EndsWith($"/recyclebin/{item.Id.Value}", StringComparison.OrdinalIgnoreCase) ?? false; + + IsReadOnly = false; _context.Tracker.TrackExisting(this); } else @@ -42,58 +43,19 @@ internal WorkItemWrapper(EngineContext context, WorkItem item) Path = "/id", Value = Id.Value }); + _context.Tracker.TrackNew(this); } } - //TODO Fields null, and does not work with pseudo Url created in WorkItemStore.NewWorkItem, delete unused constructor - public WorkItemWrapper(EngineContext context, string project, string type) - { - _context = context; - - Id = new TemporaryWorkItemId(_context.Tracker); - - _item = new WorkItem(); - _item.Fields[CoreFieldRefNames.TeamProject] = project; - _item.Fields[CoreFieldRefNames.WorkItemType] = type; - _item.Fields[CoreFieldRefNames.Id] = Id.Value; - _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); - - Changes.Add(new JsonPatchOperation() - { - Operation = Operation.Add, - Path = "/id", - Value = Id.Value - }); - _context.Tracker.TrackNew(this); - } - - public WorkItemWrapper(EngineContext context, WorkItemWrapper template, string type) - { - _context = context; - - Id = new TemporaryWorkItemId(_context.Tracker); - - _item = new WorkItem(); - _item.Fields[CoreFieldRefNames.TeamProject] = template.TeamProject; - _item.Fields[CoreFieldRefNames.WorkItemType] = type; - _item.Fields[CoreFieldRefNames.Id] = Id.Value; - _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); - - Changes.Add(new JsonPatchOperation() - { - Operation = Operation.Add, - Path = "/id", - Value = Id.Value - }); - _context.Tracker.TrackNew(this); - } internal WorkItemWrapper(EngineContext context, WorkItem item, bool isReadOnly) // we cannot reuse the code, because tracking is different //: this(context, item) { _context = context; + _item = item; + Relations = new WorkItemRelationWrapperCollection(this, _item.Relations); Id = new PermanentWorkItemId(item.Id.Value); Changes.Add(new JsonPatchOperation() @@ -102,9 +64,9 @@ internal WorkItemWrapper(EngineContext context, WorkItem item, bool isReadOnly) Path = "/rev", Value = item.Rev }); + IsDeleted = item.Url?.EndsWith($"/recyclebin/{item.Id.Value}", StringComparison.OrdinalIgnoreCase) ?? false; + IsReadOnly = isReadOnly; - _item = item; - _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); _context.Tracker.TrackRevision(this); } @@ -114,8 +76,8 @@ public WorkItemWrapper PreviousRevision { if (Rev > 0) { - // TODO we shouldn't use the client in this class - // TODO check if already loaded + // TODO we shouldn't use the client in this class, move to WorkItemStore.GetRevisionAsync, workitemstore should check tracker if already loaded + // TODO think about passing workitemstore into workitemwrapper constructor, instead of engineContext, workitemstore is used several times, see also Property Children/Parent var previousRevision = _context.Client.GetRevisionAsync(this.Id.Value, this.Rev - 1, expand: WorkItemExpand.All).Result; return new WorkItemWrapper(_context, previousRevision, true); } @@ -138,27 +100,15 @@ public IEnumerable Revisions } } - public IEnumerable RelationLinks - { - get - { - return _relationCollection; - } - } + public IEnumerable RelationLinks => Relations; - public WorkItemRelationWrapperCollection Relations - { - get - { - return _relationCollection; - } - } + public WorkItemRelationWrapperCollection Relations { get; } public IEnumerable ChildrenLinks { get { - return _relationCollection + return Relations .Where(rel => rel.Rel == CoreRelationRefNames.Children); } } @@ -167,7 +117,7 @@ public IEnumerable Children { get { - if (ChildrenLinks != null && ChildrenLinks.Count() > 0) + if (ChildrenLinks != null && ChildrenLinks.Any()) { var store = new WorkItemStore(_context); return store.GetWorkItems(ChildrenLinks); @@ -181,7 +131,7 @@ public IEnumerable RelatedLinks { get { - return _relationCollection + return Relations .Where(rel => rel.Rel == CoreRelationRefNames.Related); } } @@ -190,7 +140,7 @@ public IEnumerable Hyperlinks { get { - return _relationCollection + return Relations .Where(rel => rel.Rel == CoreRelationRefNames.Hyperlink); } } @@ -199,7 +149,7 @@ public WorkItemRelationWrapper ParentLink { get { - return _relationCollection + return Relations .SingleOrDefault(rel => rel.Rel == CoreRelationRefNames.Parent); } } @@ -208,7 +158,7 @@ public WorkItemWrapper Parent { get { - if (ParentLink != null && ParentLink != default(WorkItemRelationWrapper)) + if (ParentLink != null) { var store = new WorkItemStore(_context); return store.GetWorkItem(ParentLink); @@ -224,164 +174,158 @@ public WorkItemId Id private set; } - public int Rev - { - get { return _item.Rev.Value; } - } + public int Rev => _item.Rev.Value; - public string Url - { - get { return _item.Url; } - } + public string Url => _item.Url; public string WorkItemType { - get { return (string)_item.Fields[CoreFieldRefNames.WorkItemType]; } - private set { SetFieldValue(CoreFieldRefNames.WorkItemType, value); } + get => (string)_item.Fields[CoreFieldRefNames.WorkItemType]; + private set => SetFieldValue(CoreFieldRefNames.WorkItemType, value); } public string State { - get { return GetFieldValue(CoreFieldRefNames.State); } - set { SetFieldValue(CoreFieldRefNames.State, value); } + get => GetFieldValue(CoreFieldRefNames.State); + set => SetFieldValue(CoreFieldRefNames.State, value); } public int AreaId { - get { return GetFieldValue(CoreFieldRefNames.AreaId); } - set { SetFieldValue(CoreFieldRefNames.AreaId, value); } + get => GetFieldValue(CoreFieldRefNames.AreaId); + set => SetFieldValue(CoreFieldRefNames.AreaId, value); } public string AreaPath { - get { return GetFieldValue(CoreFieldRefNames.AreaPath); } - set { SetFieldValue(CoreFieldRefNames.AreaPath, value); } + get => GetFieldValue(CoreFieldRefNames.AreaPath); + set => SetFieldValue(CoreFieldRefNames.AreaPath, value); } public IdentityRef AssignedTo { - get { return GetFieldValue(CoreFieldRefNames.AssignedTo); } - set { SetFieldValue(CoreFieldRefNames.AssignedTo, value); } + get => GetFieldValue(CoreFieldRefNames.AssignedTo); + set => SetFieldValue(CoreFieldRefNames.AssignedTo, value); } public int AttachedFileCount { - get { return GetFieldValue(CoreFieldRefNames.AttachedFileCount); } - set { SetFieldValue(CoreFieldRefNames.AttachedFileCount, value); } + get => GetFieldValue(CoreFieldRefNames.AttachedFileCount); + set => SetFieldValue(CoreFieldRefNames.AttachedFileCount, value); } public IdentityRef AuthorizedAs { - get { return GetFieldValue(CoreFieldRefNames.AuthorizedAs); } - set { SetFieldValue(CoreFieldRefNames.AuthorizedAs, value); } + get => GetFieldValue(CoreFieldRefNames.AuthorizedAs); + set => SetFieldValue(CoreFieldRefNames.AuthorizedAs, value); } public IdentityRef ChangedBy { - get { return GetFieldValue(CoreFieldRefNames.ChangedBy); } - set { SetFieldValue(CoreFieldRefNames.ChangedBy, value); } + get => GetFieldValue(CoreFieldRefNames.ChangedBy); + set => SetFieldValue(CoreFieldRefNames.ChangedBy, value); } public DateTime? ChangedDate { - get { return GetFieldValue(CoreFieldRefNames.ChangedDate); } - set { SetFieldValue(CoreFieldRefNames.ChangedDate, value); } + get => GetFieldValue(CoreFieldRefNames.ChangedDate); + set => SetFieldValue(CoreFieldRefNames.ChangedDate, value); } public IdentityRef CreatedBy { - get { return GetFieldValue(CoreFieldRefNames.CreatedBy); } - set { SetFieldValue(CoreFieldRefNames.CreatedBy, value); } + get => GetFieldValue(CoreFieldRefNames.CreatedBy); + set => SetFieldValue(CoreFieldRefNames.CreatedBy, value); } public DateTime? CreatedDate { - get { return GetFieldValue(CoreFieldRefNames.CreatedDate); } - set { SetFieldValue(CoreFieldRefNames.CreatedDate, value); } + get => GetFieldValue(CoreFieldRefNames.CreatedDate); + set => SetFieldValue(CoreFieldRefNames.CreatedDate, value); } public string Description { - get { return GetFieldValue(CoreFieldRefNames.Description); } - set { SetFieldValue(CoreFieldRefNames.Description, value); } + get => GetFieldValue(CoreFieldRefNames.Description); + set => SetFieldValue(CoreFieldRefNames.Description, value); } public int ExternalLinkCount { - get { return GetFieldValue(CoreFieldRefNames.ExternalLinkCount); } - set { SetFieldValue(CoreFieldRefNames.ExternalLinkCount, value); } + get => GetFieldValue(CoreFieldRefNames.ExternalLinkCount); + set => SetFieldValue(CoreFieldRefNames.ExternalLinkCount, value); } public string History { - get { return GetFieldValue(CoreFieldRefNames.History); } - set { SetFieldValue(CoreFieldRefNames.History, value); } + get => GetFieldValue(CoreFieldRefNames.History); + set => SetFieldValue(CoreFieldRefNames.History, value); } public int HyperLinkCount { - get { return GetFieldValue(CoreFieldRefNames.HyperLinkCount); } - set { SetFieldValue(CoreFieldRefNames.HyperLinkCount, value); } + get => GetFieldValue(CoreFieldRefNames.HyperLinkCount); + set => SetFieldValue(CoreFieldRefNames.HyperLinkCount, value); } public int IterationId { - get { return GetFieldValue(CoreFieldRefNames.IterationId); } - set { SetFieldValue(CoreFieldRefNames.IterationId, value); } + get => GetFieldValue(CoreFieldRefNames.IterationId); + set => SetFieldValue(CoreFieldRefNames.IterationId, value); } public string IterationPath { - get { return GetFieldValue(CoreFieldRefNames.IterationPath); } - set { SetFieldValue(CoreFieldRefNames.IterationPath, value); } + get => GetFieldValue(CoreFieldRefNames.IterationPath); + set => SetFieldValue(CoreFieldRefNames.IterationPath, value); } public string Reason { - get { return GetFieldValue(CoreFieldRefNames.Reason); } - set { SetFieldValue(CoreFieldRefNames.Reason, value); } + get => GetFieldValue(CoreFieldRefNames.Reason); + set => SetFieldValue(CoreFieldRefNames.Reason, value); } public int RelatedLinkCount { - get { return GetFieldValue(CoreFieldRefNames.RelatedLinkCount); } - set { SetFieldValue(CoreFieldRefNames.RelatedLinkCount, value); } + get => GetFieldValue(CoreFieldRefNames.RelatedLinkCount); + set => SetFieldValue(CoreFieldRefNames.RelatedLinkCount, value); } public DateTime? RevisedDate { - get { return GetFieldValue(CoreFieldRefNames.RevisedDate); } - set { SetFieldValue(CoreFieldRefNames.RevisedDate, value); } + get => GetFieldValue(CoreFieldRefNames.RevisedDate); + set => SetFieldValue(CoreFieldRefNames.RevisedDate, value); } public DateTime? AuthorizedDate { - get { return GetFieldValue(CoreFieldRefNames.AuthorizedDate); } - set { SetFieldValue(CoreFieldRefNames.AuthorizedDate, value); } + get => GetFieldValue(CoreFieldRefNames.AuthorizedDate); + set => SetFieldValue(CoreFieldRefNames.AuthorizedDate, value); } public string TeamProject { - get { return GetFieldValue(CoreFieldRefNames.TeamProject); } - set { SetFieldValue(CoreFieldRefNames.TeamProject, value); } + get => GetFieldValue(CoreFieldRefNames.TeamProject); + set => SetFieldValue(CoreFieldRefNames.TeamProject, value); } public string Tags { - get { return GetFieldValue(CoreFieldRefNames.Tags); } - set { SetFieldValue(CoreFieldRefNames.Tags, value); } + get => GetFieldValue(CoreFieldRefNames.Tags); + set => SetFieldValue(CoreFieldRefNames.Tags, value); } public string Title { - get { return GetFieldValue(CoreFieldRefNames.Title); } - set { SetFieldValue(CoreFieldRefNames.Title, value); } + get => GetFieldValue(CoreFieldRefNames.Title); + set => SetFieldValue(CoreFieldRefNames.Title, value); } public double Watermark { - get { return GetFieldValue(CoreFieldRefNames.Watermark); } - set { SetFieldValue(CoreFieldRefNames.Watermark, value); } + get => GetFieldValue(CoreFieldRefNames.Watermark); + set => SetFieldValue(CoreFieldRefNames.Watermark, value); } public bool IsDeleted { get; } @@ -398,8 +342,8 @@ public double Watermark public object this[string field] { - get { return GetFieldValue(field); } - set { SetFieldValue(field, value); } + get => GetFieldValue(field); + set => SetFieldValue(field, value); } private void SetFieldValue(string field, object value) @@ -433,14 +377,18 @@ private void SetFieldValue(string field, object value) IsDirty = true; } - private object TranslateValue(object value) + private static object TranslateValue(object value) { switch (value) { case IdentityRef id: + { return id.DisplayName; + } default: + { return value; + } } } @@ -448,7 +396,7 @@ private T GetFieldValue(string field) { return _item.Fields.TryGetValue(field, out var value) ? (T)value - : default(T); + : default; } internal void ReplaceIdAndResetChanges(int oldId, int newId) diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index b7436509..f3cd5b4c 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -8,6 +8,7 @@ using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using NSubstitute; +using unittests_ruleng.TestData; using Xunit; namespace unittests_ruleng @@ -342,7 +343,7 @@ public async Task ImportDirective_Fail() } [Fact] - public async Task DelteWorkItem() + public async Task DeleteWorkItem() { int workItemId = 42; WorkItem workItem = new WorkItem @@ -366,5 +367,50 @@ public async Task DelteWorkItem() () => engine.ExecuteAsync(projectId, workItem, client, CancellationToken.None) ); } + + + [Fact] + public async Task HelloWorldRuleOnUpdate_Succeeds() + { + var workItem = ExampleTestData.Instance.WorkItem; + var workItemUpdate = ExampleTestData.Instance.WorkItemUpdateFields; + + client.GetWorkItemAsync(workItem.Id.Value, expand: WorkItemExpand.All).Returns(workItem); + string ruleCode = @" +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(projectId, new WorkItemData(workItem, workItemUpdate), client, CancellationToken.None); + + Assert.Equal("Hello #22 - Update 3 changed Title from Initial Title to Hello!", result); + await client.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); + } + + [Fact] + public async Task DocumentationRuleOnUpdateExample_Succeeds() + { + var workItem = ExampleTestData.Instance.WorkItem; + var workItemUpdate = ExampleTestData.Instance.WorkItemUpdateFields; + + client.GetWorkItemAsync(workItem.Id.Value, expand: WorkItemExpand.All).Returns(workItem); + string ruleCode = @" + if (selfChanges.Fields.ContainsKey(""System.Title"")) + { + var titleUpdate = selfChanges.Fields[""System.Title""]; + return $""Title was changed from '{titleUpdate.OldValue}' to '{titleUpdate.NewValue}'""; + } + else + { + return ""Title was not updated""; + } + "; + + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); + string result = await engine.ExecuteAsync(projectId, new WorkItemData(workItem, workItemUpdate), client, CancellationToken.None); + + Assert.Equal("Title was changed from 'Initial Title' to 'Hello'", result); + await client.DidNotReceive().GetWorkItemAsync(Arg.Any(), expand: Arg.Any()); + } } } diff --git a/src/unittests-ruleng/TestData/DeltedWorkItem.json b/src/unittests-ruleng/TestData/DeletedWorkItem.json similarity index 100% rename from src/unittests-ruleng/TestData/DeltedWorkItem.json rename to src/unittests-ruleng/TestData/DeletedWorkItem.json diff --git a/src/unittests-ruleng/TestData/ExampleTestData.cs b/src/unittests-ruleng/TestData/ExampleTestData.cs index 4663c91b..cda2b6ab 100644 --- a/src/unittests-ruleng/TestData/ExampleTestData.cs +++ b/src/unittests-ruleng/TestData/ExampleTestData.cs @@ -17,8 +17,10 @@ class ExampleTestData { public static ExampleTestData Instance => new ExampleTestData(); - public WorkItem DeltedWorkItem => GetFromResource("DeltedWorkItem.json"); - public WorkItem WorkItem => GetFromResource("WorkItem.22.json"); + public WorkItem DeltedWorkItem => GetFromResource("DeletedWorkItem.json"); + public WorkItem WorkItem => GetFromResource("WorkItem.22.json"); + public WorkItemUpdate WorkItemUpdateFields => GetFromResource("WorkItem.22.UpdateFields.json"); + public WorkItemUpdate WorkItemUpdateLinks => GetFromResource("WorkItem.22.UpdateLinks.json"); private static string GetEmbeddedResourceContent(string resourceName) @@ -39,10 +41,10 @@ private static string GetEmbeddedResourceContent(string resourceName) return fileContent; } - private static WorkItem GetFromResource(string resourceName) + private static T GetFromResource(string resourceName) { var json = GetEmbeddedResourceContent(resourceName); - return JsonConvert.DeserializeObject(json); + return JsonConvert.DeserializeObject(json); } } } diff --git a/src/unittests-ruleng/TestData/TestEvent.json b/src/unittests-ruleng/TestData/TestEvent.json new file mode 100644 index 00000000..bc4753d6 --- /dev/null +++ b/src/unittests-ruleng/TestData/TestEvent.json @@ -0,0 +1,133 @@ +{ + "id": "27646e0e-b520-4d2b-9411-bba7524947cd", + "eventType": "workitem.updated", + "publisherId": "tfs", + "message": null, + "detailedMessage": null, + "resource": { + "id": 2, + "workItemId": 0, + "rev": 2, + "revisedBy": { + "id": "e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "displayName": "Jamal Hartnett", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "_links": { + "avatar": { + "href": "https://dev.azure.com/mseng/_apis/GraphProfile/MemberAvatars/aad.YTkzODFkODYtNTYxYS03ZDdiLWJjM2QtZDUzMjllMjM5OTAz" + } + }, + "uniqueName": "Jamal Hartnett", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "descriptor": "ukn.VXkweExUVXRNakV0TWpFME5qYzNNekE0TlMwNU1ETXpOak15T0RVdE56RTVNelEwTnpBM0xURXpPRGswTlRN" + }, + "revisedDate": "0001-01-01T00:00:00", + "fields": { + "System.Rev": { + "oldValue": "1", + "newValue": "2" + }, + "System.AuthorizedDate": { + "oldValue": "2014-07-15T16:48:44.663Z", + "newValue": "2014-07-15T17:42:44.663Z" + }, + "System.RevisedDate": { + "oldValue": "2014-07-15T17:42:44.663Z", + "newValue": "9999-01-01T00:00:00Z" + }, + "System.State": { + "oldValue": "New", + "newValue": "Approved" + }, + "System.Reason": { + "oldValue": "New defect reported", + "newValue": "Approved by the Product Owner" + }, + "System.AssignedTo": { + "oldValue": "unassigned", + "newValue": "Jamal Hartnett" + }, + "System.ChangedDate": { + "oldValue": "2014-07-15T16:48:44.663Z", + "newValue": "2014-07-15T17:42:44.663Z" + }, + "System.Watermark": { + "oldValue": "2", + "newValue": "5" + }, + "Microsoft.VSTS.Common.Severity": { + "oldValue": "3 - Medium", + "newValue": "2 - High" + } + }, + "_links": { + "self": { + "href": "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/wit/workItems/5/updates/2" + }, + "parent": { + "href": "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/wit/workItems/5" + }, + "workItemUpdates": { + "href": "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/wit/workItems/5/updates" + } + }, + "url": "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/wit/workItems/5/updates/2", + "revision": { + "id": 5, + "rev": 2, + "fields": { + "System.AreaPath": "FabrikamCloud", + "System.TeamProject": "FabrikamCloud", + "System.IterationPath": "FabrikamCloud\\Release 1\\Sprint 1", + "System.WorkItemType": "Bug", + "System.State": "New", + "System.Reason": "New defect reported", + "System.CreatedDate": "2014-07-15T16:48:44.663Z", + "System.CreatedBy": { + "displayName": "Jamal Hartnett", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "_links": { + "avatar": { + "href": "https://dev.azure.com/mseng/_apis/GraphProfile/MemberAvatars/aad.YTkzODFkODYtNTYxYS03ZDdiLWJjM2QtZDUzMjllMjM5OTAz" + } + }, + "id": "e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "uniqueName": "Jamal Hartnett", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "descriptor": "ukn.VXkweExUVXRNakV0TWpFME5qYzNNekE0TlMwNU1ETXpOak15T0RVdE56RTVNelEwTnpBM0xURXpPRGswTlRN" + }, + "System.ChangedDate": "2014-07-15T16:48:44.663Z", + "System.ChangedBy": { + "displayName": "Jamal Hartnett", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "_links": { + "avatar": { + "href": "https://dev.azure.com/mseng/_apis/GraphProfile/MemberAvatars/aad.YTkzODFkODYtNTYxYS03ZDdiLWJjM2QtZDUzMjllMjM5OTAz" + } + }, + "id": "e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "uniqueName": "Jamal Hartnett", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=e5a5f7f8-6507-4c34-b397-6c4818e002f4", + "descriptor": "ukn.VXkweExUVXRNakV0TWpFME5qYzNNekE0TlMwNU1ETXpOak15T0RVdE56RTVNelEwTnpBM0xURXpPRGswTlRN" + }, + "System.Title": "Some great new idea!", + "Microsoft.VSTS.Common.Severity": "3 - Medium", + "WEF_EB329F44FE5F4A94ACB1DA153FDF38BA_Kanban.Column": "New" + }, + "url": "http://fabrikam-fiber-inc.visualstudio.com/DefaultCollection/_apis/wit/workItems/5/revisions/2" + } + }, + "resourceVersion": "1.0", + "resourceContainers": { + "collection": { + "id": "c12d0eb8-e382-443b-9f9c-c52cba5014c2" + }, + "account": { + "id": "f844ec47-a9db-4511-8281-8b63f4eaf94e" + }, + "project": { + "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f" + } + }, + "createdDate": "2019-08-07T21:59:21.541Z" +} \ No newline at end of file diff --git a/src/unittests-ruleng/TestData/WorkItem.22.UpdateFields.json b/src/unittests-ruleng/TestData/WorkItem.22.UpdateFields.json new file mode 100644 index 00000000..1a6aa079 --- /dev/null +++ b/src/unittests-ruleng/TestData/WorkItem.22.UpdateFields.json @@ -0,0 +1,55 @@ +{ + "id": 3, + "workItemId": 22, + "rev": 2, + "revisedBy": { + "id": "afc5e4ef-7441-45d8-bb56-c2eaa2e88801", + "name": "a@b.com", + "displayName": "User 1", + "url": "https://dev.azure.com/fake-organization/_apis/Identities/afc5e4ef-7441-45d8-bb56-c2eaa2e88801", + "uniqueName": "domain\\user1", + "imageUrl": "https://dev.azure.com/fake-organization/_apis/_common/identityImage?id=afc5e4ef-7441-45d8-bb56-c2eaa2e88801" + }, + "revisedDate": "9999-01-01T00:00:00Z", + "fields": { + "System.Rev": { + "oldValue": 1, + "newValue": 2 + }, + "System.AuthorizedDate": { + "oldValue": "2019-06-13T13:37:44.627Z", + "newValue": "2019-07-11T11:59:55.78Z" + }, + "System.RevisedDate": { + "oldValue": "2019-07-11T11:59:55.78Z", + "newValue": "9999-01-01T00:00:00Z" + }, + "System.ChangedDate": { + "oldValue": "2019-06-13T13:37:44.627Z", + "newValue": "2019-07-11T11:59:55.78Z" + }, + "System.Watermark": { + "oldValue": 25486155, + "newValue": 25486539 + }, + "System.Title": { + "oldValue": "Initial Title", + "newValue": "Hello" + }, + "System.History": { + "newValue": "Added new Comment" + } + }, + "_links": { + "self": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/22/updates/3" + }, + "workItemUpdates": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/22/updates" + }, + "parent": { + "href": "https://dev.azure.com/fake-organization/_apis/wit/workItems/22" + } + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/22/updates/3" +} \ No newline at end of file diff --git a/src/unittests-ruleng/TestData/WorkItem.22.UpdateLinks.json b/src/unittests-ruleng/TestData/WorkItem.22.UpdateLinks.json new file mode 100644 index 00000000..5df22ea7 --- /dev/null +++ b/src/unittests-ruleng/TestData/WorkItem.22.UpdateLinks.json @@ -0,0 +1,27 @@ +{ + "id": 2, + "workItemId": 22, + "rev": 1, + "revisedBy": { + "id": "afc5e4ef-7441-45d8-bb56-c2eaa2e88801", + "name": "a@b.com", + "displayName": "User 1", + "url": "https://dev.azure.com/fake-organization/_apis/Identities/afc5e4ef-7441-45d8-bb56-c2eaa2e88801", + "uniqueName": "domain\\user1", + "imageUrl": "https://dev.azure.com/fake-organization/_apis/_common/identityImage?id=afc5e4ef-7441-45d8-bb56-c2eaa2e88801" + }, + "revisedDate": "2019-07-11T10:54:40.943Z", + "relations": { + "added": [ + { + "rel": "System.LinkTypes.Related", + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/40", + "attributes": { + "isLocked": false, + "comment": "a comment" + } + } + ] + }, + "url": "https://dev.azure.com/fake-organization/_apis/wit/workItems/22/updates/2" +} \ No newline at end of file diff --git a/src/unittests-ruleng/unittests-ruleng.csproj b/src/unittests-ruleng/unittests-ruleng.csproj index c1c8e633..a8e23f12 100644 --- a/src/unittests-ruleng/unittests-ruleng.csproj +++ b/src/unittests-ruleng/unittests-ruleng.csproj @@ -10,8 +10,10 @@ - + + + @@ -27,8 +29,10 @@ + - + +