From 4fa8c84b560f53cb5c12f907edf30404cddcf703 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 13 Nov 2023 08:39:43 +0100 Subject: [PATCH 01/44] Update config-scrum.json, add MapRendered mapper for ReproSteps --- docs/Samples/config-scrum.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/Samples/config-scrum.json b/docs/Samples/config-scrum.json index 74a57e72..7fc61973 100644 --- a/docs/Samples/config-scrum.json +++ b/docs/Samples/config-scrum.json @@ -227,8 +227,9 @@ { "source": "description", "target": "Microsoft.VSTS.TCM.ReproSteps", - "for": "Bug" + "for": "Bug", + "mapper": "MapRendered" } ] } -} \ No newline at end of file +} From a9cddaec2187c1225b691091a7545c060f1b3660 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 13 Nov 2023 12:44:32 +0100 Subject: [PATCH 02/44] Count imported revisions correctly when IgnoreEmptyRevisions is true --- .../WorkItemImport/ImportCommandLine.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs index cdf9a4b5..388fb67a 100644 --- a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs +++ b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs @@ -119,15 +119,6 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt continue; } - if (config.IgnoreEmptyRevisions && - executionItem.Revision.Fields.Count == 0 && - executionItem.Revision.Links.Count == 0 && - executionItem.Revision.Attachments.Count == 0) - { - Logger.Log(LogLevel.Info, $"Skipped processing empty revision: {executionItem.OriginId}, rev {executionItem.Revision.Index}"); - continue; - } - WorkItem wi = null; if (executionItem.WiId > 0) @@ -137,9 +128,19 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt Logger.Log(LogLevel.Info, $"Processing {importedItems + 1}/{revisionCount} - wi '{(wi.Id > 0 ? wi.Id.ToString() : "Initial revision")}', jira '{executionItem.OriginId}, rev {executionItem.Revision.Index}'."); - agent.ImportRevision(executionItem.Revision, wi, settings); importedItems++; + if (config.IgnoreEmptyRevisions && + executionItem.Revision.Fields.Count == 0 && + executionItem.Revision.Links.Count == 0 && + executionItem.Revision.Attachments.Count == 0) + { + Logger.Log(LogLevel.Info, $"Skipped processing empty revision: {executionItem.OriginId}, rev {executionItem.Revision.Index}"); + continue; + } + + agent.ImportRevision(executionItem.Revision, wi, settings); + // Artifical wait (optional) to avoid throttling for ADO Services if (config.SleepTimeBetweenRevisionImportMilliseconds > 0) { From 4ac81b891be09b9154b674652547f474186ee0f5 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 15 Nov 2023 10:02:48 +0100 Subject: [PATCH 03/44] Update faq.md --- docs/faq.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 94cb851b..bb3fe870 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -51,7 +51,34 @@ Example: } ``` -## 4. How to migrate custom fields having dropdown lists? +## 4. Guideline for migrating multiple projects + +### Scenario 1: Single project + +When migrating a single project, you may have issues with links to other issues that are in other projects or otherwise not captured by the JQL query. + +You can include such linked issues by setting the following property in the configuration file (**NOTE: the include-linked-issues-not-captured-by-query property is not yet implemented as of 2023-11-15**): + +```json + "include-linked-issues-not-captured-by-query": true +``` + +### Scenario 2: Multiple projects + +When migrating multiple project, one after another (or otherwise running several migrations with different queries in a serial fashion), you may get duplicate issues if you have enabled the *include-linked-issues-not-captured-by-query* flag. + +The recommendation is thus to turn this property (**NOTE: the include-linked-issues-not-captured-by-query property is not yet implemented as of 2023-11-15**): + +```json + "include-linked-issues-not-captured-by-query": false +``` + +When running multiple migrations, one after another, we recommend following the below guidelines: + +- Use one separate `workspace` folder per migration. +- For every completed migration, locate the `itemsJournal.txt` file inside your `workspace` folder. Copy this file to the workspace folder of the next migration. Then proceed with the net migration. This will ensure that you do not get duplicates, and any cross-project or cross-query links will be intact. + +## 5. How to migrate custom fields having dropdown lists? - To map a custom field which is an dropdown list you can use MapArray mapper to get in a better way. Also take a look at the other possible [Mappers](config.md#mappers) to use. @@ -68,7 +95,7 @@ Example: } ``` -## 5. How to migrate correct user from Jira to Azure DevOps and assign to the new work items? +## 6. How to migrate correct user from Jira to Azure DevOps and assign to the new work items? - User mapping differs between Jira Cloud and Jira Server. To migrate users and assign the new work items in Azure DevOps to the same user as the original task had in Jira, we need to add a text file in the root that would look something like this: @@ -108,7 +135,7 @@ Example: Jira.User3@some.domain=AzureDevOps.User3@some.domain ``` -## 6. How to migrate the Work Log (Time Spent, Remaining Estimate fields)? +## 7. How to migrate the Work Log (Time Spent, Remaining Estimate fields)? You can migrate the logged and remaining time using the following field mappings. @@ -133,7 +160,7 @@ The history of the **logged time** and **remaining time** will be preserved on e } ``` -## 7. How to map custom userpicker fields +## 8. How to map custom userpicker fields Here is how we have successfully mapped userpicker fields in the past. `source` should be the field name: @@ -146,7 +173,7 @@ Here is how we have successfully mapped userpicker fields in the past. `source` }, ``` -## 8. How to map datetime fields +## 9. How to map datetime fields Here is how we can map datetime fields like ResolvedDate: @@ -158,7 +185,7 @@ Here is how we can map datetime fields like ResolvedDate: } ``` -## 9. How to migrate an issue fields to a comment +## 10. How to migrate an issue fields to a comment Through some manual intervention, we can migrate every historical value of an **issue field** to a **Work Item Comments**. Simply do the following: From 7ebb541533f38a8d78b6dbf21e5f0a9449385580 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Thu, 16 Nov 2023 08:42:11 +0100 Subject: [PATCH 04/44] Update ADO marketplace readme. Fix spelling in README.md --- readme.md | 2 +- src/WorkItemMigrator.Extension/README.md | 34 +++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index b41f802e..6538ff71 100644 --- a/readme.md +++ b/readme.md @@ -86,7 +86,7 @@ Please refer to [Contribution guidelines](docs/CONTRIBUTING.md) and the [Code of ## Supported versions of ADO/Jira -The Jira to Azure DevOps work item migration tool is offically supported on the following platforms: +The Jira to Azure DevOps work item migration tool is officially supported on the following platforms: - Atlassian Jira Cloud - Atlassian Jira Server diff --git a/src/WorkItemMigrator.Extension/README.md b/src/WorkItemMigrator.Extension/README.md index b2ecd4b4..a9f8b4b7 100644 --- a/src/WorkItemMigrator.Extension/README.md +++ b/src/WorkItemMigrator.Extension/README.md @@ -28,12 +28,38 @@ The tools are provided as-is and will require detailed understanding of how to m * Read the article [Jira to VSTS migration: migrating work items](https://solidify.se/blog/jira-to-vsts-migration-work-items) for more context of the process. * Read the article [Jira to Azure DevOps (VSTS or TFS) migration](https://solidify.se/blog/jira-azure-devops-migration) for a complete step-by-step walkthrough on a migration. -# Tested with +## Jira Azure DevOps Migrator PRO -The Jira to Azure DevOps work item migration tool has been tested on the following configurations: +The **Jira Azure DevOps Migrator PRO offering** from Solidify offers more features and utilities to further increase your migration capabilities and streamline the migration workflow. [Contact us for more information](mailto:support.jira-migrator@solidify.dev) + +### Features + +**Jira Azure DevOps Migrator PRO** contains all the features in the **Community Edition**, plus the following additional functionality: + +- Priority support +- Composite field mapper (consolidate multiple Jira fields into a single ADO field) +- Migrate **Releases** and the **fixes version** field +- Migrate **Remote Links** (Web links) to Work Item hyperlinks. +- Correct any **Embedded Links to Jira Issues** in text fields such as Description, Repro Steps and comments, so that they point to the correct Work Item in Azure DevOps. +- Select any property for **object**- and **array**-type fields for mapping. This allows for: + - More possibilities when mapping the **fixes version** and **components** fields. + - Support for mapping **custom user picker** fields. +- Utilities for automating user mapping between Jira and Azure DevOps +- Utilities for automatically generating the Jira Azure DevOps Migrator configuration file, thus enabling you to get started migrating faster +- Utilities for viewing the Jira workflow and assisting with field and state mapping + +## Supported versions of ADO/Jira + +The Jira to Azure DevOps work item migration tool is officially supported on the following platforms: - Atlassian Jira Cloud -- Atlassian Jira Server 7.0.0 +- Atlassian Jira Server + - 7.x + - 8.x + - 9.x - Azure DevOps Services -- Azure DevOps Server ("TFS 2019") +- Azure DevOps Server + - 2022 + - 2020 + - 2019 - Team Foundation Server 2018 update 3 From 47108e1e394dc7c179669400161ec227a31da535 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Thu, 16 Nov 2023 08:43:55 +0100 Subject: [PATCH 05/44] Fix header levels in src/WorkItemMigrator.Extension/README.md --- src/WorkItemMigrator.Extension/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WorkItemMigrator.Extension/README.md b/src/WorkItemMigrator.Extension/README.md index a9f8b4b7..19a631ca 100644 --- a/src/WorkItemMigrator.Extension/README.md +++ b/src/WorkItemMigrator.Extension/README.md @@ -28,11 +28,11 @@ The tools are provided as-is and will require detailed understanding of how to m * Read the article [Jira to VSTS migration: migrating work items](https://solidify.se/blog/jira-to-vsts-migration-work-items) for more context of the process. * Read the article [Jira to Azure DevOps (VSTS or TFS) migration](https://solidify.se/blog/jira-azure-devops-migration) for a complete step-by-step walkthrough on a migration. -## Jira Azure DevOps Migrator PRO +# Jira Azure DevOps Migrator PRO The **Jira Azure DevOps Migrator PRO offering** from Solidify offers more features and utilities to further increase your migration capabilities and streamline the migration workflow. [Contact us for more information](mailto:support.jira-migrator@solidify.dev) -### Features +## Features **Jira Azure DevOps Migrator PRO** contains all the features in the **Community Edition**, plus the following additional functionality: @@ -48,7 +48,7 @@ The **Jira Azure DevOps Migrator PRO offering** from Solidify offers more featur - Utilities for automatically generating the Jira Azure DevOps Migrator configuration file, thus enabling you to get started migrating faster - Utilities for viewing the Jira workflow and assisting with field and state mapping -## Supported versions of ADO/Jira +# Supported versions of ADO/Jira The Jira to Azure DevOps work item migration tool is officially supported on the following platforms: From 2920a416ac3783d5f0d34212d9fb2ababe76525b Mon Sep 17 00:00:00 2001 From: David Bastow Date: Tue, 21 Nov 2023 17:59:48 +0800 Subject: [PATCH 06/44] Add support for OAuth2 token using -t flag --- src/WorkItemMigrator/JiraExport/JiraCommandLine.cs | 7 ++++--- src/WorkItemMigrator/JiraExport/JiraServiceWrapper.cs | 5 +++++ src/WorkItemMigrator/JiraExport/JiraSettings.cs | 5 ++++- .../tests/Migration.Jira-Export.Tests/JiraItemTests.cs | 2 +- .../tests/Migration.Jira-Export.Tests/JiraMapperTests.cs | 2 +- .../tests/Migration.Jira-Export.Tests/JiraRevisionTests.cs | 2 +- .../RevisionUtils/FieldMapperUtilsTests.cs | 2 +- .../RevisionUtils/LinkMapperUtilsTests.cs | 2 +- 8 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs b/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs index 34b6fa27..6b3f25b0 100644 --- a/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs +++ b/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs @@ -37,6 +37,7 @@ private void ConfigureCommandLineParserWithOptions() CommandOption userOption = commandLineApplication.Option("-u ", "Username for authentication", CommandOptionType.SingleValue); CommandOption passwordOption = commandLineApplication.Option("-p ", "Password for authentication", CommandOptionType.SingleValue); + CommandOption tokenOption = commandLineApplication.Option("-t ", "Bearer token for OAuth2 authentication", CommandOptionType.SingleValue); CommandOption urlOption = commandLineApplication.Option("--url ", "Url for the account", CommandOptionType.SingleValue); CommandOption configOption = commandLineApplication.Option("--config ", "Export the work items based on this configuration file", CommandOptionType.SingleValue); CommandOption forceOption = commandLineApplication.Option("--force", "Forces execution from start (instead of continuing from previous run)", CommandOptionType.NoValue); @@ -49,7 +50,7 @@ private void ConfigureCommandLineParserWithOptions() if (configOption.HasValue()) { - succeeded = ExecuteMigration(userOption, passwordOption, urlOption, configOption, forceFresh, continueOnCriticalOption); + succeeded = ExecuteMigration(userOption, passwordOption, tokenOption, urlOption, configOption, forceFresh, continueOnCriticalOption); } else { @@ -60,7 +61,7 @@ private void ConfigureCommandLineParserWithOptions() }); } - private bool ExecuteMigration(CommandOption user, CommandOption password, CommandOption url, CommandOption configFile, bool forceFresh, CommandOption continueOnCritical) + private bool ExecuteMigration(CommandOption user, CommandOption password, CommandOption token, CommandOption url, CommandOption configFile, bool forceFresh, CommandOption continueOnCritical) { var itemsCount = 0; var exportedItemsCount = 0; @@ -82,7 +83,7 @@ private bool ExecuteMigration(CommandOption user, CommandOption password, Comman var downloadOptions = (DownloadOptions)config.DownloadOptions; - var jiraSettings = new JiraSettings(user.Value(), password.Value(), url.Value(), config.SourceProject) + var jiraSettings = new JiraSettings(user.Value(), password.Value(), token.Value(), url.Value(), config.SourceProject) { BatchSize = config.BatchSize, UserMappingFile = config.UserMappingFile != null ? Path.Combine(migrationWorkspace, config.UserMappingFile) : string.Empty, diff --git a/src/WorkItemMigrator/JiraExport/JiraServiceWrapper.cs b/src/WorkItemMigrator/JiraExport/JiraServiceWrapper.cs index 0c2ac7c8..ebde55e8 100644 --- a/src/WorkItemMigrator/JiraExport/JiraServiceWrapper.cs +++ b/src/WorkItemMigrator/JiraExport/JiraServiceWrapper.cs @@ -3,6 +3,7 @@ using JiraExport; using Migration.Common.Log; using RestSharp; +using RestSharp.Authenticators; using System; using System.Collections.Generic; using System.Text; @@ -27,6 +28,10 @@ public JiraServiceWrapper(JiraSettings jiraSettings) _jira = Jira.CreateRestClient(jiraSettings.Url, jiraSettings.UserID, jiraSettings.Pass); _jira.RestClient.RestSharpClient.AddDefaultHeader("X-Atlassian-Token", "no-check"); + + if (!string.IsNullOrWhiteSpace(jiraSettings.Token)) + _jira.RestClient.RestSharpClient.Authenticator = new OAuth2AuthorizationRequestHeaderAuthenticator(jiraSettings.Token, "Bearer"); + if (jiraSettings.UsingJiraCloud) _jira.RestClient.Settings.EnableUserPrivacyMode = true; } diff --git a/src/WorkItemMigrator/JiraExport/JiraSettings.cs b/src/WorkItemMigrator/JiraExport/JiraSettings.cs index 86cdd4fa..2c010d73 100644 --- a/src/WorkItemMigrator/JiraExport/JiraSettings.cs +++ b/src/WorkItemMigrator/JiraExport/JiraSettings.cs @@ -1,5 +1,6 @@  using Migration.Common.Config; +using Newtonsoft.Json.Linq; namespace JiraExport { @@ -7,6 +8,7 @@ public class JiraSettings { public string UserID { get; private set; } public string Pass { get; private set; } + public string Token { get; private set; } public string Url { get; private set; } public string Project { get; set; } public string EpicLinkField { get; set; } @@ -19,10 +21,11 @@ public class JiraSettings public bool IncludeCommits { get; set; } public RepositoryMap RepositoryMap { get; set; } - public JiraSettings(string userID, string pass, string url, string project) + public JiraSettings(string userID, string pass, string token, string url, string project) { UserID = userID; Pass = pass; + Token = token; Url = url; Project = project; } diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs index 544757e1..61494314 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs @@ -668,7 +668,7 @@ public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_rev private JiraSettings createJiraSettings() { - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); settings.EpicLinkField = "EpicLinkField"; settings.SprintField = "SprintField"; diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs index d9f768f8..479e431e 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs @@ -232,7 +232,7 @@ public void When_calling_initializefieldmappings_Then_the_expected_result_is_ret private JiraSettings createJiraSettings() { - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); settings.EpicLinkField = "Epic Link"; settings.SprintField = "SprintField"; diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraRevisionTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraRevisionTests.cs index d33aaf91..649e4ab2 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraRevisionTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraRevisionTests.cs @@ -73,7 +73,7 @@ private JiraItem createJiraItem() private JiraSettings createJiraSettings() { - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); settings.EpicLinkField = "EpicLinkField"; settings.SprintField = "SprintField"; diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/FieldMapperUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/FieldMapperUtilsTests.cs index 834785da..1c781335 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/FieldMapperUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/FieldMapperUtilsTests.cs @@ -32,7 +32,7 @@ private JiraRevision MockRevisionWithParentItem(string issueKey, string revision }; provider.DownloadIssue(default).ReturnsForAnyArgs(remoteIssue); - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); settings.SprintField = "SprintField"; provider.GetSettings().ReturnsForAnyArgs(settings); diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/LinkMapperUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/LinkMapperUtilsTests.cs index 8ac2abaf..f7abeba2 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/LinkMapperUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/RevisionUtils/LinkMapperUtilsTests.cs @@ -32,7 +32,7 @@ private JiraRevision MockRevisionWithParentItem(string issueKey, string revision }; provider.DownloadIssue(default).ReturnsForAnyArgs(remoteIssue); - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); settings.SprintField = "SprintField"; provider.GetSettings().ReturnsForAnyArgs(settings); From 0659d9b44b064bbddc950b39d9ba69e3a7b1be4a Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 22 Nov 2023 14:47:01 +0100 Subject: [PATCH 07/44] Update jira-export.md --- docs/jira-export.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/jira-export.md b/docs/jira-export.md index 7e0c235e..d9dd2885 100644 --- a/docs/jira-export.md +++ b/docs/jira-export.md @@ -11,12 +11,21 @@ Usage: jira-export [options] |-? \| -h \| --help|False|Show help information| |-u \|True|Username for authentication| |-p \|True|Password for authentication| +|-t \<>|False|AuOAuth 2.0 token (leave empty |--url \|True|Url of the Jira organization| |--config \|True|Export the work items based on this configuration file| |--force|False|Force execution from start (instead of continuing from previous run)| -## Example +## Examples + +### Usage, authentication with username + password ```bash jira-export -u myUser -p myPassword --url https://myorganization.atlassian.net --config config.json --force ``` + +### Usage, authentication with OAuth2 token + +```bash +jira-export -u myUser -p myPassword -t myToken --url https://myorganization.atlassian.net --config config.json --force +``` From de5cf35320f7116c0f8b2e34d8ae6f06f16733f0 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 22 Nov 2023 14:48:27 +0100 Subject: [PATCH 08/44] Update jira-export.md --- docs/jira-export.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/jira-export.md b/docs/jira-export.md index d9dd2885..5a9b471d 100644 --- a/docs/jira-export.md +++ b/docs/jira-export.md @@ -5,13 +5,13 @@ Work item migration tool that assists with moving Jira items to Azure DevOps or ```txt Usage: jira-export [options] ``` - + authe |Argument|Required|Description| |---|---|---| |-? \| -h \| --help|False|Show help information| |-u \|True|Username for authentication| |-p \|True|Password for authentication| -|-t \<>|False|AuOAuth 2.0 token (leave empty +|-t \|False|OAuth 2.0 token (leave empty unless authenticating with OAuth)| |--url \|True|Url of the Jira organization| |--config \|True|Export the work items based on this configuration file| |--force|False|Force execution from start (instead of continuing from previous run)| From 946c05854316b66bbeb0db49040d227428dd3c6e Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 22 Nov 2023 14:51:50 +0100 Subject: [PATCH 09/44] Update faq.md --- docs/faq.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index bb3fe870..25f5eeb3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -206,3 +206,24 @@ Through some manual intervention, we can migrate every historical value of an ** - `5397700c-5bc3-4efe-b1e9-d626929b89ca` > `System.History` - `e0cd3eb0-d8b7-4e62-ba35-c24d06d7f667` > `System.History` 1. Run `WiImport` as usual. + +## 11. How to omit the Jira issue ID/key in the work item title + +By default, the field mapping for `System.Title` will be set up so that the title is prefixed with the Issue key. This can be prevented by omitting the **MapTitle mapper** from the field map in the configuration: + +```json + { + "source": "summary", + "target": "System.Title" + } +``` + +Instead of the default: + +```json + { + "source": "summary", + "target": "System.Title", + "mapper": "MapTitle" + } +``` From 4a3966068d6ba321df1c4e73d6e2e742ca3a76e7 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Sat, 25 Nov 2023 11:09:28 +0100 Subject: [PATCH 10/44] Update guidelines for migrating multiple projects --- docs/faq.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 25f5eeb3..9bb04268 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -33,12 +33,13 @@ If you are still not able to authenticate. Try and run the tool as another user. - To map a custom field by value we have to add a mapping in the configuration file, using the custom field name: - ``` +```json { "source": "Custom Field Name Jira", "source-type": "name", "target": "Custom.CustomFieldNameADO" }, +``` - Alternatively, we can map the filed kay instead of the name. Inspect the REST API response to find the **field key** for your custom field. This is usually something like **customfield_12345**. @@ -57,22 +58,26 @@ Example: When migrating a single project, you may have issues with links to other issues that are in other projects or otherwise not captured by the JQL query. -You can include such linked issues by setting the following property in the configuration file (**NOTE: the include-linked-issues-not-captured-by-query property is not yet implemented as of 2023-11-15**): +You can include such linked issues (all parents, epic links and sub items) by setting the following property in the configuration file: ```json - "include-linked-issues-not-captured-by-query": true + "download-options": 7 ``` +See for more information on the `download-options` property. + ### Scenario 2: Multiple projects When migrating multiple project, one after another (or otherwise running several migrations with different queries in a serial fashion), you may get duplicate issues if you have enabled the *include-linked-issues-not-captured-by-query* flag. -The recommendation is thus to turn this property (**NOTE: the include-linked-issues-not-captured-by-query property is not yet implemented as of 2023-11-15**): +The recommendation is thus to turn off all linked issues (parents, epic links and sub items) by setting the following property in the configuration file: ```json - "include-linked-issues-not-captured-by-query": false + "download-options": 0 ``` +See for more information on the `download-options` property. + When running multiple migrations, one after another, we recommend following the below guidelines: - Use one separate `workspace` folder per migration. From d684a69ee5fcd76c1b03cac36b8f3b072a7d930f Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 27 Nov 2023 19:27:48 +0100 Subject: [PATCH 11/44] Force remove faulty parent and child links --- .../WitClient/WitClientUtils.cs | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index f89d4dc5..f833e5d4 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -82,10 +82,89 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) } catch (AggregateException ex) { - Logger.Log(LogLevel.Error, ex.Message); + Logger.Log(LogLevel.Warning, ex.Message); foreach (Exception ex2 in ex.InnerExceptions) { - Logger.Log(LogLevel.Error, ex2.Message); + if (ex2.Message.Contains("TF201036: You cannot add a Child link between work items")) + { + Logger.Log(LogLevel.Warning, ex2.Message); + Logger.Log(LogLevel.Warning, "Attempting to fix the above issue by removing the offending link and re-adding the correct link..."); + + var wiTargetCurrent = GetWorkItem(link.TargetWiId); + + bool linkFixed = false; + foreach (var relation in wiTargetCurrent.Relations) + { + if (relation.Rel == "System.LinkTypes.Hierarchy-Reverse") + { + // Remove old link + WiLink linkToRemove = new WiLink(); + linkToRemove.Change = ReferenceChangeType.Removed; + linkToRemove.SourceWiId = wiTargetCurrent.Id.Value; + linkToRemove.TargetWiId = int.Parse(relation.Url.Split('/').Last()); + linkToRemove.WiType = "System.LinkTypes.Hierarchy-Reverse"; + RemoveAndSaveLink(linkToRemove, wiTargetCurrent); + + // Add new link again + var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-Forward" && r.Url.Split('/').Last() == link.TargetWiId.ToString()); + wi.Relations.Remove(matchedRelations.First()); + linkFixed = AddAndSaveLink(link, wi); + break; + } + } + + if (linkFixed) + { + Logger.Log(LogLevel.Warning, "Solved issue with conflicting child links. Continuing happily..."); + } + else + { + Logger.Log(LogLevel.Error, "Could not solve issue with conflicting child links. This revision did" + + " not import successfully. You may see the wrong parent issue when verifying the work items."); + } + } + else if (ex2.Message.Contains("TF201036: You cannot add a Parent link between work items")) + { + Logger.Log(LogLevel.Warning, ex2.Message); + Logger.Log(LogLevel.Warning, "Attempting to fix the above issue by removing the offending link and re-adding the correct link..."); + + var wiSourceCurrent = GetWorkItem(link.SourceWiId); + + bool linkFixed = false; + foreach (var relation in wiSourceCurrent.Relations) + { + if (relation.Rel == "System.LinkTypes.Hierarchy-Reverse") + { + // Remove old link + WiLink linkToRemove = new WiLink(); + linkToRemove.Change = ReferenceChangeType.Removed; + linkToRemove.SourceWiId = wiSourceCurrent.Id.Value; + linkToRemove.TargetWiId = int.Parse(relation.Url.Split('/').Last()); + linkToRemove.WiType = "System.LinkTypes.Hierarchy-Reverse"; + RemoveAndSaveLink(linkToRemove, wiSourceCurrent); + + // Add new link again + var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-Reverse" && r.Url.Split('/').Last() == link.TargetWiId.ToString()); + wi.Relations.Remove(matchedRelations.First()); + linkFixed = AddAndSaveLink(link, wi); + break; + } + } + + if (linkFixed) + { + Logger.Log(LogLevel.Warning, "Solved issue with conflicting parent links. Continuing happily..."); + } + else + { + Logger.Log(LogLevel.Error, "Could not solve issue with conflicting parent links. This revision did" + + " not import successfully. You may see the wrong parent issue when verifying the work items."); + } + } + else + { + Logger.Log(LogLevel.Error, ex2.Message); + } } return false; } From ed2c3cae7066f4bcb91990137825f7f985f069ab Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 27 Nov 2023 19:30:08 +0100 Subject: [PATCH 12/44] Add child link map to sample configs --- docs/Samples/config-agile.json | 4 ++++ docs/Samples/config-basic.json | 4 ++++ docs/Samples/config-scrum.json | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/docs/Samples/config-agile.json b/docs/Samples/config-agile.json index 7fe3ff6f..14d33760 100644 --- a/docs/Samples/config-agile.json +++ b/docs/Samples/config-agile.json @@ -29,6 +29,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" diff --git a/docs/Samples/config-basic.json b/docs/Samples/config-basic.json index 2b0b3295..1c48358c 100644 --- a/docs/Samples/config-basic.json +++ b/docs/Samples/config-basic.json @@ -29,6 +29,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" diff --git a/docs/Samples/config-scrum.json b/docs/Samples/config-scrum.json index 7fc61973..9916752a 100644 --- a/docs/Samples/config-scrum.json +++ b/docs/Samples/config-scrum.json @@ -29,6 +29,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" From c72ac409a5293a9adb9e86acd1ad49cc6c5eec19 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 27 Nov 2023 19:42:01 +0100 Subject: [PATCH 13/44] Refactor link swap functionality to now method: ForceSwapLinkAndSave() --- .../WitClient/WitClientUtils.cs | 109 +++++++----------- 1 file changed, 39 insertions(+), 70 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index f833e5d4..6e7bb837 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -87,79 +87,11 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) { if (ex2.Message.Contains("TF201036: You cannot add a Child link between work items")) { - Logger.Log(LogLevel.Warning, ex2.Message); - Logger.Log(LogLevel.Warning, "Attempting to fix the above issue by removing the offending link and re-adding the correct link..."); - - var wiTargetCurrent = GetWorkItem(link.TargetWiId); - - bool linkFixed = false; - foreach (var relation in wiTargetCurrent.Relations) - { - if (relation.Rel == "System.LinkTypes.Hierarchy-Reverse") - { - // Remove old link - WiLink linkToRemove = new WiLink(); - linkToRemove.Change = ReferenceChangeType.Removed; - linkToRemove.SourceWiId = wiTargetCurrent.Id.Value; - linkToRemove.TargetWiId = int.Parse(relation.Url.Split('/').Last()); - linkToRemove.WiType = "System.LinkTypes.Hierarchy-Reverse"; - RemoveAndSaveLink(linkToRemove, wiTargetCurrent); - - // Add new link again - var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-Forward" && r.Url.Split('/').Last() == link.TargetWiId.ToString()); - wi.Relations.Remove(matchedRelations.First()); - linkFixed = AddAndSaveLink(link, wi); - break; - } - } - - if (linkFixed) - { - Logger.Log(LogLevel.Warning, "Solved issue with conflicting child links. Continuing happily..."); - } - else - { - Logger.Log(LogLevel.Error, "Could not solve issue with conflicting child links. This revision did" + - " not import successfully. You may see the wrong parent issue when verifying the work items."); - } + ForceSwapLinkAndSave(link, wi, ex2, "Reverse", GetWorkItem(link.TargetWiId), "child"); } else if (ex2.Message.Contains("TF201036: You cannot add a Parent link between work items")) { - Logger.Log(LogLevel.Warning, ex2.Message); - Logger.Log(LogLevel.Warning, "Attempting to fix the above issue by removing the offending link and re-adding the correct link..."); - - var wiSourceCurrent = GetWorkItem(link.SourceWiId); - - bool linkFixed = false; - foreach (var relation in wiSourceCurrent.Relations) - { - if (relation.Rel == "System.LinkTypes.Hierarchy-Reverse") - { - // Remove old link - WiLink linkToRemove = new WiLink(); - linkToRemove.Change = ReferenceChangeType.Removed; - linkToRemove.SourceWiId = wiSourceCurrent.Id.Value; - linkToRemove.TargetWiId = int.Parse(relation.Url.Split('/').Last()); - linkToRemove.WiType = "System.LinkTypes.Hierarchy-Reverse"; - RemoveAndSaveLink(linkToRemove, wiSourceCurrent); - - // Add new link again - var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-Reverse" && r.Url.Split('/').Last() == link.TargetWiId.ToString()); - wi.Relations.Remove(matchedRelations.First()); - linkFixed = AddAndSaveLink(link, wi); - break; - } - } - - if (linkFixed) - { - Logger.Log(LogLevel.Warning, "Solved issue with conflicting parent links. Continuing happily..."); - } - else - { - Logger.Log(LogLevel.Error, "Could not solve issue with conflicting parent links. This revision did" + - " not import successfully. You may see the wrong parent issue when verifying the work items."); - } + ForceSwapLinkAndSave(link, wi, ex2, "Forward", GetWorkItem(link.SourceWiId), "parent"); } else { @@ -179,6 +111,43 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) } + private void ForceSwapLinkAndSave(WiLink link, WorkItem wi, Exception ex2, string newLinkType, WorkItem wiTargetCurrent, string parentOrChild) + { + Logger.Log(LogLevel.Warning, ex2.Message); + Logger.Log(LogLevel.Warning, "Attempting to fix the above issue by removing the offending link and re-adding the correct link..."); + + bool linkFixed = false; + foreach (var relation in wiTargetCurrent.Relations) + { + if (relation.Rel == "System.LinkTypes.Hierarchy-Reverse") + { + // Remove old link + WiLink linkToRemove = new WiLink(); + linkToRemove.Change = ReferenceChangeType.Removed; + linkToRemove.SourceWiId = wiTargetCurrent.Id.Value; + linkToRemove.TargetWiId = int.Parse(relation.Url.Split('/').Last()); + linkToRemove.WiType = "System.LinkTypes.Hierarchy-Reverse"; + RemoveAndSaveLink(linkToRemove, wiTargetCurrent); + + // Add new link again + var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-"+newLinkType && r.Url.Split('/').Last() == link.TargetWiId.ToString()); + wi.Relations.Remove(matchedRelations.First()); + linkFixed = AddAndSaveLink(link, wi); + break; + } + } + + if (linkFixed) + { + Logger.Log(LogLevel.Warning, $"Solved issue with conflicting {parentOrChild} links. Continuing happily..."); + } + else + { + Logger.Log(LogLevel.Error, $"Could not solve issue with conflicting {parentOrChild} links. This revision did" + + " not import successfully. You may see the wrong parent issue when verifying the work items."); + } + } + public bool RemoveAndSaveLink(WiLink link, WorkItem wi) { if (link == null) From cabeaac90c0b066e50f64fa9902e5a5e7ed2d393 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 27 Nov 2023 20:15:32 +0100 Subject: [PATCH 14/44] Use correct link type in ForceSwapLinkAndSave() --- .../WorkItemImport/WitClient/WitClientUtils.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index 6e7bb837..8733d903 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -87,11 +87,11 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) { if (ex2.Message.Contains("TF201036: You cannot add a Child link between work items")) { - ForceSwapLinkAndSave(link, wi, ex2, "Reverse", GetWorkItem(link.TargetWiId), "child"); + ForceSwapLinkAndSave(link, wi, ex2, "Forward", GetWorkItem(link.TargetWiId), "child"); } else if (ex2.Message.Contains("TF201036: You cannot add a Parent link between work items")) { - ForceSwapLinkAndSave(link, wi, ex2, "Forward", GetWorkItem(link.SourceWiId), "parent"); + ForceSwapLinkAndSave(link, wi, ex2, "Reverse", GetWorkItem(link.SourceWiId), "parent"); } else { @@ -130,7 +130,7 @@ private void ForceSwapLinkAndSave(WiLink link, WorkItem wi, Exception ex2, strin RemoveAndSaveLink(linkToRemove, wiTargetCurrent); // Add new link again - var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-"+newLinkType && r.Url.Split('/').Last() == link.TargetWiId.ToString()); + var matchedRelations = wi.Relations.Where(r => r.Rel == "System.LinkTypes.Hierarchy-" + newLinkType && r.Url.Split('/').Last() == link.TargetWiId.ToString()); wi.Relations.Remove(matchedRelations.First()); linkFixed = AddAndSaveLink(link, wi); break; From 22ec371e9946a616a93072a5a055715c100a174a Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Tue, 28 Nov 2023 13:01:14 +0100 Subject: [PATCH 15/44] Update faq.md --- docs/faq.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 9bb04268..6d4ebec6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -37,7 +37,7 @@ If you are still not able to authenticate. Try and run the tool as another user. { "source": "Custom Field Name Jira", "source-type": "name", - "target": "Custom.CustomFieldNameADO" + "target": "Custom.TargetField" }, ``` @@ -52,6 +52,17 @@ Example: } ``` +### (Troubleshooting) My custom field is not migrated correctly/not migrated at all. + +If your custom field is not imported correctly into Azure DevOps, please go through the following checklist and ensure that every step has been followed: + +1. Ensure that the field is created in the correct Azure DevOps process model, and that the field is existing on the correct work item type. +2. Ensure that the `target` of your field mapping is set to the **Field reference name** of the ADO field, not the **Field name** (Observe that these two are different!!!) + + For example, if the **field name** is `MyField`, the **field reference name** is usually something like `Custom.MyField` (for ADO Services) or `MyCompany.MyField` (for ADO Server). Spaces are not allowed in the **field reference name**. + + Here is a reference sheet with all of the default fields: https://learn.microsoft.com/en-us/azure/devops/boards/work-items/guidance/work-item-field?view=azure-devops (click each field to open up the documentation page and view the field reference name). + ## 4. Guideline for migrating multiple projects ### Scenario 1: Single project From 202d9696999535fbfcb89876897d9000c988d44f Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Tue, 28 Nov 2023 13:06:57 +0100 Subject: [PATCH 16/44] Update bug_report.md --- .github/ISSUE_TEMPLATE/bug_report.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 479bf366..d3ae4895 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,11 +6,22 @@ labels: '' assignees: '' --- +# Read this before submitting your support ticket/bug report + +- [ ] Make sure that you have searched for your problem in the **FAQ**: +- [ ] Make sure that you have searched for your problem in the **issue board** (you can search for keywords in the **filters** search bar): +- [ ] Fill in the issue template below (starting with **Describe the problem**) +- [ ] Delete this section but keep the issue template +--- + **Describe the problem** + A clear and concise description of what the problem/bug is. **To Reproduce** + Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' From 7f2d49c735bd02b7e4f72ecf8afc9976699edde8 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Tue, 28 Nov 2023 21:27:05 +0100 Subject: [PATCH 17/44] Add support for images in custom rendered fields --- src/WorkItemMigrator/WorkItemImport/Agent.cs | 28 ++++++++++++++++ .../WorkItemImport/ImportCommandLine.cs | 3 +- .../WorkItemImport/Settings.cs | 5 ++- .../WitClient/WitClientUtils.cs | 33 +++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/Agent.cs b/src/WorkItemMigrator/WorkItemImport/Agent.cs index 188572f0..7d74e7d3 100644 --- a/src/WorkItemMigrator/WorkItemImport/Agent.cs +++ b/src/WorkItemMigrator/WorkItemImport/Agent.cs @@ -128,6 +128,34 @@ public bool ImportRevision(WiRevision rev, WorkItem wi, Settings settings) { Logger.Log(ex, $"Failed to correct description for '{wi.Id}', rev '{rev}'."); } + + // Correct other HTMl fields than description + foreach (var field in settings.FieldMap.Fields) + { + if ( + field.Mapper == "MapRendered" + && (field.For == "All" || field.For.Split(',').Contains(wi.Fields[WiFieldReference.WorkItemType])) + && (field.NotFor == null || !field.NotFor.Split(',').Contains(wi.Fields[WiFieldReference.WorkItemType])) + && wi.Fields.ContainsKey(field.Target) + && field.Target != WiFieldReference.Description + ) + { + try + { + _witClientUtils.CorrectRenderedField( + wi, + _context.GetItem(rev.ParentOriginId), + rev, + field.Target, + _context.Journal.IsAttachmentMigrated + ); + } + catch (Exception ex) + { + Logger.Log(ex, $"Failed to correct description for '{wi.Id}', rev '{rev}'."); + } + } + } } // rev with a commit won't have meaningful information, skip saving fields diff --git a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs index 388fb67a..ab4fa96c 100644 --- a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs +++ b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs @@ -90,7 +90,8 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt IgnoreFailedLinks = config.IgnoreFailedLinks, ProcessTemplate = config.ProcessTemplate, IncludeLinkComments = config.IncludeLinkComments, - IncludeCommits = config.IncludeCommits + IncludeCommits = config.IncludeCommits, + FieldMap = config.FieldMap }; // initialize Azure DevOps/TFS connection. Creates/fetches project, fills area and iteration caches. diff --git a/src/WorkItemMigrator/WorkItemImport/Settings.cs b/src/WorkItemMigrator/WorkItemImport/Settings.cs index 168ed5d5..6c5d268c 100644 --- a/src/WorkItemMigrator/WorkItemImport/Settings.cs +++ b/src/WorkItemMigrator/WorkItemImport/Settings.cs @@ -1,4 +1,6 @@ -namespace WorkItemImport +using Migration.Common.Config; + +namespace WorkItemImport { public class Settings { @@ -18,5 +20,6 @@ public Settings(string account, string project, string pat) public string ProcessTemplate { get; internal set; } public bool IncludeLinkComments { get; internal set; } public bool IncludeCommits { get; internal set; } + public FieldMap FieldMap { get; internal set; } } } \ No newline at end of file diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index 8733d903..a107dc3f 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -463,6 +463,39 @@ public bool CorrectDescription(WorkItem wi, WiItem wiItem, WiRevision rev, IsAtt return descUpdated; } + public bool CorrectRenderedField(WorkItem wi, WiItem wiItem, WiRevision rev, string fieldRef, IsAttachmentMigratedDelegate isAttachmentMigratedDelegate) + { + if (wi == null) + { + throw new ArgumentException(nameof(wi)); + } + + if (wiItem == null) + { + throw new ArgumentException(nameof(wiItem)); + } + + if (rev == null) + { + throw new ArgumentException(nameof(rev)); + } + + string fieldValue = wi.Fields[fieldRef].ToString(); + if (string.IsNullOrWhiteSpace(fieldValue)) + return false; + + bool updated = false; + + CorrectImagePath(wi, wiItem, rev, ref fieldValue, ref updated, isAttachmentMigratedDelegate); + + if (updated) + { + wi.Fields[fieldRef] = fieldValue; + } + + return updated; + } + public bool CorrectAcceptanceCriteria(WorkItem wi, WiItem wiItem, WiRevision rev, IsAttachmentMigratedDelegate isAttachmentMigratedDelegate) { if (wi == null) From 5533e9a5628071004217b252755c8b363ffd89c0 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 29 Nov 2023 15:03:00 +0100 Subject: [PATCH 18/44] Update faq.md --- docs/faq.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 6d4ebec6..d485909c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -63,6 +63,10 @@ If your custom field is not imported correctly into Azure DevOps, please go thro Here is a reference sheet with all of the default fields: https://learn.microsoft.com/en-us/azure/devops/boards/work-items/guidance/work-item-field?view=azure-devops (click each field to open up the documentation page and view the field reference name). +### (Troubleshooting) I receive errore like: **VS403691: Update to work item 165 had two or more updates for field with reference name 'Custom.XXX'. A field cannot be updated more than once in the same update."** + +This error is usually indicative of incorrect configuration on the user's side. Please follow the checklist [here](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#troubleshooting-my-custom-field-is-not-migrated-correctlynot-migrated-at-all) to ensure that you do not have any issues with your `config.json` file. + ## 4. Guideline for migrating multiple projects ### Scenario 1: Single project From 6f5fbf496e53ff1f45b68ea403940ad685b95288 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 29 Nov 2023 15:03:20 +0100 Subject: [PATCH 19/44] Update faq.md --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index d485909c..9ce208c8 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -63,7 +63,7 @@ If your custom field is not imported correctly into Azure DevOps, please go thro Here is a reference sheet with all of the default fields: https://learn.microsoft.com/en-us/azure/devops/boards/work-items/guidance/work-item-field?view=azure-devops (click each field to open up the documentation page and view the field reference name). -### (Troubleshooting) I receive errore like: **VS403691: Update to work item 165 had two or more updates for field with reference name 'Custom.XXX'. A field cannot be updated more than once in the same update."** +### (Troubleshooting) I receive errors like: **VS403691: Update to work item 165 had two or more updates for field with reference name 'Custom.XXX'. A field cannot be updated more than once in the same update."** This error is usually indicative of incorrect configuration on the user's side. Please follow the checklist [here](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#troubleshooting-my-custom-field-is-not-migrated-correctlynot-migrated-at-all) to ensure that you do not have any issues with your `config.json` file. From 384ccd42261273ac10fe63d3bacf0e1fc740c5b6 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Wed, 29 Nov 2023 15:04:34 +0100 Subject: [PATCH 20/44] Update faq.md --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 9ce208c8..a456b1cf 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -65,7 +65,7 @@ If your custom field is not imported correctly into Azure DevOps, please go thro ### (Troubleshooting) I receive errors like: **VS403691: Update to work item 165 had two or more updates for field with reference name 'Custom.XXX'. A field cannot be updated more than once in the same update."** -This error is usually indicative of incorrect configuration on the user's side. Please follow the checklist [here](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#troubleshooting-my-custom-field-is-not-migrated-correctlynot-migrated-at-all) to ensure that you do not have any issues with your `config.json` file. +This error is usually indicative of incorrect configuration on the user's side. Please follow the checklist [here](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#troubleshooting-my-custom-field-is-not-migrated-correctlynot-migrated-at-all) (the section above this one, in the same document) to ensure that you do not have any issues with your `config.json` file. ## 4. Guideline for migrating multiple projects From c9410cb29d7b022d17c2777a207d98e3f82be8e9 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Thu, 30 Nov 2023 14:19:17 +0100 Subject: [PATCH 21/44] Update faq.md --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index a456b1cf..fc74a3cc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -41,7 +41,7 @@ If you are still not able to authenticate. Try and run the tool as another user. }, ``` -- Alternatively, we can map the filed kay instead of the name. Inspect the REST API response to find the **field key** for your custom field. This is usually something like **customfield_12345**. +- Alternatively, we can map the field key instead of the name. Inspect the REST API response to find the **field key** for your custom field. This is usually something like **customfield_12345**. Example: From e81a5395f298fceabceea23e2e7227762a6fc243 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 09:15:31 +0100 Subject: [PATCH 22/44] Update extension icon --- .../images/extension-icon.png | Bin 5126 -> 10403 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/WorkItemMigrator.Extension/images/extension-icon.png b/src/WorkItemMigrator.Extension/images/extension-icon.png index 27064ab0675683d830659974214e529855305c99..25a58ccdd96a53c7da9dc2fcc749e7c9397f1cb1 100644 GIT binary patch literal 10403 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 literal 5126 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 From 43d5fa9e406e72e98320d8becfe644f01aaf3a2d Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 09:18:17 +0100 Subject: [PATCH 23/44] Update ADO marketplace title --- src/WorkItemMigrator.Extension/vss-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WorkItemMigrator.Extension/vss-extension.json b/src/WorkItemMigrator.Extension/vss-extension.json index 5c509daf..c51147c2 100644 --- a/src/WorkItemMigrator.Extension/vss-extension.json +++ b/src/WorkItemMigrator.Extension/vss-extension.json @@ -1,7 +1,7 @@ { "manifestVersion": 1, "id": "jira-devops-migration", - "name": "Jira to Azure DevOps/TFS work item migration tool", + "name": "Jira to Azure DevOps/TFS work item migration tool (FREE)", "version": "0.0.1", "public": false, "publisher": "solidify", From 837ee080f918c92e47a6416161d73d92e33ec10c Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 11:57:52 +0100 Subject: [PATCH 24/44] Add marketplace pricing page --- src/WorkItemMigrator.Extension/PRICING.md | 17 +++++++++++++++++ .../vss-extension.json | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 src/WorkItemMigrator.Extension/PRICING.md diff --git a/src/WorkItemMigrator.Extension/PRICING.md b/src/WorkItemMigrator.Extension/PRICING.md new file mode 100644 index 00000000..1cc0c100 --- /dev/null +++ b/src/WorkItemMigrator.Extension/PRICING.md @@ -0,0 +1,17 @@ +### Jira Azure DevOps Migrator PRO, price plan + +#### Included features + +* Jira Azure DevOps Migrator PRO + bootstrapper utility +* Support for a limited number of Jira organizations/servers +* Support for an unlimited number of Azure DevOps organizations/servers +* Support over email + +#### Price + +$1000 / Jira organization (or server) / month + +
+ +The extension can be evaluated during a 30 days period. +All prices excl. VAT. diff --git a/src/WorkItemMigrator.Extension/vss-extension.json b/src/WorkItemMigrator.Extension/vss-extension.json index c51147c2..880ee75d 100644 --- a/src/WorkItemMigrator.Extension/vss-extension.json +++ b/src/WorkItemMigrator.Extension/vss-extension.json @@ -57,6 +57,9 @@ }, "privacypolicy": { "path": "PRIVACY.md" + }, + "pricing": { + "path": "static/docs/PRICING.md" } } } From a306d22a43d3a877a5197e33e4bbac157aa1fc33 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 12:04:14 +0100 Subject: [PATCH 25/44] Add contact information to marketplace page --- src/WorkItemMigrator.Extension/PRICING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/WorkItemMigrator.Extension/PRICING.md b/src/WorkItemMigrator.Extension/PRICING.md index 1cc0c100..192b326f 100644 --- a/src/WorkItemMigrator.Extension/PRICING.md +++ b/src/WorkItemMigrator.Extension/PRICING.md @@ -15,3 +15,5 @@ $1000 / Jira organization (or server) / month The extension can be evaluated during a 30 days period. All prices excl. VAT. + +[Contact us](mailto:support.jira-migrator@solidify.dev) for more information. From 0489cb133cc9446731b0cbd05bbb997f6bc602d7 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 12:50:51 +0100 Subject: [PATCH 26/44] Update path to PRICING.md --- src/WorkItemMigrator.Extension/vss-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WorkItemMigrator.Extension/vss-extension.json b/src/WorkItemMigrator.Extension/vss-extension.json index 880ee75d..6c00a9ba 100644 --- a/src/WorkItemMigrator.Extension/vss-extension.json +++ b/src/WorkItemMigrator.Extension/vss-extension.json @@ -59,7 +59,7 @@ "path": "PRIVACY.md" }, "pricing": { - "path": "static/docs/PRICING.md" + "path": "PRICING.md" } } } From 771e3a9d4af57160377a3acb51d95aa217ea3629 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Fri, 1 Dec 2023 15:02:18 +0100 Subject: [PATCH 27/44] Support for user picker fields for jira server --- src/WorkItemMigrator/JiraExport/JiraItem.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/WorkItemMigrator/JiraExport/JiraItem.cs b/src/WorkItemMigrator/JiraExport/JiraItem.cs index a64b3056..15e48ab0 100644 --- a/src/WorkItemMigrator/JiraExport/JiraItem.cs +++ b/src/WorkItemMigrator/JiraExport/JiraItem.cs @@ -485,13 +485,20 @@ private static Dictionary ExtractFields(string key, JObject remo { value = prop.Value.Value(); } - // User picker + // User picker, cloud else if (type == JTokenType.Object && prop.Value["accountId"] != null && prop.Value["emailAddress"] != null && prop.Value["avatarUrls"] != null && prop.Value["displayName"] != null) { value = prop.Value["accountId"].ToString(); } + // User picker, on-prem + else if (type == JTokenType.Object && prop.Value["key"] != null + && prop.Value["emailAddress"] != null && prop.Value["avatarUrls"] != null + && prop.Value["displayName"] != null) + { + value = prop.Value["key"].ToString(); + } else if (prop.Value.Type == JTokenType.Date) { value = prop.Value.Value(); From 6f2bcef56863316418f4cfd2781d8e2d54f8614f Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 4 Dec 2023 21:03:31 +0100 Subject: [PATCH 28/44] Move integration tests to source repo --- test/integration/.azure-pipelines.yml | 61 +++ test/integration/config.json | 212 ++++++++ test/integration/delete-work-items.py | 93 ++++ test/integration/smoke-tests.py | 698 ++++++++++++++++++++++++++ test/integration/users.txt | 4 + 5 files changed, 1068 insertions(+) create mode 100644 test/integration/.azure-pipelines.yml create mode 100644 test/integration/config.json create mode 100644 test/integration/delete-work-items.py create mode 100644 test/integration/smoke-tests.py create mode 100644 test/integration/users.txt diff --git a/test/integration/.azure-pipelines.yml b/test/integration/.azure-pipelines.yml new file mode 100644 index 00000000..84652766 --- /dev/null +++ b/test/integration/.azure-pipelines.yml @@ -0,0 +1,61 @@ +trigger: none + +pool: + vmimage: windows-2019 + +variables: +- group: jira-azuredevops-migrator-smoke-tests +- name: BuildPlatform + value: 'any cpu' +- name: BuildConfiguration + value: 'release' + +steps: +- checkout: self + +- task: PowerShell@2 + displayName: 'Replace Migration workspace token in config' + inputs: + targetType: 'inline' + script: | + $file = "$(System.DefaultWorkingDirectory)\test\integration\config.json" + $str_find = "__workspace__" + $str_replace = "$(System.DefaultWorkingDirectory)\test\integration\jira-migrator-workspace" + $str_replace = $str_replace -replace "\\", "\\" + ((Get-Content -path $file -Raw) -replace $str_find, $str_replace) | Set-Content -Path $file + cat $file + +- task: NuGetCommand@2 + displayName: 'NuGet restore' + inputs: + restoreSolution: '**\*.sln' + +- task: VSBuild@1 + displayName: 'Build solution WorkItemMigrator' + inputs: + solution: $(System.DefaultWorkingDirectory)\src\WorkItemMigrator\WorkItemMigrator.sln + platform: '$(BuildPlatform)' + configuration: '$(BuildConfiguration)' + +- script: pip install requests python-dateutil + displayName: pip install + +- task: PythonScript@0 + displayName: Delete work items on target org, PythonScript + inputs: + scriptSource: 'filePath' + scriptPath: '$(System.DefaultWorkingDirectory)\test\integration\delete-work-items.py' + arguments: '$(AdoOrganizationUrl) $(AdoProjectName) $(AdoApiToken)' + +- script: $(System.DefaultWorkingDirectory)\src\WorkItemMigrator\JiraExport\bin\$(BuildConfiguration)\jira-export.exe -u $(JiraUser) -p $(JiraApiToken) --url $(JiraUrl) --config $(System.DefaultWorkingDirectory)\test\integration\config.json --force + displayName: jira-export.exe + +- script: $(System.DefaultWorkingDirectory)\src\WorkItemMigrator\WorkItemImport\bin\$(BuildConfiguration)\wi-import.exe --token $(AdoApiToken) --url $(AdoOrganizationUrl) --config $(System.DefaultWorkingDirectory)\test\integration\config.json --force + displayName: wi-import.exe + +- task: PythonScript@0 + displayName: Smoke tests, PythonScript + inputs: + scriptSource: 'filePath' + scriptPath: '$(System.DefaultWorkingDirectory)\test\integration\smoke-tests.py' + arguments: '$(AdoOrganizationUrl) $(AdoProjectName) $(AdoApiToken) $(JiraUrl) $(JiraUser) $(JiraApiToken) $(JiraProject) $(System.DefaultWorkingDirectory)\test\integration\jira-migrator-workspace\users.txt' diff --git a/test/integration/config.json b/test/integration/config.json new file mode 100644 index 00000000..12cda519 --- /dev/null +++ b/test/integration/config.json @@ -0,0 +1,212 @@ +{ + "source-project": "Agile-Demo", + "target-project": "AzureDevOps-Jira-Migrator-Smoke-Tests", + "query": "project = \"AGILEDEMO\" ORDER BY created DESC", + "using-jira-cloud": true, + "workspace": "__workspace__", + "epic-link-field": "Epic Link", + "sprint-field": "Sprint", + "download-options": 7, + "batch-size": 20, + "log-level": "Info", + "attachment-folder": "Attachments", + "user-mapping-file": "users.txt", + "base-area-path": "Migrated", + "base-iteration-path": "Migrated", + "ignore-failed-links": true, + "process-template": "Agile", + "link-map": { + "link": [ + { + "source": "Epic", + "target": "System.LinkTypes.Hierarchy-Reverse" + }, + { + "source": "Parent", + "target": "System.LinkTypes.Hierarchy-Reverse" + }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, + { + "source": "Relates", + "target": "System.LinkTypes.Related" + }, + { + "source": "Duplicate", + "target": "System.LinkTypes.Duplicate-Forward" + } + ] + }, + "type-map": { + "type": [ + { + "source": "Epic", + "target": "Epic" + }, + { + "source": "Story", + "target": "Feature" + }, + { + "source": "Bug", + "target": "Bug" + }, + { + "source": "Task", + "target": "Task" + }, + { + "source": "Sub-task", + "target": "Task" + } + ] + }, + "field-map": { + "field": [ + { + "source": "summary", + "target": "System.Title", + "mapper": "MapTitle" + }, + { + "source": "assignee", + "target": "System.AssignedTo", + "mapper": "MapUser" + }, + { + "source": "description", + "target": "System.Description", + "mapper": "MapRendered" + }, + { + "source": "priority", + "target": "Microsoft.VSTS.Common.Priority", + "mapping": { + "values": [ + { + "source": "Highest", + "target": "1" + }, + { + "source": "High", + "target": "2" + }, + { + "source": "Medium", + "target": "3" + }, + { + "source": "Low", + "target": "3" + }, + { + "source": "Lowest", + "target": "4" + } + ] + } + }, + { + "source": "labels", + "target": "System.Tags", + "mapper": "MapTags" + }, + { + "source": "comment", + "target": "System.History", + "mapper": "MapRendered" + }, + { + "source": "status", + "target": "System.State", + "for": "Feature,Epic,User Story,Bug", + "mapping": { + "values": [ + { + "source": "To Do", + "target": "New" + }, + { + "source": "In Progress", + "target": "Active" + }, + { + "source": "Done", + "target": "Resolved" + }, + { + "source": "Done", + "target": "Closed" + }, + { + "source": "Removed", + "target": "Removed" + } + ] + } + }, + { + "source": "status", + "target": "System.State", + "for": "Task", + "mapping": { + "values": [ + { + "source": "To Do", + "target": "New" + }, + { + "source": "In Progress", + "target": "Active" + }, + { + "source": "Done", + "target": "Closed" + }, + { + "source": "Removed", + "target": "Removed" + } + ] + } + }, + { + "source": "Story Points", + "source-type": "name", + "target": "Microsoft.VSTS.Scheduling.StoryPoints", + "not-for": "Task" + }, + { + "source": "timeestimate", + "target": "Microsoft.VSTS.Scheduling.RemainingWork", + "mapper": "MapRemainingWork", + "for": "Bug,Task" + }, + { + "source": "description", + "target": "Microsoft.VSTS.TCM.ReproSteps", + "for": "Bug" + }, + { + "source": "environment", + "source-type": "name", + "target": "Microsoft.VSTS.TCM.SystemInfo", + "for": "Bug,Epic" + }, + { + "source": "fixversions", + "source-type": "name", + "target": "Custom.FixVersion", + "for": "Bug,Feature" + }, + { + "source": "alexander-custom-html", + "target": "Custom.CustomHtml", + "source-type": "name", + "mapper": "MapRendered" + } + ] + } + } \ No newline at end of file diff --git a/test/integration/delete-work-items.py b/test/integration/delete-work-items.py new file mode 100644 index 00000000..701c95ce --- /dev/null +++ b/test/integration/delete-work-items.py @@ -0,0 +1,93 @@ +import sys +import requests +import base64 + +########################## +###### ADO REST API ###### +########################## + + +### Wiql - Query By Wiql +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/wiql/query-by-wiql +# Params: +# - project_name: The friendly name or guid of the project +# - team_name: The guid or friendly name of the team (optional) +# - query: The wiql query. +def list_workitems_by_wiql( + PAT: str, organization_url: str, project_name: str, query: str = "" +): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + body = '{"query": "' + query + '"}' + + uri_api = "{0}/{1}/_apis/wit/wiql?api-version=6.0".format( + organization_url, project_name + ) + response = requests.post(url=uri_api, headers=headers, data=body) + r = None + try: + r = response.json() + except: + print("Http error while sending to the enpoint: " + uri_api) + print(response) + print("body:" + body) + return r + + +### Work Items - Delete +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/delete +# Params: +# - project_name: The friendly name or guid of the project +# - id: ID of the work item +def delete_workitem(PAT: str, organization_url: str, project_name: str, id: str): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + uri_api = "{0}/{1}/_apis/wit/workitems/{2}?$expand=All&api-version=6.0".format( + organization_url, project_name, id + ) + response = requests.delete(url=uri_api, headers=headers) + return response.json() + + +def create_auth_header(PAT): + return "Basic " + str(base64.b64encode(bytes(":" + PAT, "ascii")), "ascii") + + +#################### +###### CONFIG ###### +#################### + +print("Argument List:", str(sys.argv)) + +ado_organization_url: str = sys.argv[1] +ado_project_name: str = sys.argv[2] +ado_api_token: str = sys.argv[3] + +##################### +###### PROGRAM ###### +##################### + +# Set queries +ado_wiql_query: str = ( + "SELECT [Id] FROM WorkItems WHERE [System.TeamProject] = '{0}'".format( + ado_project_name + ) +) + +# Get issues/work items +ado_work_items_json: list = list_workitems_by_wiql( + ado_api_token, ado_organization_url, ado_project_name, ado_wiql_query +) + +original_wi_count: int = len(ado_work_items_json["workItems"]) + +for work_item in ado_work_items_json["workItems"]: + delete_workitem( + ado_api_token, ado_organization_url, ado_project_name, work_item["id"] + ) + +print("Deleted {0} work items from {1}".format(original_wi_count, ado_project_name)) diff --git a/test/integration/smoke-tests.py b/test/integration/smoke-tests.py new file mode 100644 index 00000000..ffe540bf --- /dev/null +++ b/test/integration/smoke-tests.py @@ -0,0 +1,698 @@ +from distutils.log import error +import sys +from requests.auth import HTTPBasicAuth +import requests +import base64 +from dateutil import parser as dateparser + +########################## +###### ADO REST API ###### +########################## + + +### Wiql - Query By Wiql +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/wiql/query-by-wiql +# Params: +# - project_name: The friendly name or guid of the project +# - team_name: The guid or friendly name of the team (optional) +# - query: The wiql query. +def list_workitems_by_wiql( + PAT: str, organization_url: str, project_name: str, query: str = "" +): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + body = '{"query": "' + query + '"}' + + uri_api = "{0}/{1}/_apis/wit/wiql?api-version=6.0".format( + organization_url, project_name + ) + response = requests.post(url=uri_api, headers=headers, data=body) + r = None + try: + r = response.json() + except: + print("Http error while sending to the enpoint: " + uri_api) + print(response) + print("body:" + body) + return r + + +### Work Items - Get +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/get +# Params: +# - project_name: The friendly name or guid of the project +# - id: ID of the work item +def get_workitem(PAT: str, organization_url: str, project_name: str, id: str): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + uri_api = "{0}/{1}/_apis/wit/workitems/{2}?$expand=All&api-version=6.0".format( + organization_url, project_name, id + ) + response = requests.get(url=uri_api, headers=headers) + return response.json() + + +### Work Items - Delete +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/delete +# Params: +# - project_name: The friendly name or guid of the project +# - id: ID of the work item +def delete_workitem(PAT: str, organization_url: str, project_name: str, id: str): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + uri_api = "{0}/{1}/_apis/wit/workitems/{2}?$expand=All&api-version=6.0".format( + organization_url, project_name, id + ) + response = requests.delete(url=uri_api, headers=headers) + return response.json() + + +### Comments - Get +# https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/comments/get-comments +# Params: +# - project_name: The friendly name or guid of the project +# - id: ID of the work item +def get_comments(PAT: str, organization_url: str, project_name: str, id: str): + headers = { + "Authorization": create_auth_header(PAT), + "Content-Type": "application/json", + } + uri_api = "{0}/{1}/_apis/wit/workitems/{2}/comments?$expand=All&api-version=6.0-preview.3".format( + organization_url, project_name, id + ) + response = requests.get(url=uri_api, headers=headers) + return response.json()["comments"] + + +def create_auth_header(PAT): + return "Basic " + str(base64.b64encode(bytes(":" + PAT, "ascii")), "ascii") + + +########################### +###### JIRA REST API ###### +########################### + + +def list_issues(API_token: str, email: str, jira_url: str, JQL_query: str): + api = "{0}/rest/api/2/search?jql={1}&fields=attachment,summary,description,comment,assignee,parent,issuelinks,subtasks,fixVersions,created,updated,priority,status,customfield_10066".format( + jira_url, JQL_query + ) + auth = HTTPBasicAuth(email, API_token) + headers = {"Accept": "application/json"} + response = requests.get(url=api, headers=headers, auth=auth) + return response.json() + +def list_releases(API_token: str, email: str, jira_url: str, jira_project: str): + api = "{0}/rest/api/2/project/{1}/version?expand=*".format( + jira_url, jira_project + ) + auth = HTTPBasicAuth(email, API_token) + headers = {"Accept": "application/json"} + response = requests.get(url=api, headers=headers, auth=auth) + return response.json() + +def get_remote_links(API_token: str, email: str, jira_url: str, issue_key: str): + api = "{0}/rest/api/2/issue/{1}/remotelink".format( + jira_url, issue_key + ) + auth = HTTPBasicAuth(email, API_token) + headers = {"Accept": "application/json"} + response = requests.get(url=api, headers=headers, auth=auth) + return response.json() + +########################## +###### TEST HELPERS ###### +########################## + +def do_error(error_msg: str): + error(error_msg) + return 1 + +def test_user(jira_field_key: str, ado_field_key: str): + if ( + jira_field_key in jira_issue["fields"] + and jira_issue["fields"][jira_field_key] != None + and jira_issue["fields"][jira_field_key]["accountId"] in user_map + ): + if ado_field_key not in ado_work_item["fields"]: + ec = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + ado_field_key, + jira_issue["fields"][jira_field_key]["displayName"], + "", + ) + ) + return ec + + else: + if "uniqueName" not in ado_work_item["fields"][ado_field_key]: + ec = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + ado_field_key, + jira_issue["fields"][jira_field_key]["displayName"], + "", + ) + ) + return ec + + elif ( + ado_work_item["fields"][ado_field_key]["uniqueName"] + != user_map[jira_issue["fields"][jira_field_key]["accountId"]].rstrip() + ): + ec = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + ado_field_key, + user_map[ + jira_issue["fields"][jira_field_key]["accountId"] + ].rstrip(), + ado_work_item["fields"][ado_field_key]["uniqueName"], + ) + ) + return ec + return None + +def test_date(jira_field_key: str, ado_field_key: str): + jira_changed_date = dateparser.parse(jira_issue["fields"][jira_field_key]).utcnow() + ado_changed_date = dateparser.parse( + ado_work_item["fields"][ado_field_key] + ).utcnow() + total_seconds = (jira_changed_date - ado_changed_date).total_seconds() + if abs(total_seconds) > 2.0: + ec = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + ado_field_key, + jira_issue["fields"][jira_field_key], + ado_work_item["fields"][ado_field_key], + ) + ) + return ec + return None + +def test_field_simple(jira_field_key: str, ado_field_key: str): + if ( + jira_field_key in jira_issue["fields"] + and jira_issue["fields"][jira_field_key] != [] + ): + if ( + ado_work_item["fields"][ado_field_key] + != jira_issue["fields"][jira_field_key][0]["name"] + ): + ec = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + ado_field_key, + jira_issue["fields"][jira_field_key][0]["name"], + ado_work_item["fields"][ado_field_key], + ) + ) + return ec + return None + +#################### +###### CONFIG ###### +#################### + +print("Argument List:", str(sys.argv)) + +ado_organization_url: str = sys.argv[1] +ado_project_name: str = sys.argv[2] +ado_api_token: str = sys.argv[3] + +jira_url: str = sys.argv[4] +jira_email: str = sys.argv[5] +jira_api_token: str = sys.argv[6] +jira_project: str = sys.argv[7] + +user_mapping_file_path = sys.argv[8] + +##################### +###### PROGRAM ###### +##################### + +# Set queries +ado_wiql_query: str = ( + "SELECT [Id] FROM WorkItems WHERE [System.TeamProject] = '{0}'".format( + ado_project_name + ) +) +jira_jql_query: str = 'project = "{0}" ORDER BY created DESC'.format(jira_project) + +# Get issues/work items +jira_issues_json: list = list_issues( + jira_api_token, jira_email, jira_url, jira_jql_query +) +jira_releases: list = list_releases( + jira_api_token, jira_email, jira_url, jira_project +) +ado_work_items_json: list = list_workitems_by_wiql( + ado_api_token, ado_organization_url, ado_project_name, ado_wiql_query +) + +exit_code = 0 + +ado_work_items_by_id: dict = {} + +# Parse user mapping file +user_mapping_file = open(user_mapping_file_path, "r") +user_map_list = map(lambda x: x.split("="), user_mapping_file.readlines()) +user_map = {} +for entry in user_map_list: + user_map[entry[0]] = entry[1] +user_mapping_file.close() + +# Get work items +ado_work_items: list = [] +for ado_work_item_key in ado_work_items_json["workItems"]: + # Cache work items + if ado_work_item_key["id"] not in ado_work_items_by_id.keys(): + ado_work_items_by_id[ado_work_item_key["id"]] = get_workitem( + ado_api_token, + ado_organization_url, + ado_project_name, + ado_work_item_key["id"], + ) + ado_work_item = ado_work_items_by_id[ado_work_item_key["id"]] + ado_work_items.append(ado_work_item) + +# Check issue count +if len(jira_issues_json) != len(ado_work_items_json): + exit_code = do_error("Jira issue count does not match ADO work item count") + +for jira_issue in jira_issues_json["issues"]: + issue_found_in_ADO: bool = False + jira_issue_mapped_title = "[{0}] {1}".format( + jira_issue["key"], jira_issue["fields"]["summary"] + ) + + for ado_work_item in ado_work_items: + # Compare title + if ado_work_item["fields"]["System.Title"] == jira_issue_mapped_title or ( + len(ado_work_item["fields"]["System.Title"]) >= 255 + and ado_work_item["fields"]["System.Title"] + == jira_issue_mapped_title[0:252] + "..." + ): + issue_found_in_ADO = True + else: + continue + + # Compare description + if ( + "description" in jira_issue["fields"] + and jira_issue["fields"]["description"] != None + and jira_issue["fields"]["description"].strip() != "" + ): + if ( + ado_work_item["fields"]["System.Description"] + in jira_issue["fields"]["description"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "System.Description", + jira_issue["fields"]["description"], + ado_work_item["fields"]["System.Description"], + ) + ) + + # Test unformatted attachments + if ( + "https://dev.azure.com/secure/attachment/" + in ado_work_item["fields"]["System.Description"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}': an unformatted attachment link was detected".format( + jira_issue_mapped_title, "System.Description" + ) + ) + + # Test unformatted links to Jira issues + if ( + "href=\\\"https://solidifydemo.atlassian.net/browse/AGILEDEMO-" + in ado_work_item["fields"]["System.Description"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}': an unformatted issue link was detected".format( + jira_issue_mapped_title, "System.Description" + ) + ) + + # Compare custom HTML rendered field + if ( + "customfield_10066" in jira_issue["fields"] + and jira_issue["fields"]["customfield_10066"] != None + and jira_issue["fields"]["customfield_10066"].strip() != "" + ): + if ( + ado_work_item["fields"]["Custom.CustomHtml"] + in jira_issue["fields"]["customfield_10066"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Custom.CustomHtml", + jira_issue["fields"]["customfield_10066"], + ado_work_item["fields"]["Custom.CustomHtml"], + ) + ) + + # Test unformatted attachments + if ( + "https://dev.azure.com/secure/attachment/" + in ado_work_item["fields"]["Custom.CustomHtml"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}': an unformatted attachment link was detected".format( + jira_issue_mapped_title, "Custom.CustomHtml" + ) + ) + + # Compare status + if "status" in jira_issue["fields"] and jira_issue["fields"]["status"] != None: + if ( + ( + jira_issue["fields"]["status"]["name"] == "Klart" + and not ( + ado_work_item["fields"]["System.State"] == "Resolved" + or ado_work_item["fields"]["System.State"] == "Closed" + ) + ) + or ( + jira_issue["fields"]["status"]["name"] == "Pågående" + and ado_work_item["fields"]["System.State"] != "Active" + ) + or ( + jira_issue["fields"]["status"]["name"] == "Att göra" + and ado_work_item["fields"]["System.State"] != "New" + ) + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "System.State", + jira_issue["fields"]["status"]["name"], + ado_work_item["fields"]["System.State"], + ) + ) + + # Compare attachment count + if ( + "attachment" in jira_issue["fields"] + and jira_issue["fields"]["attachment"] != None + ): + jira_attachments_filtered = list( + filter( + lambda a: a["size"] < 60000000, jira_issue["fields"]["attachment"] + ) + ) + jira_attachment_count = len(jira_attachments_filtered) + if jira_attachment_count > 0: + ado_attachment_count = len( + list( + filter( + lambda x: x["rel"] == "AttachedFile", + ado_work_item["relations"], + ) + ) + ) + if ( + ado_attachment_count < 100 + and ado_attachment_count != jira_attachment_count + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "AttachmentCount", + jira_attachment_count, + ado_attachment_count, + ) + ) + + # Compare area path + if ( + ado_work_item["fields"]["System.AreaPath"] + != "AzureDevOps-Jira-Migrator-Smoke-Tests\Migrated" + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "System.AreaPath", + ado_work_item["fields"]["System.AreaPath"], + "AzureDevOps-Jira-Migrator-Smoke-Tests\\Migrated", + ) + ) + + # Compare comment count + if ( + "comment" in jira_issue["fields"] + and len(jira_issue["fields"]["comment"]["comments"]) > 0 + ): + jira_comment_count = len(jira_issue["fields"]["comment"]["comments"]) + if jira_comment_count > 0: + ado_comments = get_comments( + ado_api_token, + ado_organization_url, + ado_project_name, + ado_work_item["id"], + ) + ado_comments_filtered = list( + filter( + lambda x: "Added link(s): [Added]" not in x["text"], + ado_comments, + ) + ) + ado_comment_count = len(ado_comments_filtered) + if ado_comment_count != jira_comment_count: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "CommentCount", + jira_comment_count, + ado_comment_count, + ) + ) + + # Compare AssignedTo + if test_user("assignee", "System.AssignedTo") != None: + exit_code = 1 + + # Compare parent + if "parent" in jira_issue["fields"] and jira_issue["fields"]["parent"] != None: + jira_parent = jira_issue["fields"]["parent"] + jira_parent_mapped_title = "[{0}] {1}".format( + jira_parent["key"], jira_parent["fields"]["summary"] + ) + + if "relations" not in ado_work_item: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, "Parent", "", jira_parent_mapped_title + ) + ) + else: + ado_relations_filtered = list( + filter( + lambda x: x["rel"] == "System.LinkTypes.Hierarchy-Reverse", + ado_work_item["relations"], + ) + ) + if len(ado_relations_filtered) == 0: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not exist when it should.".format( + jira_issue_mapped_title, + "Parent" + ) + ) + else: + ado_parent_id = int(ado_relations_filtered[0]["url"].split("/")[-1]) + ado_parent_work_item = ado_work_items_by_id[ado_parent_id] + if ( + jira_parent_mapped_title + != ado_parent_work_item["fields"]["System.Title"] + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Parent", + ado_parent_work_item["fields"]["System.Title"], + jira_parent_mapped_title, + ) + ) + + # Compare related issues + if ( + "issuelinks" in jira_issue["fields"] + and len(jira_issue["fields"]["issuelinks"]) > 0 + ): + jira_linked_issues = jira_issue["fields"]["issuelinks"] + jira_related_issues_filtered = list( + filter(lambda x: x["type"]["name"] == "Relates", jira_linked_issues) + ) + if len(jira_related_issues_filtered) > 0: + if "relations" not in ado_work_item: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Linked Issue count", + "0", + str(len(jira_linked_issues)), + ) + ) + else: + ado_relations_filtered = list( + filter( + lambda x: x["rel"] == "System.LinkTypes.Related", + ado_work_item["relations"], + ) + ) + for jira_related_issue in jira_related_issues_filtered: + if "inwardIssue" in jira_related_issue: + jira_related_issue_mapped_title = "[{0}] {1}".format( + jira_related_issue["inwardIssue"]["key"], + jira_related_issue["inwardIssue"]["fields"]["summary"], + ) + elif "outwardIssue" in jira_related_issue: + jira_related_issue_mapped_title = "[{0}] {1}".format( + jira_related_issue["outwardIssue"]["key"], + jira_related_issue["outwardIssue"]["fields"]["summary"], + ) + ado_relations_filtered_final = list( + filter( + lambda x: ado_work_items_by_id[ + int(x["url"].split("/")[-1]) + ]["fields"]["System.Title"] + == jira_related_issue_mapped_title, + ado_relations_filtered, + ) + ) + if len(ado_relations_filtered_final) != len( + jira_related_issues_filtered + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Related issues count", + len(ado_relations_filtered_final), + len(jira_related_issues_filtered), + ) + ) + + # Compare subtasks + if ( + "subtasks" in jira_issue["fields"] + and len(jira_issue["fields"]["subtasks"]) > 0 + ): + jira_linked_issues = jira_issue["fields"]["subtasks"] + + if "relations" not in ado_work_item: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Subtask (child) count", + "0", + str(len(jira_linked_issues)), + ) + ) + else: + ado_relations_filtered = list( + filter( + lambda x: x["rel"] == "System.LinkTypes.Hierarchy-Forward", + ado_work_item["relations"], + ) + ) + for jira_related_issue in jira_linked_issues: + jira_related_issue_mapped_title = "[{0}] {1}".format( + jira_related_issue["key"], + jira_related_issue["fields"]["summary"], + ) + ado_relations_filtered_final = list( + filter( + lambda x: ado_work_items_by_id[ + int(x["url"].split("/")[-1]) + ]["fields"]["System.Title"] + == jira_related_issue_mapped_title, + ado_relations_filtered, + ) + ) + if len(ado_relations_filtered_final) != 1: + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Subtack (child) count", + len(ado_relations_filtered_final), + len(jira_linked_issues), + ) + ) + + # Compare Fixversions + if ( + "fixVersions" in jira_issue["fields"] + and jira_issue["fields"]["fixVersions"] != [] + and jira_issue["fields"]["fixVersions"][0]["name"] == "2021.2.0.296" + ): + if test_field_simple("fixVersions", "Custom.FixVersion") != None: + exit_code = 1 + + # Compare createdDate + if test_date("created", "System.CreatedDate") != None: + exit_code = 1 + + # Compare changedDate + if test_date("updated", "System.ChangedDate") != None: + exit_code = 1 + + # Compare Reporter + if test_user("reporter", "Custom.Reporter") != None: + exit_code = 1 + + # Compare Custom UserPicker + if test_user("alexander-testar-custom-userpicker", "Custom.CustomUserPicker") != None: + exit_code = 1 + + # Compare Story points + if test_field_simple("customfield_10014", "Microsoft.VSTS.Scheduling.StoryPoints") != None: + exit_code = 1 + + # Compare priority + if "priority" in jira_issue["fields"] and jira_issue["fields"]["priority"] != None: + if ( + ( + jira_issue["fields"]["priority"]["name"] == "Highest" + and ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"] != 1 + ) + or ( + jira_issue["fields"]["priority"]["name"] == "High" + and ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"] != 2 + ) + or ( + jira_issue["fields"]["priority"]["name"] == "Medium" + and ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"] != 3 + ) + or ( + jira_issue["fields"]["priority"]["name"] == "Low" + and ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"] != 3 + ) + or ( + jira_issue["fields"]["priority"]["name"] == "Lowest" + and ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"] != 4 + ) + ): + exit_code = do_error( + "Problem for Jira issue '{0}': field '{1}' did not match the target work item. ('{2}' vs '{3}')".format( + jira_issue_mapped_title, + "Microsoft.VSTS.Common.Priority", + jira_issue["fields"]["priority"]["name"], + ado_work_item["fields"]["Microsoft.VSTS.Common.Priority"], + ) + ) + +exit(exit_code) \ No newline at end of file diff --git a/test/integration/users.txt b/test/integration/users.txt new file mode 100644 index 00000000..252f6f53 --- /dev/null +++ b/test/integration/users.txt @@ -0,0 +1,4 @@ +5dc52a54b8eb490c677a56dd=alexander.hjelm@solidify.dev +alexander.hjelm@solidify.dev=alexander.hjelm@solidify.dev +madis.koosaar=alexander.hjelm@solidify.dev +mathias.olausson@solidify.se=alexander.hjelm@solidify.dev \ No newline at end of file From 908433a3bd6b3b40e36b19d5e6f5065260099181 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 4 Dec 2023 21:13:54 +0100 Subject: [PATCH 29/44] Rename .azure-pipelines.yml > integration-test.yml --- test/integration/{.azure-pipelines.yml => integration-test.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/integration/{.azure-pipelines.yml => integration-test.yml} (100%) diff --git a/test/integration/.azure-pipelines.yml b/test/integration/integration-test.yml similarity index 100% rename from test/integration/.azure-pipelines.yml rename to test/integration/integration-test.yml From f3e1a0f9d1508efa704b2c89218e8a82cc6e9fcb Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 4 Dec 2023 22:44:33 +0100 Subject: [PATCH 30/44] Correct path to users.txt --- test/integration/integration-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/integration-test.yml b/test/integration/integration-test.yml index 84652766..1519da95 100644 --- a/test/integration/integration-test.yml +++ b/test/integration/integration-test.yml @@ -58,4 +58,4 @@ steps: inputs: scriptSource: 'filePath' scriptPath: '$(System.DefaultWorkingDirectory)\test\integration\smoke-tests.py' - arguments: '$(AdoOrganizationUrl) $(AdoProjectName) $(AdoApiToken) $(JiraUrl) $(JiraUser) $(JiraApiToken) $(JiraProject) $(System.DefaultWorkingDirectory)\test\integration\jira-migrator-workspace\users.txt' + arguments: '$(AdoOrganizationUrl) $(AdoProjectName) $(AdoApiToken) $(JiraUrl) $(JiraUser) $(JiraApiToken) $(JiraProject) $(System.DefaultWorkingDirectory)\test\integration\users.txt' From 373b9b090258f0a305b893a3f3d58523e7310716 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 4 Dec 2023 22:51:11 +0100 Subject: [PATCH 31/44] Create workspace in integration test pipeline --- test/integration/integration-test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/integration/integration-test.yml b/test/integration/integration-test.yml index 1519da95..22bd4b7b 100644 --- a/test/integration/integration-test.yml +++ b/test/integration/integration-test.yml @@ -47,6 +47,14 @@ steps: scriptPath: '$(System.DefaultWorkingDirectory)\test\integration\delete-work-items.py' arguments: '$(AdoOrganizationUrl) $(AdoProjectName) $(AdoApiToken)' +- task: PowerShell@2 + displayName: Create workspace and copy users.txt + inputs: + targetType: 'inline' + script: | + New-Item -Path "$(System.DefaultWorkingDirectory)" -Name "workspace" -ItemType Directory + Copy-Item "$(System.DefaultWorkingDirectory)\test\integration\users.txt" -Destination "$(System.DefaultWorkingDirectory)" + - script: $(System.DefaultWorkingDirectory)\src\WorkItemMigrator\JiraExport\bin\$(BuildConfiguration)\jira-export.exe -u $(JiraUser) -p $(JiraApiToken) --url $(JiraUrl) --config $(System.DefaultWorkingDirectory)\test\integration\config.json --force displayName: jira-export.exe From b203fd8070b910f2266410359207e2858f4b6125 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 4 Dec 2023 22:52:04 +0100 Subject: [PATCH 32/44] Integration tests correct path to workspace --- test/integration/integration-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/integration-test.yml b/test/integration/integration-test.yml index 22bd4b7b..91e5a5ea 100644 --- a/test/integration/integration-test.yml +++ b/test/integration/integration-test.yml @@ -20,7 +20,7 @@ steps: script: | $file = "$(System.DefaultWorkingDirectory)\test\integration\config.json" $str_find = "__workspace__" - $str_replace = "$(System.DefaultWorkingDirectory)\test\integration\jira-migrator-workspace" + $str_replace = "$(System.DefaultWorkingDirectory)\workspace" $str_replace = $str_replace -replace "\\", "\\" ((Get-Content -path $file -Raw) -replace $str_find, $str_replace) | Set-Content -Path $file cat $file @@ -53,7 +53,7 @@ steps: targetType: 'inline' script: | New-Item -Path "$(System.DefaultWorkingDirectory)" -Name "workspace" -ItemType Directory - Copy-Item "$(System.DefaultWorkingDirectory)\test\integration\users.txt" -Destination "$(System.DefaultWorkingDirectory)" + Copy-Item "$(System.DefaultWorkingDirectory)\test\integration\users.txt" -Destination "$(System.DefaultWorkingDirectory)\workspace" - script: $(System.DefaultWorkingDirectory)\src\WorkItemMigrator\JiraExport\bin\$(BuildConfiguration)\jira-export.exe -u $(JiraUser) -p $(JiraApiToken) --url $(JiraUrl) --config $(System.DefaultWorkingDirectory)\test\integration\config.json --force displayName: jira-export.exe From 105cdcd8fcf0a4e3128f6f6b099fb928b9c7274a Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Tue, 5 Dec 2023 17:04:21 +0100 Subject: [PATCH 33/44] Add support for the new Parent field --- .../RevisionUtils/LinkMapperUtils.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/WorkItemMigrator/JiraExport/RevisionUtils/LinkMapperUtils.cs b/src/WorkItemMigrator/JiraExport/RevisionUtils/LinkMapperUtils.cs index 7f743d34..59c93509 100644 --- a/src/WorkItemMigrator/JiraExport/RevisionUtils/LinkMapperUtils.cs +++ b/src/WorkItemMigrator/JiraExport/RevisionUtils/LinkMapperUtils.cs @@ -55,6 +55,8 @@ public static void AddRemoveSingleLink(JiraRevision r, List links, strin if (r.Fields.TryGetValue(field, out object value)) { + value = NumericCheckOnLinkTypeField(r, field, value); + var changeType = value == null ? ReferenceChangeType.Removed : ReferenceChangeType.Added; var linkType = (from t in config.LinkMap.Links where t.Source == type select t.Target).FirstOrDefault(); @@ -62,6 +64,11 @@ public static void AddRemoveSingleLink(JiraRevision r, List links, strin if (r.Index != 0) { var prevLinkValue = r.ParentItem.Revisions[r.Index - 1].GetFieldValue(field); + var prevLinkValueUnchecked = NumericCheckOnLinkTypeField(r, field, prevLinkValue); + if(prevLinkValueUnchecked != null) + { + prevLinkValue = prevLinkValueUnchecked.ToString(); + } // if previous value is not null, add removal of previous link if (!string.IsNullOrWhiteSpace(prevLinkValue)) { @@ -94,6 +101,24 @@ public static void AddRemoveSingleLink(JiraRevision r, List links, strin } } + private static object NumericCheckOnLinkTypeField(JiraRevision r, string field, object value) + { + // 2023-12-05: For later versions pf Jira cloud, the parent link/epic link fields have been replaced by a single + // field named "Parent". This is represented by the ParentItem field and r.field["parent"] instead holds the numeric ID. + // Here we ensure that what we get is the issue key + + if (value != null) + { + bool isNumeric = int.TryParse(value.ToString(), out int n); + if (isNumeric && field == "parent" && r.ParentItem != null && r.ParentItem.Parent != null) + { + value = r.ParentItem.Parent; + } + } + + return value; + } + public static void AddSingleLink(JiraRevision r, List links, string field, string type, ConfigJson config) { if (String.IsNullOrWhiteSpace(field)) From db96bcf0fa0744370dbbf90f42138c1865138710 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 18 Dec 2023 12:46:21 +0100 Subject: [PATCH 34/44] Add sprint map block to all config samples --- docs/Samples/config-agile.json | 6 ++++++ docs/Samples/config-basic.json | 6 ++++++ docs/Samples/config-cmmi.json | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/docs/Samples/config-agile.json b/docs/Samples/config-agile.json index 14d33760..3e9c70ea 100644 --- a/docs/Samples/config-agile.json +++ b/docs/Samples/config-agile.json @@ -79,6 +79,12 @@ "target": "System.AssignedTo", "mapper": "MapUser" }, + { + "source": "Sprint", + "source-type": "name", + "target": "System.IterationPath", + "mapper": "MapSprint" + }, { "source": "description", "target": "System.Description", diff --git a/docs/Samples/config-basic.json b/docs/Samples/config-basic.json index 1c48358c..34db0c26 100644 --- a/docs/Samples/config-basic.json +++ b/docs/Samples/config-basic.json @@ -75,6 +75,12 @@ "target": "System.AssignedTo", "mapper": "MapUser" }, + { + "source": "Sprint", + "source-type": "name", + "target": "System.IterationPath", + "mapper": "MapSprint" + }, { "source": "description", "target": "System.Description", diff --git a/docs/Samples/config-cmmi.json b/docs/Samples/config-cmmi.json index 198304ab..200b833c 100644 --- a/docs/Samples/config-cmmi.json +++ b/docs/Samples/config-cmmi.json @@ -103,6 +103,12 @@ "target": "System.AssignedTo", "mapper": "MapUser" }, + { + "source": "Sprint", + "source-type": "name", + "target": "System.IterationPath", + "mapper": "MapSprint" + }, { "source": "description", "target": "System.Description", From 9b7f622ddf859922c18050a511ae30d97d1995d6 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 18 Dec 2023 18:08:56 +0100 Subject: [PATCH 35/44] Update faq.md --- docs/faq.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index fc74a3cc..a26b4b62 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -247,3 +247,26 @@ Instead of the default: "mapper": "MapTitle" } ``` + +## 13. I get https response code 400 and a System.Aggregate Exception with the warning "Failed to get item count using query ...", and no items are exported. + +The issue is usually a malformed query. Mak sure that you have tried all of the following solutions: + +- Ensure that the `query` property in your `config.json` file follows correct [JQL syntax](https://www.atlassian.com/software/jira/guides/jql/overview) + - You can set up the corresponding JQL query in the issues view in your Jira project to debug the query. +- Ensure that you don't have any issues with [authorization](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#2-why-i-am-getting-unauthorized-exception-when-running-the-export). +- In the `project` clause of your query, try both the prject name, project key and project ID + +If all of the aboce suggestions fail, verifu that you are able to reach the issue search rest API endpoint outside of the Exporter. Try to see if you can set up a query in [postman](https://www.postman.com/) or similar, with the same JQL query as you are trying in your config.json-file, with the same user + API token/password and let me know the result of that. + +Here is an example in curl: + +```txt +curl -D- + -u johnie:johnie + -X POST + -H "Content-Type: application/json" + --data '{"jql":"project = QA","startAt":0,"maxResults":2,"fields":["id","key"]}' + + "http://johnie:8081/rest/api/2/search" +``` From 753a6b8ab737c23e8ce796bb602445dbc3f09825 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 18 Dec 2023 18:09:26 +0100 Subject: [PATCH 36/44] Update faq.md --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index a26b4b62..fe4b5bbe 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -248,7 +248,7 @@ Instead of the default: } ``` -## 13. I get https response code 400 and a System.Aggregate Exception with the warning "Failed to get item count using query ...", and no items are exported. +## 12. I get https response code 400 and a System.Aggregate Exception with the warning "Failed to get item count using query ...", and no items are exported. The issue is usually a malformed query. Mak sure that you have tried all of the following solutions: From d1b74af734c75c1809dda1b8ef24558cf7155b53 Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Tue, 19 Dec 2023 09:32:19 +0100 Subject: [PATCH 37/44] Update faq.md --- docs/faq.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index fe4b5bbe..06de3d8e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -250,14 +250,14 @@ Instead of the default: ## 12. I get https response code 400 and a System.Aggregate Exception with the warning "Failed to get item count using query ...", and no items are exported. -The issue is usually a malformed query. Mak sure that you have tried all of the following solutions: +The issue is usually a malformed query. Make sure that you have tried all of the following solutions: - Ensure that the `query` property in your `config.json` file follows correct [JQL syntax](https://www.atlassian.com/software/jira/guides/jql/overview) - You can set up the corresponding JQL query in the issues view in your Jira project to debug the query. - Ensure that you don't have any issues with [authorization](https://github.com/solidify/jira-azuredevops-migrator/blob/master/docs/faq.md#2-why-i-am-getting-unauthorized-exception-when-running-the-export). - In the `project` clause of your query, try both the prject name, project key and project ID -If all of the aboce suggestions fail, verifu that you are able to reach the issue search rest API endpoint outside of the Exporter. Try to see if you can set up a query in [postman](https://www.postman.com/) or similar, with the same JQL query as you are trying in your config.json-file, with the same user + API token/password and let me know the result of that. +If all of the aboce suggestions fail, verifu that you are able to reach the issue search rest API endpoint outside of the Exporter. Try to see if you can set up a Rest query in [postman](https://www.postman.com/) or similar, with the same JQL query as you are trying in your config.json-file, with the same user + API token/password and let me know the result of that. Here is an example in curl: From eb7160d6c9acadbf845b7cb86eb44c43e64c0bc9 Mon Sep 17 00:00:00 2001 From: Coby Pritchard Date: Thu, 21 Dec 2023 16:07:09 -0600 Subject: [PATCH 38/44] fixed issue with git links not in the correct format. --- .../WitClient/IWitClientWrapper.cs | 4 +++- .../WitClient/JsonPatchDocUtils.cs | 14 +++++------ .../WitClient/WitClientUtils.cs | 9 ++++--- .../WitClient/WitClientWrapper.cs | 9 ++++++- .../WitClient/JsonPatchDocUtilsTests.cs | 13 +++++----- .../WitClient/WitClientUtilsTests.cs | 24 ++++++++++++++++++- 6 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs index 486e270e..e38f6fb5 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs @@ -1,4 +1,5 @@ using Microsoft.TeamFoundation.Core.WebApi; +using Microsoft.TeamFoundation.SourceControl.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using Migration.WIContract; @@ -13,7 +14,8 @@ public interface IWitClientWrapper WorkItem GetWorkItem(int wiId); WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId); TeamProject GetProject(string projectId); - List GetRelationTypes(); + GitRepository GetRepository(string project, string repository); + List GetRelationTypes(); AttachmentReference CreateAttachment(WiAttachment attachment); } } diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs index 93c633fb..fabe991a 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs @@ -33,21 +33,21 @@ public static JsonPatchOperation CreateJsonFieldPatchOp(Operation op, string key }; } - public static JsonPatchOperation CreateJsonArtifactLinkPatchOp(Operation op, string project, string repository, string commitId) + public static JsonPatchOperation CreateJsonArtifactLinkPatchOp(Operation op, string projectId, string repositoryId, string commitId) { if (string.IsNullOrEmpty(commitId)) { throw new ArgumentException(nameof(commitId)); } - if (string.IsNullOrEmpty(project)) + if (string.IsNullOrEmpty(projectId)) { - throw new ArgumentException(nameof(project)); + throw new ArgumentException(nameof(projectId)); } - if (string.IsNullOrEmpty(repository)) + if (string.IsNullOrEmpty(repositoryId)) { - throw new ArgumentException(nameof(repository)); + throw new ArgumentException(nameof(repositoryId)); } return new JsonPatchOperation() @@ -57,8 +57,8 @@ public static JsonPatchOperation CreateJsonArtifactLinkPatchOp(Operation op, str Value = new PatchOperationValue { Rel = "ArtifactLink", - Url = $"vstfs:///Git/Commit/{project}/{repository}/{commitId}", - Attributes = new Attributes + Url = $"vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}", + Attributes = new Attributes { Name = "Fixed in Commit" } diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index a107dc3f..bed4fdad 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -682,10 +682,13 @@ public void SaveWorkItemArtifacts(WiRevision rev, WorkItem wi, Settings settings return; } - var patchDocument = new JsonPatchDocument + Guid projectId = _witClientWrapper.GetProject(settings.Project).Id; + Guid repositoryId = _witClientWrapper.GetRepository(settings.Project, rev.Commit.Repository).Id; + + var patchDocument = new JsonPatchDocument { - JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, settings.Project, rev.Commit.Repository, rev.Commit.Id), - JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedDate, rev.Time), + JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, projectId.ToString(), repositoryId.ToString(), rev.Commit.Id), + JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedDate, rev.Time), JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedBy, rev.Author) }; diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs index e09c2a20..1fa15781 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs @@ -1,4 +1,5 @@ using Microsoft.TeamFoundation.Core.WebApi; +using Microsoft.TeamFoundation.SourceControl.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; @@ -21,6 +22,7 @@ public class WitClientWrapper : IWitClientWrapper private ProjectHttpClient ProjectClient { get; } private VssConnection Connection { get; } private TeamProjectReference TeamProject { get; } + private GitHttpClient GitClient { get; } public WitClientWrapper(string collectionUri, string project, string personalAccessToken) { @@ -29,7 +31,8 @@ public WitClientWrapper(string collectionUri, string project, string personalAcc WitClient = Connection.GetClient(); ProjectClient = Connection.GetClient(); TeamProject = ProjectClient.GetProject(project).Result; - } + GitClient = Connection.GetClient(); + } public WorkItem CreateWorkItem(string wiType, DateTime? createdDate = null, string createdBy = "") { @@ -103,6 +106,10 @@ public TeamProject GetProject(string projectId) { return ProjectClient.GetProject(projectId).Result; } + public GitRepository GetRepository(string project, string repository) + { + return GitClient.GetRepositoryAsync(project, repository).Result; + } public List GetRelationTypes() { diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs index 36dd3e15..827ae756 100644 --- a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs @@ -58,10 +58,10 @@ public void When_calling_create_json_field_patch_op_Then_a_correct_op_is_returne [Test] public void When_calling_create_json_artifact_link_field_patch_op_Then_a_correct_op_is_returned() { - string project = "project"; - string repository = "repository"; - string commitId = "commitId"; - JsonPatchOperation jsonPatchOp = JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, project, repository, commitId); + string projectId = Guid.NewGuid().ToString(); + string repositoryId = Guid.NewGuid().ToString(); + string commitId = Guid.NewGuid().ToString(); + JsonPatchOperation jsonPatchOp = JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, projectId, repositoryId, commitId); PatchOperationValue artifactLink = jsonPatchOp.Value as PatchOperationValue; Assert.Multiple(() => @@ -69,8 +69,9 @@ public void When_calling_create_json_artifact_link_field_patch_op_Then_a_correct Assert.AreEqual(Operation.Add, jsonPatchOp.Operation); Assert.AreEqual("/relations/-", jsonPatchOp.Path); Assert.AreEqual("ArtifactLink", artifactLink.Rel); - Assert.AreEqual($"vstfs:///Git/Commit/{project}/{repository}/{commitId}", artifactLink.Url); - }); + Assert.AreEqual($"vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}", artifactLink.Url); + + }); } } } \ No newline at end of file diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs index ab6b306c..91b158f7 100644 --- a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using AutoFixture.AutoNSubstitute; using Microsoft.TeamFoundation.Core.WebApi; +using Microsoft.TeamFoundation.SourceControl.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.WebApi; using Microsoft.VisualStudio.Services.WebApi.Patch; @@ -12,6 +13,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Security.Cryptography; +using System.Text; using WorkItemImport; namespace Migration.Wi_Import.Tests @@ -124,7 +127,26 @@ public TeamProject GetProject(string projectId) return tp; } - public List GetRelationTypes() + public GitRepository GetRepository(string project, string repository) + { + GitRepository gr = new GitRepository(); + Guid repoGuid; + + // Create a new instance of the MD5CryptoServiceProvider object. + MD5 md5Hasher = MD5.Create(); + + // Convert the input string to a byte array and compute the hash. + byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(project)); + + // Create a new Guid using the hash value. + repoGuid = new Guid(md5Hasher.ComputeHash(Encoding.Default.GetBytes(project))); + gr.Id = repoGuid; + gr.Name = repository; + + return gr; + } + + public List GetRelationTypes() { WorkItemRelationType hierarchyForward = new WorkItemRelationType { From dbd0ddfa4bbaabb40d1138498c82fd4c003b97f4 Mon Sep 17 00:00:00 2001 From: Coby Pritchard Date: Thu, 21 Dec 2023 16:25:16 -0600 Subject: [PATCH 39/44] fix tab and space formatting issue. --- .../WitClient/IWitClientWrapper.cs | 4 +-- .../WitClient/JsonPatchDocUtils.cs | 4 +-- .../WitClient/WitClientUtils.cs | 10 +++---- .../WitClient/JsonPatchDocUtilsTests.cs | 3 +-- .../WitClient/WitClientUtilsTests.cs | 26 +++++++++---------- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs index e38f6fb5..77c45bbf 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs @@ -14,8 +14,8 @@ public interface IWitClientWrapper WorkItem GetWorkItem(int wiId); WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId); TeamProject GetProject(string projectId); - GitRepository GetRepository(string project, string repository); - List GetRelationTypes(); + GitRepository GetRepository(string project, string repository); + List GetRelationTypes(); AttachmentReference CreateAttachment(WiAttachment attachment); } } diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs index fabe991a..04e845b4 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/JsonPatchDocUtils.cs @@ -57,8 +57,8 @@ public static JsonPatchOperation CreateJsonArtifactLinkPatchOp(Operation op, str Value = new PatchOperationValue { Rel = "ArtifactLink", - Url = $"vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}", - Attributes = new Attributes + Url = $"vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}", + Attributes = new Attributes { Name = "Fixed in Commit" } diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs index bed4fdad..59b2ac96 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -682,13 +682,13 @@ public void SaveWorkItemArtifacts(WiRevision rev, WorkItem wi, Settings settings return; } - Guid projectId = _witClientWrapper.GetProject(settings.Project).Id; - Guid repositoryId = _witClientWrapper.GetRepository(settings.Project, rev.Commit.Repository).Id; + Guid projectId = _witClientWrapper.GetProject(settings.Project).Id; + Guid repositoryId = _witClientWrapper.GetRepository(settings.Project, rev.Commit.Repository).Id; - var patchDocument = new JsonPatchDocument + var patchDocument = new JsonPatchDocument { - JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, projectId.ToString(), repositoryId.ToString(), rev.Commit.Id), - JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedDate, rev.Time), + JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, projectId.ToString(), repositoryId.ToString(), rev.Commit.Id), + JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedDate, rev.Time), JsonPatchDocUtils.CreateJsonFieldPatchOp(Operation.Add, WiFieldReference.ChangedBy, rev.Author) }; diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs index 827ae756..cbcd0fc0 100644 --- a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/JsonPatchDocUtilsTests.cs @@ -70,8 +70,7 @@ public void When_calling_create_json_artifact_link_field_patch_op_Then_a_correct Assert.AreEqual("/relations/-", jsonPatchOp.Path); Assert.AreEqual("ArtifactLink", artifactLink.Rel); Assert.AreEqual($"vstfs:///Git/Commit/{projectId}%2F{repositoryId}%2F{commitId}", artifactLink.Url); - - }); + }); } } } \ No newline at end of file diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs index 91b158f7..823165f9 100644 --- a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs @@ -127,26 +127,26 @@ public TeamProject GetProject(string projectId) return tp; } - public GitRepository GetRepository(string project, string repository) - { - GitRepository gr = new GitRepository(); - Guid repoGuid; + public GitRepository GetRepository(string project, string repository) + { + GitRepository gr = new GitRepository(); + Guid repoGuid; - // Create a new instance of the MD5CryptoServiceProvider object. - MD5 md5Hasher = MD5.Create(); + // Create a new instance of the MD5CryptoServiceProvider object. + MD5 md5Hasher = MD5.Create(); - // Convert the input string to a byte array and compute the hash. - byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(project)); + // Convert the input string to a byte array and compute the hash. + byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(project)); - // Create a new Guid using the hash value. - repoGuid = new Guid(md5Hasher.ComputeHash(Encoding.Default.GetBytes(project))); + // Create a new Guid using the hash value. + repoGuid = new Guid(md5Hasher.ComputeHash(Encoding.Default.GetBytes(project))); gr.Id = repoGuid; gr.Name = repository; - return gr; - } + return gr; + } - public List GetRelationTypes() + public List GetRelationTypes() { WorkItemRelationType hierarchyForward = new WorkItemRelationType { From cf8d9cdddf4c4919562c5bc6e024d60654c04671 Mon Sep 17 00:00:00 2001 From: Coby Pritchard Date: Thu, 21 Dec 2023 16:29:36 -0600 Subject: [PATCH 40/44] fix tab and space formatting issue. --- .../WorkItemImport/WitClient/WitClientWrapper.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs index 1fa15781..9369fc0e 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs @@ -22,7 +22,7 @@ public class WitClientWrapper : IWitClientWrapper private ProjectHttpClient ProjectClient { get; } private VssConnection Connection { get; } private TeamProjectReference TeamProject { get; } - private GitHttpClient GitClient { get; } + private GitHttpClient GitClient { get; } public WitClientWrapper(string collectionUri, string project, string personalAccessToken) { @@ -31,8 +31,8 @@ public WitClientWrapper(string collectionUri, string project, string personalAcc WitClient = Connection.GetClient(); ProjectClient = Connection.GetClient(); TeamProject = ProjectClient.GetProject(project).Result; - GitClient = Connection.GetClient(); - } + GitClient = Connection.GetClient(); + } public WorkItem CreateWorkItem(string wiType, DateTime? createdDate = null, string createdBy = "") { @@ -106,10 +106,10 @@ public TeamProject GetProject(string projectId) { return ProjectClient.GetProject(projectId).Result; } - public GitRepository GetRepository(string project, string repository) - { - return GitClient.GetRepositoryAsync(project, repository).Result; - } + public GitRepository GetRepository(string project, string repository) + { + return GitClient.GetRepositoryAsync(project, repository).Result; + } public List GetRelationTypes() { From 77b0dbadcacb72d8f4dd36e1448280bf32a6cacc Mon Sep 17 00:00:00 2001 From: Coby Pritchard Date: Thu, 21 Dec 2023 16:31:34 -0600 Subject: [PATCH 41/44] fix tab and space formatting issue. --- .../WorkItemImport/WitClient/WitClientWrapper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs index 9369fc0e..80fc52a1 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs @@ -106,6 +106,7 @@ public TeamProject GetProject(string projectId) { return ProjectClient.GetProject(projectId).Result; } + public GitRepository GetRepository(string project, string repository) { return GitClient.GetRepositoryAsync(project, repository).Result; From 816864be18124700d484a9370feb9df5d0fad8f2 Mon Sep 17 00:00:00 2001 From: SilentSmuggler Date: Thu, 21 Dec 2023 18:18:38 -0600 Subject: [PATCH 42/44] Update WitClientWrapper.cs Enhanced the efficiency of the WitClientWrapper class by introducing caching mechanisms for project and repository retrieval. This update avoids repetitive calls to ProjectClient.GetProject and GitClient.GetRepositoryAsync for the same project or repository, reducing unnecessary network traffic and improving overall performance. Added concurrent dictionaries to safely cache TeamProject and GitRepository objects, with checks in place to retrieve from cache before making external calls. This change ensures a more responsive and resource-efficient interaction with the Azure DevOps services. --- .../WitClient/WitClientWrapper.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs index 80fc52a1..9dbc960a 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs @@ -9,6 +9,7 @@ using Migration.Common.Log; using Migration.WIContract; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Threading; @@ -18,6 +19,10 @@ namespace WorkItemImport { public class WitClientWrapper : IWitClientWrapper { + // Cache fields + private ConcurrentDictionary _projectCache = new ConcurrentDictionary(); + private ConcurrentDictionary _repositoryCache = new ConcurrentDictionary(); + private WorkItemTrackingHttpClient WitClient { get; } private ProjectHttpClient ProjectClient { get; } private VssConnection Connection { get; } @@ -104,12 +109,32 @@ public WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId) public TeamProject GetProject(string projectId) { - return ProjectClient.GetProject(projectId).Result; + // Check cache first + if (_projectCache.TryGetValue(projectId, out var cachedProject)) + { + return cachedProject; + } + + // If not in cache, fetch and store in cache + var project = ProjectClient.GetProject(projectId).Result; + _projectCache[projectId] = project; + return project; } public GitRepository GetRepository(string project, string repository) { - return GitClient.GetRepositoryAsync(project, repository).Result; + string cacheKey = $"{project}-{repository}"; + + // Check cache first + if (_repositoryCache.TryGetValue(cacheKey, out var cachedRepository)) + { + return cachedRepository; + } + + // If not in cache, fetch and store in cache + var repo = GitClient.GetRepositoryAsync(project, repository).Result; + _repositoryCache[cacheKey] = repo; + return repo; } public List GetRelationTypes() From d5b420dc524f15df38d02a89ba861937f769ffb3 Mon Sep 17 00:00:00 2001 From: mammabear123 <127075115+mammabear123@users.noreply.github.com> Date: Fri, 22 Dec 2023 16:26:21 +0800 Subject: [PATCH 43/44] NotFor matches should still depend on Target Field name. --- .../RevisionUtils/FieldMapperUtils.cs | 11 +- .../JiraValueMapperTests.cs | 280 ++++++++++++++++++ 2 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraValueMapperTests.cs diff --git a/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs b/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs index 4de29ddb..c9f932c2 100644 --- a/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs +++ b/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs @@ -70,11 +70,13 @@ public static (bool, object) MapValue(JiraRevision r, string itemSource, string if (!hasFieldValue) return (false, null); - foreach (var item in config.FieldMap.Fields) + foreach (var item in config.FieldMap.Fields.Where(i => i.Mapping?.Values != null)) { - if ((((item.Source == itemSource && item.Target == itemTarget) && (item.For.Contains(targetWit) || item.For == "All")) || - item.Source == itemSource && (!string.IsNullOrWhiteSpace(item.NotFor) && !item.NotFor.Contains(targetWit))) && - item.Mapping?.Values != null) + var sourceAndTargetMatch = item.Source == itemSource && item.Target == itemTarget; + var forOrAllMatch = item.For.Contains(targetWit) || item.For == "All"; // matches "For": "All", or when this Wit is specifically named. + var notForMatch = !string.IsNullOrWhiteSpace(item.NotFor) && !item.NotFor.Contains(targetWit); // matches if not-for is specified and doesn't contain this Wit. + + if (sourceAndTargetMatch && (forOrAllMatch || notForMatch)) { if (value == null) { @@ -89,7 +91,6 @@ public static (bool, object) MapValue(JiraRevision r, string itemSource, string } } return (true, value); - } public static (bool, object) MapRenderedValue(JiraRevision r, string sourceField, bool isCustomField, string customFieldName, ConfigJson config) diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraValueMapperTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraValueMapperTests.cs new file mode 100644 index 00000000..8a10a274 --- /dev/null +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraValueMapperTests.cs @@ -0,0 +1,280 @@ +using NUnit.Framework; +using NSubstitute; +using AutoFixture; +using Common.Config; +using JiraExport; +using Migration.Common.Config; +using System.Collections.Generic; +using System; +using Newtonsoft.Json.Linq; +using Type = Migration.Common.Config.Type; +using AutoFixture.AutoNSubstitute; + +namespace Migration.Jira_Export.Tests +{ + + [TestFixture] + public class JiraValueMapperTests + { + // use auto fixture to help mock and instantiate with dummy data with nsubsitute. + private Fixture _fixture; + private ConfigJson _config; + private JiraItem _item; + private IJiraProvider _provider; + + [SetUp] + public void SetupValueMapperTests() + { + _fixture = new Fixture(); + _fixture.Customize(new AutoNSubstituteCustomization() { }); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _config = new ConfigJson + { + TypeMap = new TypeMap + { + Types = new List + { + new Type { Source = "Bug", Target = "Defect" }, + new Type { Source = "Task", Target = "Work Item" } + } + }, + FieldMap = new FieldMap + { + Fields = new List + { + new Field + { + Source = "Priority", + Target = "Severity", + For = "All", + Mapping = new Mapping + { + Values = new List + { + new Value { Source = "High", Target = "Critical" }, + new Value { Source = "Medium", Target = "Major" }, + new Value { Source = "Low", Target = "Minor" } + } + } + }, + new Field + { + Source = "Status", + Target = "State", + For = "Defect", + Mapping = new Mapping + { + Values = new List + { + new Value { Source = "Open", Target = "Active" }, + new Value { Source = "In Progress", Target = "In Development" }, + new Value { Source = "Resolved", Target = "Fixed" }, + new Value { Source = "Closed", Target = "Closed" } + } + } + }, + new Field + { + Source = "Empty", + Target = "Mapping", + Mapping = null + } + } + } + }; + + _provider = CreateJiraProvider(); + string issueKey = "issue_key"; + _item = JiraItem.CreateFromRest(issueKey, _provider); + } + + [Test] + public void MapValue_WithNullRevision_ThrowsArgumentNullException() + { + // Arrange + JiraRevision revision = null; + string itemSource = "Priority"; + string itemTarget = "Severity"; + + // Act & Assert + Assert.Throws(() => FieldMapperUtils.MapValue(revision, itemSource, itemTarget, _config)); + } + + [Test] + public void MapValue_WithNullConfig_ThrowsArgumentNullException() + { + // Arrange + ConfigJson config = null; + string itemSource = "Priority"; + string itemTarget = "Severity"; + var revision = new JiraRevision(_item) + { + Fields = new Dictionary + { + { "Priority", "High" }, + { "Status", "Open" } + } + }; + + // Act & Assert + Assert.Throws(() => FieldMapperUtils.MapValue(revision, itemSource, itemTarget, config)); + } + + [Test] + public void MapValue_WithNonExistingField_ReturnsFalseAndNull() + { + // Arrange + string itemSource = "NonExistingField"; + string itemTarget = "Severity"; + var revision = new JiraRevision(_item) + { + Fields = new Dictionary + { + { "Priority", "High" }, + { "Status", "Open" } + } + }; + + // Act + var result = FieldMapperUtils.MapValue(revision, itemSource, itemTarget, _config); + + // Assert + Assert.IsFalse(result.Item1); + Assert.IsNull(result.Item2); + } + + [Test] + public void MapValue_WithExistingFieldAndMapping_ReturnsTrueAndMappedValue() + { + // Arrange + string itemSource = "Priority"; + string itemTarget = "Severity"; + var revision = new JiraRevision(_item) + { + Fields = new Dictionary + { + { "Priority", "High" }, + { "Status", "Open" } + } + }; + + // Act + var result = FieldMapperUtils.MapValue(revision, itemSource, itemTarget, _config); + + // Assert + Assert.IsTrue(result.Item1); + Assert.AreEqual("Critical", result.Item2); + } + + [Test] + public void MapValue_WithMatchesNotForButTargetDoesNotMatch_ReturnsFalseAndNull() + { + // Arrange + string itemSource = "Status"; + string itemTarget = "target"; + var revision = new JiraRevision(_item) // type is Bug by default; + { + Fields = new Dictionary + { + { "Status", "Open" }, + { "SomethingElse", "SomethingElse" } + } + }; + var typeMap = new TypeMap + { + Types = new List + { + new Type { Source = "Bug", Target = "Bug" }, + new Type { Source = "Task", Target = "Work Item" } + } + }; + var fieldConfig = new Field + { + Source = "Status", + Target = "XXXNotStateXXX", + NotFor = "Defect", + Mapping = new Mapping + { + Values = new List + { + new Value { Source = "Open", Target = "Active" }, + new Value { Source = "In Progress", Target = "In Development" }, + new Value { Source = "Resolved", Target = "Fixed" }, + new Value { Source = "Closed", Target = "Closed" } + } + } + }; + + var config = new ConfigJson(); + config.FieldMap = new FieldMap(); + config.FieldMap.Fields = new List { fieldConfig }; + config.TypeMap = typeMap; + + // Act + var result = FieldMapperUtils.MapValue(revision, itemSource, itemTarget, config); + + // Assert + Assert.IsTrue(result.Item1); + Assert.AreNotEqual("Active", result.Item2); + Assert.AreEqual("Open", result.Item2); // no mapping should have taken place + + } + + [Test] + public void MapValue_WithExistingFieldAndNoMapping_ReturnsTrueAndOriginalValue() + { + // Arrange + string itemSource = "FieldWithNoMapping"; + string itemTarget = "Target"; + var revision = new JiraRevision(_item) + { + Fields = new Dictionary + { + { "Priority", "High" }, + { "Status", "Open" }, + { "FieldWithNoMapping", "SourceValue" } + } + }; + + // Act + var result = FieldMapperUtils.MapValue(revision, itemSource, itemTarget, _config); + + // Assert + Assert.IsTrue(result.Item1); + Assert.AreEqual("SourceValue", result.Item2); + } + + private JiraSettings CreateJiraSettings() + { + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); + settings.EpicLinkField = "Epic Link"; + settings.SprintField = "SprintField"; + + return settings; + } + + private IJiraProvider CreateJiraProvider(JObject remoteIssue = null) + { + IJiraProvider provider = Substitute.For(); + provider.GetSettings().ReturnsForAnyArgs(CreateJiraSettings()); + provider.DownloadIssue(default).ReturnsForAnyArgs(remoteIssue ?? CreateRemoteIssueJObject()); + + return provider; + } + + private JObject CreateRemoteIssueJObject(string workItemType = "Bug", string issueKey = "issue_key") + { + var issueType = JObject.Parse("{ 'issuetype': {'name': '"+ workItemType +"'}}"); + var renderedFields = JObject.Parse("{ 'custom_field_name': 'SomeValue', 'description': 'RenderedDescription' }"); + + return new JObject + { + { "fields", issueType }, + { "renderedFields", renderedFields }, + { "key", issueKey } + }; + + } + } +} \ No newline at end of file From 4f5f3cff6413251f0cc705e36b2587bc072249ca Mon Sep 17 00:00:00 2001 From: Alexander Hjelm Date: Mon, 25 Dec 2023 09:31:06 +0100 Subject: [PATCH 44/44] Fix up unit tests --- .../WitClient/WitClientUtilsTests.cs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs index 823165f9..6034ad8b 100644 --- a/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Wi-Import.Tests/WitClient/WitClientUtilsTests.cs @@ -13,8 +13,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Security.Cryptography; -using System.Text; using WorkItemImport; namespace Migration.Wi_Import.Tests @@ -26,6 +24,8 @@ public class WitClientUtilsTests private class MockedWitClientWrapper : IWitClientWrapper { private int _wiIdCounter = 1; + public Guid projectId = Guid.NewGuid(); + public Guid repositoryId = Guid.NewGuid(); public Dictionary _wiCache = new Dictionary(); public MockedWitClientWrapper() @@ -121,7 +121,7 @@ public TeamProject GetProject(string projectId) } else { - tp.Id = Guid.NewGuid(); + tp.Id = this.projectId; tp.Name = projectId; } return tp; @@ -130,17 +130,7 @@ public TeamProject GetProject(string projectId) public GitRepository GetRepository(string project, string repository) { GitRepository gr = new GitRepository(); - Guid repoGuid; - - // Create a new instance of the MD5CryptoServiceProvider object. - MD5 md5Hasher = MD5.Create(); - - // Convert the input string to a byte array and compute the hash. - byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(project)); - - // Create a new Guid using the hash value. - repoGuid = new Guid(md5Hasher.ComputeHash(Encoding.Default.GetBytes(project))); - gr.Id = repoGuid; + gr.Id = repositoryId; gr.Name = repository; return gr; @@ -1127,7 +1117,8 @@ public void When_calling_save_workitem_artifacts_with_populated_workitem_Then_wo Assert.Multiple(() => { Assert.That(updatedWI.Relations.First().Rel, Is.EqualTo("ArtifactLink")); - Assert.That(updatedWI.Relations.First().Url, Is.EqualTo("vstfs:///Git/Commit/project/repository/1234567890")); + Assert.That(updatedWI.Relations.First().Url, Is.EqualTo($"vstfs:///Git/Commit/" + + $"{witClientWrapper.projectId}%2F{witClientWrapper.repositoryId}%2F{revision.Commit.Id}")); }); }