Migrate your project from Jira to Azure DevOps

1 Oct

My team recently had the need to migrate two of our project boards from Jira to Azure DevOps (formerly VSTS). There was a whole lot of suggestions when I googled with bing, but not a whole lot of sample code that I could start with. This is completely understandable.

With both systems being highly customizable and the needs of you team being unique, it would be near impossible to come up with a complete solution that will work flawlessly for everyone. So, I decided to provide one. Just kidding.

I hacked together pieces from all over to come up with a solution that worked for my project. It is by no means robust and bulletproof, but it does get the job done and i open for improvement and tailoring. In short, it is a good starting point for anyone needing to do this type of migration.

It is done as a console app without any trappings of a UI. This is a process that is usually executed once and therefore having a UI is not necessary. So, it is designed to be run using the debugger, which has the added benefit of being able to be monitored and paused whenever you want.

I had a few things that I was interested in. This may or may not line up to your requirements.

  • Migrate two JIra projects/boards into a single Azure DevOps project
  • Each Jira project work items would be a configured as a child of a Azure DevOps epic.
  • Jira epics are mapped to features
  • Jira PBIs are mapped to PBIs
  • Jira tasks and sub-tasks are mapped to tasks

You can absolutely go nuts in migrating all the history of PBIs. In that is your case, it might be better to find someone who specialized in this type of migration. In my case, I wanted some limited history. Here is what I was hoping to migrate:

  • Created by and Created date
  • Assigned To
  • work item hierarchy
  • title and description
  • Status (ToDo, done, etc)
  • priority
  • attachments
  • comments
  • tags

You’ll notice that I did not migrate anything to do with sprints. In my case, both Jira projects had a different number of completed sprints and it wasn’t important enough to keep the sprint history to deal with this inconsistency. If you have to need, good luck!

I am using the Azure DevOps Scrum template for my project. It should work for other templates as well, but I have not tested it, so your mileage may vary.

Code

Enough already. Show me the code! Ok, ok.

Nuget Packages

You’ll need 3 nuget packages:


Install-Package Atlassian.SDK
Install-Package Microsoft.VisualStudio.Services.Client
Install-Package Microsoft.TeamFoundationServer.Client

Credentials

You’ll need to configure the connection to Jira and Azure DevOps. The todo block at the top contains some constants for this.

You’ll need an Azure DevOps personal access token. See this for more information about personal access tokens.

You’ll also need a local user account for Jira. Presumably, you could connect using an OpenId account. However, the SDK did not seem to provide an easy way to do this and, in the end, it was easier to create a temporary local admin account.

Field Migrations

Some fields, like title and attachments migrate just fine. Others need a little massaging. For example rich text in Jira uses markdown while rich text in Azure DevOps (at this point) uses HTML. In my case, I decided to punt on converting between markdown and html. It wasn’t worth spending the time and Azure DevOps is likely to support markdown rich text in the future.

Another place that needs massaging is work item statuses. They are close enough that, if you haven’t customized your Azure DevOps status, the provided mapping should work pretty well.

Lastly, username conversions is completely unimplemented. You’ll have to provide your own mapping. In my case, we only had a dozen developers and stakeholders, so I just created a static mapping. If your Jira usernames naturally map to your Azure DevOps (ours didn’t) you could probably just tack on your @contoso.com and call it a day. Unfortunately, our Jira instanced used a completely different AAD tenant than our Azure DevOps organization. There were also some inconsistencies usernames between the two systems.

Idempotency

You’ll notice that the migration keeps and stores a log of everything that has been migrated so far. This accomplishes two things:

  1. An easy way to look up the completed mapping of Jira items to Azure DevOps items. This is essential to keep the Jira hierarchy.
  2. Allow you to resume after an inevitable exception without re-importing everything again. If you do need to start over, simply delete the migrated.json file in the projects root directory.

That’s It

Good luck in your migration! I hope this helps.

 


