Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Added support for migration of comments, links to git repo, and mapping of work item types. #10

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
199 changes: 199 additions & 0 deletions TFSProjectMigration/CommentMigrator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.TeamFoundation.Client;
using Microsoft.TeamFoundation.WorkItemTracking.Client;

namespace TFSProjectMigration
{
class CommentMigrator
{
private readonly Project _destinationProject;
private readonly bool _areVersionHistoryCommentsIncluded;
private readonly bool _shouldWorkItemsBeLinkedToGitCommits;
private readonly GitRepoIntegration _gitRepoIntegration;
private Dictionary<string, GitRepoIntegration.CommitInfo> _commitsByChangeset;

const int MaxHistoryLength = 1048576;

private enum RevisionMigrateAction
{
Skip,
MigrateComment,
MigrateLink
}

public CommentMigrator(TfsTeamProjectCollection tfs, Project destinationProject, bool areVersionHistoryCommentsIncluded, bool shouldWorkItemsBeLinkedToGitCommits)
{
_destinationProject = destinationProject;
_areVersionHistoryCommentsIncluded = areVersionHistoryCommentsIncluded;
_shouldWorkItemsBeLinkedToGitCommits = shouldWorkItemsBeLinkedToGitCommits;
if (tfs != null)
_gitRepoIntegration = new GitRepoIntegration(tfs);
}

private string getCommitUri(string changeset)
{
if (_commitsByChangeset == null)
initCommitsByChangeset();

if (_commitsByChangeset.ContainsKey(changeset))
{
return CreateCommitUriFromCommit(_commitsByChangeset[changeset]);
}
return null;

string CreateCommitUriFromCommit(GitRepoIntegration.CommitInfo commit)
{
return $"vstfs:///Git/Commit/{_destinationProject.Guid}%2F{commit.RepoId}%2F{commit.CommitId}";
}
}

private void initCommitsByChangeset()
{
_commitsByChangeset = new Dictionary<string, GitRepoIntegration.CommitInfo>();

foreach (var commitInfo in _gitRepoIntegration.GetCommits())
{
var changeset = GetChangesetFromComment(commitInfo.Comment);
if (!string.IsNullOrWhiteSpace(changeset) && !_commitsByChangeset.ContainsKey(changeset))
_commitsByChangeset.Add(changeset, commitInfo);
}

string GetChangesetFromComment(string comment)
{
const string gitTfsId = "git-tfs-id:";
var gitTfsIdIndex = comment.IndexOf(gitTfsId, StringComparison.Ordinal);
if (gitTfsIdIndex < 0)
return null;
var changeset = comment.Substring(gitTfsIdIndex + gitTfsId.Length).Split(';').Last();

if (string.IsNullOrEmpty(changeset) || changeset.Length < 2)
return null;

changeset = changeset.Substring(1);

if (new Regex(@"^\d+$").IsMatch(changeset))
return changeset;

return null;
}
}

public void MigrateComments(WorkItem sourceWorkItem, WorkItem targetWorkItem)
{
if (!_areVersionHistoryCommentsIncluded && !_shouldWorkItemsBeLinkedToGitCommits)
return;

var migratedLinks = new List<string>();

Debug.WriteLine("Work Item: " + sourceWorkItem.Id);

foreach (Revision revision in sourceWorkItem.Revisions)
{
switch (ShouldRevisionBeMigrated(revision))
{
case RevisionMigrateAction.MigrateComment:
var history = ExtractHistoryFromRevision(revision);

if (history.Length < MaxHistoryLength)
{
SaveHistory(targetWorkItem, history, revision);
}

break;
case RevisionMigrateAction.MigrateLink:
history = ExtractHistoryFromRevision(revision);
foreach (Link x in revision.Links)
{
if (x is ExternalLink el && !migratedLinks.Contains(el.LinkedArtifactUri))
{
migratedLinks.Add(el.LinkedArtifactUri);
var changeset = el.LinkedArtifactUri.Split('/').Last();
var commitUri = getCommitUri(changeset);
if (!string.IsNullOrWhiteSpace(commitUri))
{
targetWorkItem.Links.Add(new ExternalLink(_destinationProject.Store.RegisteredLinkTypes[3], commitUri));
}
}

}

SaveHistory(targetWorkItem, history, revision);

break;
}
}

//
void SaveHistory(WorkItem workItem, string history, Revision revision)
{
workItem.Fields["Changed By"].Value = revision.Fields["Changed By"].Value;
workItem.History = history;
workItem.Save();
}

string ExtractHistoryFromRevision(Revision revision)
{
var history =
$"{revision.Fields["History"].Value}<br><p><FONT color=#808080 size=1><EM>Changed date: {revision.Fields["Changed Date"].Value}</EM></FONT></p>";
RemoveImagesIfHistoryIsTooLong(ref history);
return history;
}


}

RevisionMigrateAction ShouldRevisionBeMigrated(Revision revision)
{
if (revision.Fields["Changed By"].Value?.ToString() == "_SYSTFSBuild")
return RevisionMigrateAction.Skip;

var history = revision.Fields["History"].Value?.ToString();

if (string.IsNullOrWhiteSpace(history))
return RevisionMigrateAction.Skip;

if (history.StartsWith("Associated with changeset"))
{
if (_shouldWorkItemsBeLinkedToGitCommits)
return RevisionMigrateAction.MigrateLink;
return RevisionMigrateAction.Skip;
}

if (_areVersionHistoryCommentsIncluded)
return RevisionMigrateAction.MigrateComment;
return RevisionMigrateAction.Skip;
}

void RemoveImagesIfHistoryIsTooLong(ref string history)
{
int ImageStart(string s)
{
return s.IndexOf("<img", StringComparison.Ordinal);
}

int ImageEnd(string s, int imageStart)
{
return s.IndexOf(">", imageStart, StringComparison.Ordinal);
}

{
var imageStart = ImageStart(history);

while (history.Length >= MaxHistoryLength && imageStart >= 0)
{
var imageEnd = ImageEnd(history, imageStart);
if (imageEnd < 0)
return;
history = history.Remove(imageStart, imageEnd - imageStart);
history = history.Insert(imageStart, "<p><FONT color=#808080 size=1><EM>(image removed)</EM></FONT></p>");
imageStart = ImageStart(history);
}
}
}

}
}
67 changes: 67 additions & 0 deletions TFSProjectMigration/GitRepoIntegration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.TeamFoundation.Client;
using Microsoft.TeamFoundation.Git.Client;
using Microsoft.TeamFoundation.SourceControl.WebApi;

