diff --git a/src/WorkItemMigrator/WorkItemImport/BoardColumnCollector.cs b/src/WorkItemMigrator/WorkItemImport/BoardColumnCollector.cs new file mode 100644 index 00000000..6655fbd2 --- /dev/null +++ b/src/WorkItemMigrator/WorkItemImport/BoardColumnCollector.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Migration.WIContract; + +namespace WorkItemImport +{ + /// + /// Implementations of this interface will track the value of a field over multiple revisions for each item. They can + /// then be queried for the 'current' value of the field as per the latest revision they have seen the a field in. + /// + /// The type used when storing and retrieving the field's value. + public interface IFieldCollector + { + /// + /// Collects the field values in a similar way to but also tests . For non-final + /// revisions, the collected fields are also removed from the Fields array. For final revisions, adds or updates + /// the Field to contain the latest value of each collected field (whether collected from this revision or a previous one). + /// Usually implemented as a combination of and + /// + /// The execution item containing the revision to process. + /// It is acceptable for a revision to not contain a value for the field(s) of interest. + void ProcessFields(ExecutionPlan.ExecutionItem executionItem); + + /// + /// Return the 'current' value collected for the field(s) as per the latest revision of the given work item (that specified a value for this field). + /// + /// + /// + T GetCurrentValue(string workItemId); + + /// + /// Collect field value(s) from the given revision, updating the internal collection with the latest value(s). + /// The revision provided in each call is considered to overwrite value(s) collected during previous calls. + /// This method does not modify the revision - unlike . + /// + /// The work item revision to collect field value(s) from. + /// It is acceptable for a revision to not contain a value for the field(s) of interest. + void CollectValues(WiRevision revision); + } + + /// + /// Collects System.BoardColumn values from each revision in order to provide the final value for the last revision. + /// Expected usage is to call ProcessFields on each revision, which will cause BoardColumn to be removed from all revisions except + /// the last, and the final value to be updated/inserted into the BoardColumn field of the final revision. + /// + public class BoardColumnCollector : IFieldCollector + { + private readonly Dictionary _collection = new Dictionary(); + + /// + public void CollectValues(WiRevision revision) + { + var boardColumn = revision?.Fields?.FirstOrDefault(f => f.ReferenceName == WiFieldReference.BoardColumn)?.Value as string; + if (!string.IsNullOrEmpty(boardColumn)) + { + _collection[revision.ParentOriginId] = boardColumn; + } + } + + /// + public string GetCurrentValue(string workItemId) + { + return _collection.TryGetValue(workItemId, out var value) ? value : null; + } + + /// + /// Collects the BoardColumn value and removes the field. Except in the final revision the field is explicitly inserted with the latest value. + /// + /// The execution item containing the revision to process. + /// + public void ProcessFields(ExecutionPlan.ExecutionItem executionItem) + { + CollectValues(executionItem.Revision); + if (executionItem.IsFinal) + { + var boardColumnValue = GetCurrentValue(executionItem.OriginId); + if (!string.IsNullOrWhiteSpace(boardColumnValue)) + { + executionItem.Revision.Fields.RemoveAll(i => i.ReferenceName == WiFieldReference.BoardColumn); + executionItem.Revision.Fields.Add(new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = boardColumnValue }); + } + } + else + { + executionItem.Revision.Fields.RemoveAll(f => f.ReferenceName == WiFieldReference.BoardColumn); + } + } + } +} diff --git a/src/WorkItemMigrator/WorkItemImport/ExecutionPlan.cs b/src/WorkItemMigrator/WorkItemImport/ExecutionPlan.cs index 43b2b7c6..001c7f57 100644 --- a/src/WorkItemMigrator/WorkItemImport/ExecutionPlan.cs +++ b/src/WorkItemMigrator/WorkItemImport/ExecutionPlan.cs @@ -12,6 +12,8 @@ public class ExecutionItem public int WiId { get; set; } = -1; public WiRevision Revision { get; set; } public string WiType { get; internal set; } + /// Is this the final revision for the work item? + public bool IsFinal { get; set; } = false; public override string ToString() { @@ -34,7 +36,7 @@ private ExecutionItem TransformToExecutionItem(RevisionReference revRef) var item = _context.GetItem(revRef.OriginId); var rev = item.Revisions[revRef.RevIndex]; rev.Time = revRef.Time; - return new ExecutionItem() { OriginId = item.OriginId, WiId = item.WiId, WiType = item.Type, Revision = rev }; + return new ExecutionItem() { OriginId = item.OriginId, WiId = item.WiId, WiType = item.Type, Revision = rev, IsFinal = revRef.IsFinal }; } public bool TryPop(out ExecutionItem nextItem) diff --git a/src/WorkItemMigrator/WorkItemImport/ExecutionPlanBuilder.cs b/src/WorkItemMigrator/WorkItemImport/ExecutionPlanBuilder.cs index dee862dd..dc2bc9eb 100644 --- a/src/WorkItemMigrator/WorkItemImport/ExecutionPlanBuilder.cs +++ b/src/WorkItemMigrator/WorkItemImport/ExecutionPlanBuilder.cs @@ -34,11 +34,19 @@ private IEnumerable BuildExecutionPlanFromDir() foreach (var wi in _context.EnumerateAllItems()) { Logger.Log(LogLevel.Debug, $"Analyzing item '{wi.OriginId}'."); + RevisionReference lastRevision = null; foreach (var rev in wi.Revisions) { var revRef = new RevisionReference() { OriginId = wi.OriginId, RevIndex = rev.Index, Time = rev.Time }; + lastRevision = revRef; actionPlan.Add(revRef); } + + if (lastRevision != null) + { + // mark the last revision as final. + lastRevision.IsFinal = true; + } } actionPlan.Sort(); diff --git a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs index f5833cb6..ced435f9 100644 --- a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs +++ b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs @@ -106,6 +106,7 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt var executionBuilder = new ExecutionPlanBuilder(context); var plan = executionBuilder.BuildExecutionPlan(); + var boardColumnCollector = new BoardColumnCollector(); itemCount = plan.ReferenceQueue.AsEnumerable().Select(x => x.OriginId).Distinct().Count(); revisionCount = plan.ReferenceQueue.Count; @@ -132,6 +133,8 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt importedItems++; + boardColumnCollector.ProcessFields(executionItem); + if (config.IgnoreEmptyRevisions && executionItem.Revision.Fields.Count == 0 && executionItem.Revision.Links.Count == 0 && @@ -141,9 +144,9 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt continue; } - agent.ImportRevision(executionItem.Revision, wi, settings); + agent.ImportRevision(executionItem.Revision, wi, settings, executionItem.IsFinal); - // Artifical wait (optional) to avoid throttling for ADO Services + // Artificial wait (optional) to avoid throttling for ADO Services if (config.SleepTimeBetweenRevisionImportMilliseconds > 0) { Thread.Sleep(config.SleepTimeBetweenRevisionImportMilliseconds); @@ -184,6 +187,7 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt return succeeded; } + private static void BeginSession(string configFile, ConfigJson config, bool force, Agent agent, int itemsCount, int revisionCount) { var toolVersion = VersionInfo.GetVersionInfo(); diff --git a/src/WorkItemMigrator/WorkItemImport/RevisionReference.cs b/src/WorkItemMigrator/WorkItemImport/RevisionReference.cs index 112728ac..10c4f5ad 100644 --- a/src/WorkItemMigrator/WorkItemImport/RevisionReference.cs +++ b/src/WorkItemMigrator/WorkItemImport/RevisionReference.cs @@ -7,6 +7,8 @@ public sealed class RevisionReference : IComparable, IEquatab public string OriginId { get; set; } public int RevIndex { get; set; } public DateTime Time { get; set; } + /// Is this the final/latest revision for the work item? + public bool IsFinal { get; set; } = false; public int CompareTo(RevisionReference other) { @@ -21,7 +23,7 @@ public int CompareTo(RevisionReference other) public bool Equals(RevisionReference other) { - return OriginId.Equals(other.OriginId, StringComparison.InvariantCultureIgnoreCase) && RevIndex == other.RevIndex; + return OriginId.Equals(other?.OriginId, StringComparison.InvariantCultureIgnoreCase) && RevIndex == other?.RevIndex; } public override bool Equals(object obj) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index 219ca303..ace98264 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -1,8 +1,10 @@ -using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.TeamFoundation.Work.WebApi; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.WebApi; using Microsoft.VisualStudio.Services.WebApi.Patch; using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using Migration.Common; +using Migration.Common.Config; using Migration.Common.Log; using Migration.WIContract; using System; @@ -625,20 +627,44 @@ public void SaveWorkItemFields(WorkItem wi, Settings settings) { throw new ArgumentException(nameof(wi)); } + + if (wi.Fields.TryGetValue(WiFieldReference.BoardColumn, out var value)) + { + wi.Fields.Remove(WiFieldReference.BoardColumn); // the API refuses to update this field directly + var itemType = wi.Fields[WiFieldReference.WorkItemType]?.ToString(); // used for logging + var kanbanField = wi.Fields.Keys.FirstOrDefault(k => k.EndsWith("_Kanban.Column")); // kanban field is the real board-column field + + if (wi.Rev == 0) + { + Logger.Log(LogLevel.Warning, $"Work Item {wi.Id}, rev {wi.Rev} - BoardColumn can not be set to '{value}' because " + + $"items with only one revision are not supported by this feature."); + } + else if (string.IsNullOrWhiteSpace(kanbanField)) + { + Logger.Log(LogLevel.Warning, $"Work Item {wi.Id}, rev {wi.Rev} - BoardColumn can not be set to '{value}' because " + + $"items of type '{itemType}' are not supported by this feature."); + } + else + { + Logger.Log(LogLevel.Debug, $"Work Item {wi.Id}, rev {wi.Rev} - Setting BoardColumn to '{value}'."); + wi.Fields[kanbanField] = value; + } + } // Build json patch document from fields JsonPatchDocument patchDocument = new JsonPatchDocument(); foreach (string key in wi.Fields.Keys) { if (new string[] { - WiFieldReference.BoardColumn, WiFieldReference.BoardColumnDone, WiFieldReference.BoardLane, }.Contains(key)) + { + Logger.Log(LogLevel.Debug, $"Work Item {wi.Id} importing field {key} is not supported."); continue; + } object val = wi.Fields[key]; - if (val == null || val.ToString() == "") { patchDocument.Add( diff --git a/src/WorkItemMigrator/WorkItemImport/wi-import.csproj b/src/WorkItemMigrator/WorkItemImport/wi-import.csproj index c11577de..f099b797 100644 --- a/src/WorkItemMigrator/WorkItemImport/wi-import.csproj +++ b/src/WorkItemMigrator/WorkItemImport/wi-import.csproj @@ -178,6 +178,7 @@ + diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/BoardColumnCollectorTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/BoardColumnCollectorTests.cs new file mode 100644 index 00000000..de81ab7e --- /dev/null +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/BoardColumnCollectorTests.cs @@ -0,0 +1,269 @@ +using Migration.WIContract; +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using WorkItemImport; + + +namespace Migration.Wi_Import.Tests +{ + [TestFixture] + public class BoardColumnCollectorTests + { + private BoardColumnCollector _boardColumnCollector; + + [SetUp] + public void Setup() + { + _boardColumnCollector = new BoardColumnCollector(); + } + + [Test] + public void ProcessFields_WithFinalRevision_AddsLatestBoardColumnValueToRevisionFields() + { + // Arrange + var executionItem1 = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" } + } + }, + OriginId = "1", + IsFinal = false + }; + var executionItem2 = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "Second Value" } + } + }, + OriginId = "1", + IsFinal = true + }; + + // Act + _boardColumnCollector.ProcessFields(executionItem1); + _boardColumnCollector.ProcessFields(executionItem2); + + // Assert + var boardColumnField = executionItem2.Revision.Fields.FirstOrDefault(f => f.ReferenceName == WiFieldReference.BoardColumn); + Assert.IsNotNull(boardColumnField); + Assert.AreEqual("Second Value", boardColumnField.Value); + } + + [Test] + public void ProcessFields_WithNonFinalRevision_DoesNotAddBoardColumnFieldFromRevisionFields() + { + // Arrange + var executionItem = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.Title, Value = "Test Title" }, + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" } + }, + }, + OriginId = "1", + IsFinal = false + }; + + // Act + _boardColumnCollector.ProcessFields(executionItem); + + // Assert + var boardColumnField = executionItem.Revision.Fields.FirstOrDefault(f => f.ReferenceName == WiFieldReference.BoardColumn); + Assert.IsNull(boardColumnField); + + } + + [Test] + public void ProcessFields_MultipleCallsWithDifferentBoardColumnValues_ReturnsCorrectCurrentValue() + { + // Arrange + var firstValue = "ValueOne"; + var secondValue = "ValueTwo"; + + var collector = new BoardColumnCollector(); + var executionItem1 = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = firstValue } + } + }, + IsFinal = false, + OriginId = "1" + }; + var executionItem2 = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = secondValue } + } + }, + IsFinal = false, + OriginId = "1" + }; + + // Act + collector.ProcessFields(executionItem1); + collector.ProcessFields(executionItem2); + var latestValue = collector.GetCurrentValue("1"); + + // Assert + Assert.AreEqual(secondValue, latestValue); + + } + + [Test] + public void ProcessFields_FinalRevisionDoesNotHaveBoardColumn_RetainsPreviousValueAndSetsInTheFinalRevision() + { + // Arrange + var executionItem1 = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" }, + new WiField { ReferenceName = WiFieldReference.Title, Value = "First Item" } + } + }, + OriginId = "1", + IsFinal = false + }; + var executionItemWithoutBoardColumn = new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.Title, Value = "Second Item" } + } + }, + IsFinal = true, + OriginId = "1" + }; + + // Act + _boardColumnCollector.ProcessFields(executionItem1); + _boardColumnCollector.ProcessFields(executionItemWithoutBoardColumn); + + // Assert + var boardColumnField = executionItem1.Revision.Fields.FirstOrDefault(f => f.ReferenceName == WiFieldReference.BoardColumn); + Assert.IsNull(boardColumnField); + boardColumnField = executionItemWithoutBoardColumn.Revision.Fields.FirstOrDefault(f => f.ReferenceName == WiFieldReference.BoardColumn); + Assert.IsNotNull(boardColumnField); + } + + [Test] + public void GetCurrentValue_WithExistingWorkItemId_ReturnsLatestBoardColumnValue() + { + // Arrange + _boardColumnCollector.ProcessFields(new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" } + } + }, + OriginId = "1", + IsFinal = true + }); + + // Act + var currentValue = _boardColumnCollector.GetCurrentValue("1"); + + // Assert + Assert.AreEqual("To Do", currentValue); + } + + [Test] + public void GetCurrentValue_WithNonExistingWorkItemId_ReturnsNull() + { + // Arrange + _boardColumnCollector.ProcessFields(new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" } + } + }, + OriginId = "1", + IsFinal = true + }); + + // Act + var currentValue = _boardColumnCollector.GetCurrentValue("2"); + + // Assert + Assert.IsNull(currentValue); + } + + [Test] + public void GetCurrentValue_WithWorkItemThatHasNoBoardColumnValue_ReturnsNull() + { + // Arrange + _boardColumnCollector.ProcessFields(new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "1", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.BoardColumn, Value = "To Do" } + } + }, + OriginId = "1", + IsFinal = true + }); + _boardColumnCollector.ProcessFields(new ExecutionPlan.ExecutionItem + { + Revision = new WiRevision + { + ParentOriginId = "2", + Fields = new List + { + new WiField { ReferenceName = WiFieldReference.Title, Value = "To Do" } + } + }, + OriginId = "2", + IsFinal = true + }); + + // Act + var currentValue = _boardColumnCollector.GetCurrentValue("2"); + + // Assert + Assert.IsNull(currentValue); + } + } +} + +