using Atlassian.Jira;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace JiraMigration
{
    class Program
    {
        // TODO: Provide these
        const string VstsUrl = "https://{AzureDevOps Organization}.visualstudio.com";
        const string VstsPAT = "{AzureDevOps Personal Access Token}";
        const string VstsProject = "{AzureDevOps Project Name}";

        const string JiraUserID = "{Jira local username}";
        const string JiraPassword = "{Jira local password}";
        const string JiraUrl = "{Jira instance url}";
        const string JiraProject = "{Jira Project abbreviation}";
        // END TODO

        // These are to provide the ability to resume a migration if an error occurs.
        //
        static string MigratedPath = Path.Combine(Environment.CurrentDirectory, "..", "..", "migrated.json");
        static Dictionary<string, int> Migrated = File.Exists(MigratedPath) ? JsonConvert.DeserializeObject<Dictionary<string, int>>(File.ReadAllText(MigratedPath)) : new Dictionary<string, int>();

        static void Main(string[] args) => Execute().GetAwaiter().GetResult();
        static async Task Execute()
        {
            var vstsConnection = new VssConnection(new Uri(VstsUrl), new VssBasicCredential(string.Empty, VstsPAT));
            var witClient = vstsConnection.GetClient<WorkItemTrackingHttpClient>();

            var jiraConn = Jira.CreateRestClient(JiraUrl, JiraUserID, JiraPassword);

            var issues = jiraConn.Issues.Queryable
                .Where(p => p.Project == JiraProject)
                .Take(Int32.MaxValue)
                .ToList();

            // By default this will root the migrated items at the root of Vsts project
            // Uncomment ths line and provide an epic id if you want everything to be
            // a child of Vsts epic
            //
            //AddMigrated(JiraProject, {VstsEpic Id});
            foreach (var feature in issues.Where(p => p.Type.Name == "Epic"))
                await CreateFeature(witClient, feature);
            foreach (var bug in issues.Where(p => p.Type.Name == "Bug"))
                await CreateBug(witClient, bug, JiraProject);
            foreach (var backlogItem in issues.Where(p => p.Type.Name == "Story"))
                await CreateBacklogItem(witClient, backlogItem, JiraProject);
            foreach (var task in issues.Where(p => p.Type.Name == "Task" || p.Type.Name == "Sub-task"))
                await CreateTask(witClient, task, JiraProject);
       }

        static Task CreateFeature(WorkItemTrackingHttpClient client, Issue jira) =>
            CreateWorkItem(client, "Feature", jira,
                jira.Project, 
                jira.CustomFields["Epic Name"].Values[0], 
                jira.Description ?? jira.Summary,
                ResolveFeatureState(jira.Status));
        static Task CreateBug(WorkItemTrackingHttpClient client, Issue jira, string defaultParentKey) =>
            CreateWorkItem(client, "Bug", jira,
                jira.CustomFields["Epic Link"]?.Values[0] ?? defaultParentKey,
                jira.Summary,
                jira.Description,
                ResolveBacklogItemState(jira.Status));
        static Task CreateBacklogItem(WorkItemTrackingHttpClient client, Issue jira, string defaultParentKey) =>
            CreateWorkItem(client, "Product Backlog Item", jira,
                jira.CustomFields["Epic Link"]?.Values[0] ?? defaultParentKey,
                jira.Summary,
                jira.Description,
                ResolveBacklogItemState(jira.Status),
                new JsonPatchOperation { Path = "/fields/Microsoft.VSTS.Scheduling.Effort", Value = jira.CustomFields["Story Points"]?.Values[0] });
        static Task CreateTask(WorkItemTrackingHttpClient client, Issue jira, string defaultParentKey) =>
            CreateWorkItem(client, "Task", jira,
                jira.ParentIssueKey ?? defaultParentKey,
                jira.Summary,
                jira.Description,
                ResolveTaskState(jira.Status));
        static async Task CreateWorkItem(WorkItemTrackingHttpClient client, string type, Issue jira, string parentKey, string title, string description, string state, params JsonPatchOperation[] fields)
        {
            // Short-circuit if we've already projcessed this item.
            //
            if (Migrated.ContainsKey(jira.Key.Value)) return;

            var vsts = new JsonPatchDocument
            {
                new JsonPatchOperation { Path = "/fields/System.State", Value = state },
                new JsonPatchOperation { Path = "/fields/System.CreatedBy", Value = ResolveUser(jira.Reporter) },
                new JsonPatchOperation { Path = "/fields/System.CreatedDate", Value = jira.Created.Value.ToUniversalTime() },
                new JsonPatchOperation { Path = "/fields/System.ChangedBy", Value = ResolveUser(jira.Reporter) },
                new JsonPatchOperation { Path = "/fields/System.ChangedDate", Value = jira.Created.Value.ToUniversalTime() },
                new JsonPatchOperation { Path = "/fields/System.Title", Value = title },
                new JsonPatchOperation { Path = "/fields/System.Description", Value = description },
                new JsonPatchOperation { Path = "/fields/Microsoft.VSTS.Common.Priority", Value = ResolvePriority(jira.Priority) }
            };
            if (parentKey != null)
                vsts.Add(new JsonPatchOperation { Path = "/relations/-", Value = new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Reverse", Url = $"https://ciappdev.visualstudio.com/_apis/wit/workItems/{Migrated[parentKey]}" } });
            if (jira.Assignee != null)
                vsts.Add(new JsonPatchOperation { Path = "/fields/System.AssignedTo", Value = ResolveUser(jira.Assignee) });
            if (jira.Labels.Any())
                vsts.Add(new JsonPatchOperation { Path = "/fields/System.Tags", Value = jira.Labels.Aggregate("", (l, r) => $"{l}; {r}").Trim(';', ' ') });
            foreach (var attachment in await jira.GetAttachmentsAsync())
            {
                var bytes = await attachment.DownloadDataAsync();
                using (var stream = new MemoryStream(bytes))
                {
                    var uploaded = await client.CreateAttachmentAsync(stream, VstsProject, fileName: attachment.FileName);
                    vsts.Add(new JsonPatchOperation { Path = "/relations/-", Value = new WorkItemRelation { Rel = "AttachedFile", Url = uploaded.Url } });
                }
            }

            var all = vsts.Concat(fields)
                .Where(p => p.Value != null)
                .ToList();
            vsts = new JsonPatchDocument();
            vsts.AddRange(all);
            var workItem = await client.CreateWorkItemAsync(vsts, VstsProject, type, bypassRules: true);
            AddMigrated(jira.Key.Value, workItem.Id.Value);

            await CreateComments(client, workItem.Id.Value, jira);

            Console.WriteLine($"Added {type}: {jira.Key} {title}");
        }
        static async Task CreateComments(WorkItemTrackingHttpClient client, int id, Issue jira)
        {
            var comments = (await jira.GetCommentsAsync())
                .Select(p => CreateComment(p.Body, p.Author, p.CreatedDate?.ToUniversalTime()))
                .Concat(new[] { CreateComment($"Migrated from {jira.Key}") })
                .ToList();
            foreach (var comment in comments)
                await client.UpdateWorkItemAsync(comment, id, bypassRules: true);
        }
        static JsonPatchDocument CreateComment(string comment, string username = null, DateTime? date = null)
        {
            var patch = new JsonPatchDocument
            {
                new JsonPatchOperation { Path = "/fields/System.History", Value = comment }
            };
            if (username != null)
                patch.Add(new JsonPatchOperation { Path = "/fields/System.ChangedBy", Value = ResolveUser(username) });
            if (date != null)
                patch.Add(new JsonPatchOperation { Path = "/fields/System.ChangedDate", Value = date?.ToUniversalTime() });

            return patch;
        }

        static void AddMigrated(string jira, int vsts)
        {
            if (Migrated.ContainsKey(jira)) return;

            Migrated.Add(jira, vsts);
            File.WriteAllText(MigratedPath, JsonConvert.SerializeObject(Migrated));
        }
        static string ResolveUser(string user)
        {
            // Provide your own user mapping
            //
            switch (user)
            {
                case "anna.banana": return "anna.banana@contoso.com";
                default: throw new ArgumentException("Could not find user", nameof(user));
            }
        }
        static string ResolveFeatureState(IssueStatus state)
        {
            // Customize if your Vsts project uses custom task states.
            //
            switch (state.Name)
            {
                case "Needs Approval": return "New";
                case "Ready for Review": return "In Progress";
                case "Closed": return "Done";
                case "Resolved": return "Done";
                case "Reopened": return "New";
                case "In Progress": return "In Progress";
                case "Backlog": return "New";
                case "Selected for Development": return "New";
                case "Open": return "New";
                case "To Do": return "New";
                case "DONE": return "Done";
                default: throw new ArgumentException("Could not find state", nameof(state));
            }
        }
        static string ResolveBacklogItemState(IssueStatus state)
        {
            // Customize if your Vsts project uses custom task states.
            //
            switch (state.Name)
            {
                case "Needs Approval": return "New";
                case "Ready for Review": return "Committed";
                case "Closed": return "Done";
                case "Resolved": return "Done";
                case "Reopened": return "New";
                case "In Progress": return "Committed";
                case "Backlog": return "New";
                case "Selected for Development": return "Approved";
                case "Open": return "Approved";
                case "To Do": return "New";
                case "DONE": return "Done";
                default: throw new ArgumentException("Could not find state", nameof(state));
            }
        }
        static string ResolveTaskState(IssueStatus state)
        {
            // Customize if your Vsts project uses custom task states.
            //
            switch (state.Name)
            {
                case "Needs Approval": return "To Do";
                case "Ready for Review": return "In Progress";
                case "Closed": return "Done";
                case "Resolved": return "Done";
                case "Reopened": return "To Do";
                case "In Progress": return "In Progress";
                case "Backlog": return "To Do";
                case "Selected for Development": return "To Do";
                case "Open": return "To Do";
                case "To Do": return "To Do";
                case "DONE": return "Done";
                default: throw new ArgumentException("Could not find state", nameof(state));
            }
        }
        static int ResolvePriority(IssuePriority priority)
        {
            switch (priority.Name)
            {
                case "Low-Minimal business impact": return 4;
                case "Medium-Limited business impact": return 3;
                case "High-Significant business impact": return 2;
                case "Urgent- Critical business impact": return 1;
                default: throw new ArgumentException("Could not find priority", nameof(priority));
            }
        }
    }
}

Jason Sherman

Jason is a developer with Avanade’s Azure Cloud Enablement (ACE) team.