namespace TFSProjectMigration
{
class GitRepoIntegration
{
private readonly TfsTeamProjectCollection _tfs;
private readonly GitRepositoryService _git;
internal class CommitInfo
{
public Guid RepoId { get; set; }
public string CommitId { get; set; }
public string Comment { get; set; }
}

public GitRepoIntegration(TfsTeamProjectCollection tfs)
{
_tfs = tfs;
_git = new GitRepositoryService();
_git.Initialize(tfs);
}

public IEnumerable<CommitInfo> GetCommits()
{
foreach (var repo in _git.QueryRepositories(string.Empty))
{
var gitClient = _tfs.GetClient<GitHttpClient>();

var repoId = repo.Id;

var criteria = new GitQueryCommitsCriteria();

int skip = 0;
bool more;

do
{
var commitRefs = gitClient.GetCommitsAsync(repoId, criteria, skip).Result;
skip += commitRefs.Count;

foreach (var commitRef in commitRefs)
{
var comment = commitRef.CommentTruncated ? gitClient.GetCommitAsync(commitRef.CommitId, repoId).Result.Comment : commitRef.Comment;

yield return
new CommitInfo
{
RepoId = repoId,
CommitId = commitRef.CommitId,
Comment = comment
};
}

more = commitRefs.Count > 0;
} while (more);

}

}

}
}
70 changes: 61 additions & 9 deletions TFSProjectMigration/TFSWorkItemMigrationUI.xaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Window x:Class="TFSProjectMigration.TFSWorkItemMigrationUI"
<Window x:Class="TFSProjectMigration.TfsWorkItemMigrationUi"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Total TFS Migration Tool" Height="626" Width="691">
Expand Down Expand Up @@ -35,14 +35,24 @@

</WrapPanel>
</GroupBox>
<GroupBox Header="Migration Options" HorizontalAlignment="Left" Margin="10,235,0,0" VerticalAlignment="Top" Width="522" Height="91" BorderBrush="#FFDADADA" Grid.ColumnSpan="2">
<DockPanel HorizontalAlignment="Left" Height="41" LastChildFill="False" VerticalAlignment="Top" Width="501" Margin="10,21,-1,0">
<Label Content="" Height="23" VerticalAlignment="Top" Width="28"/>
<CheckBox x:Name="ClosedTextBox" Content="Exclude 'Closed' items" Height="23" VerticalAlignment="Top" FontWeight="Normal"/>
<Label Content="" Height="23" VerticalAlignment="Top" Width="48"/>
<CheckBox x:Name="RemovedTextBox" Content="Exclude 'Removed' items" Height="23" VerticalAlignment="Bottom" Margin="0,0,0,18" FontWeight="Normal"/>
</DockPanel>

<GroupBox Header="Migration Options" HorizontalAlignment="Left" Margin="10,235,0,0" VerticalAlignment="Top" Width="522" Height="114" BorderBrush="#FFDADADA" Grid.ColumnSpan="2">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="10"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column ="1" Grid.Row="1" x:Name="ClosedTextBox" Content="Exclude 'Closed' items" Height="23" VerticalAlignment="Top" FontWeight="Normal"/>
<CheckBox Grid.Column ="3" Grid.Row="1" x:Name="RemovedTextBox" Content="Exclude 'Removed' items" Height="23" VerticalAlignment="Bottom" Margin="0,0,0,10" FontWeight="Normal"/>
<CheckBox Grid.Column ="1" Grid.Row="2" x:Name="VersionHistoryCheckBox" Content="Include Version History Comments" Height="23" VerticalAlignment="Top" FontWeight="Normal"/>
<CheckBox Grid.Column ="3" Grid.Row="2" Name="LinkToCommitsCheckBox" Content="Link to Git Commits" Height="23" VerticalAlignment="Bottom" Margin="0,0,0,10" FontWeight="Normal"/>
</Grid>
</GroupBox>
<Button x:Name="NextButton" Content="Next" HorizontalAlignment="Left" VerticalAlignment="Top" Width="97" Margin="298,492,0,0" Height="29" Click="NextButton_Click" Grid.Column="1">
<Button.Background>
Expand All @@ -58,6 +68,48 @@

</Grid>
</TabItem>
<TabItem x:Name="ItemMappingTab" Header="Item Mapping" MinHeight="100" Margin="-2,-3,0,3" FontStretch="UltraExpanded" IsEnabled="False" BorderBrush="#FFABD3FD" Foreground="#FF23578D">
<TabItem.Background>
<LinearGradientBrush EndPoint="0,1" StartPoint="0,0">
<GradientStop Color="#FFAED3F9" Offset="0"/>
<GradientStop Color="#FF729FCF" Offset="1"/>
<GradientStop Color="#FF6E9CCD" Offset="1"/>
<GradientStop Color="#FF7EBCFB" Offset="1"/>
<GradientStop Color="#FFADD2F8" Offset="0.007"/>
<GradientStop Color="#FF8BB5E0" Offset="0.65"/>
<GradientStop Color="#FFBBDCFD" Offset="0.215"/>
</LinearGradientBrush>
</TabItem.Background>
<Grid Background="White">
<GroupBox Header="Map Work Items" HorizontalAlignment="Left" Height="223" Margin="32,13,0,0" VerticalAlignment="Top" Width="477" BorderBrush="#FFDADADA">
<WrapPanel HorizontalAlignment="Left" Height="192" Margin="0,10,0,-1" VerticalAlignment="Top" Width="458">
<ComboBox x:Name="SourceItemComboBox" Height="26" Width="154" SelectionChanged="SourceItemComboBox_SelectionChanged"/>
<Label Content=" To" Height="25" Width="41"/>
<ComboBox x:Name="DestItemComboBox" Height="26" Width="154"/>
<Label Content="" Height="24" Width="37"/>
<Button x:Name="MapItemsButton" Content="Map" Height="25" Width="72" Click="MapItemsButton_Click"/>
<Label Content=" " Height="10" Width="456"/>
<DataGrid x:Name="MappedItemListGrid" Height="126" Width="458" AutoGenerateColumns="False" AlternatingRowBackground="#FFD9EFF7" Background="WhiteSmoke" GridLinesVisibility="Vertical" VerticalGridLinesBrush="#FFE4E4E4">
<DataGrid.Columns>
<DataGridTextColumn Width="190" Header="Source Item" Binding="{Binding Key}" IsReadOnly="True" />
<DataGridTextColumn Width="190" Header="Target Item" Binding="{Binding Value}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
<Label Content="" Height="27" Width="382"/>
</WrapPanel>
</GroupBox>
<Button x:Name="NextButtonItemMapping" Content="Next" HorizontalAlignment="Left" VerticalAlignment="Top" Width="97" Margin="412,502,0,0" Height="29" Click="NextButtonItemMapping_Click">
<Button.Background>
<LinearGradientBrush EndPoint="0,1" StartPoint="0,0">
<GradientStop Color="#FFF3F3F3" Offset="0"/>
<GradientStop Color="#FFEBEBEB" Offset="0.5"/>
<GradientStop Color="#FFDDDDDD" Offset="0.5"/>
<GradientStop Color="{DynamicResource {x:Static SystemColors.ActiveBorderColorKey}}" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
</Button>
</Grid>
</TabItem>
<TabItem x:Name="FieldCopyTab" Header="Field Copying" MinHeight="100" Margin="-2,-3,0,3" FontStretch="UltraExpanded" IsEnabled="False" BorderBrush="#FFABD3FD" Foreground="#FF23578D">
<TabItem.Background>
<LinearGradientBrush EndPoint="0,1" StartPoint="0,0">
Expand Down
Loading