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 '....' diff --git a/docs/Samples/config-agile.json b/docs/Samples/config-agile.json index 7fe3ff6f..4f6f0c34 100644 --- a/docs/Samples/config-agile.json +++ b/docs/Samples/config-agile.json @@ -17,6 +17,7 @@ "include-link-comments": false, "include-jira-css-styles": false, "ignore-empty-revisions": false, + "suppress-notifications": false, "sleep-time-between-revision-import-milliseconds": 0, "process-template": "Agile", "link-map": { @@ -29,6 +30,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" @@ -75,6 +80,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 2b0b3295..6078ecf7 100644 --- a/docs/Samples/config-basic.json +++ b/docs/Samples/config-basic.json @@ -17,6 +17,7 @@ "include-link-comments": false, "include-jira-css-styles": false, "ignore-empty-revisions": false, + "suppress-notifications": false, "sleep-time-between-revision-import-milliseconds": 0, "process-template": "Basic", "link-map": { @@ -29,6 +30,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" @@ -71,6 +76,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..f464c47e 100644 --- a/docs/Samples/config-cmmi.json +++ b/docs/Samples/config-cmmi.json @@ -17,6 +17,7 @@ "include-link-comments": false, "include-jira-css-styles": false, "ignore-empty-revisions": false, + "suppress-notifications": false, "sleep-time-between-revision-import-milliseconds": 0, "process-template": "CMMI", "link-map": { @@ -103,6 +104,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-scrum.json b/docs/Samples/config-scrum.json index 74a57e72..cff88dcb 100644 --- a/docs/Samples/config-scrum.json +++ b/docs/Samples/config-scrum.json @@ -17,6 +17,7 @@ "include-link-comments": false, "include-jira-css-styles": false, "ignore-empty-revisions": false, + "suppress-notifications": false, "sleep-time-between-revision-import-milliseconds": 0, "process-template": "Scrum", "link-map": { @@ -29,6 +30,10 @@ "source": "Parent", "target": "System.LinkTypes.Hierarchy-Reverse" }, + { + "source": "Child", + "target": "System.LinkTypes.Hierarchy-Forward" + }, { "source": "Relates", "target": "System.LinkTypes.Related" @@ -227,8 +232,9 @@ { "source": "description", "target": "Microsoft.VSTS.TCM.ReproSteps", - "for": "Bug" + "for": "Bug", + "mapper": "MapRendered" } ] } -} \ No newline at end of file +} diff --git a/docs/config.md b/docs/config.md index 1acb61fa..0afc5393 100644 --- a/docs/config.md +++ b/docs/config.md @@ -35,6 +35,7 @@ The migration configuration file is defined in a json file with the properties d |**include-link-comments**|False|boolean|Set to True to get a verbose comment on the work item for every work item link created. Default = True.| |**include-jira-css-styles**|True|boolean|Set to True to generate and include confluence CSS Stylesheets for description, repro steps and comments. Default = True.| |**ignore-empty-revisions**|False|boolean|Set to True to ignore importing empty revisions. Empty revisions will be created if you have historical revisions where none of the changed fields or links have been mapped. This may indicate that you have unmapped data, which will not be migrated. Default = False.| +|**suppress-notifications**|False|boolean|Set to True to suppress all notifications in Azure DevOps about created and updated Work Items. Default = False.| |**sleep-time-between-revision-import-milliseconds**|False|integer|How many milliseconds to sleep between each revision import. Use this if throttling is an issue for ADO Services. Default = 0 (no sleep).| |**process-template**|False|string|Process template in the target DevOps project. Supported values: Scrum, Agile or CMMI. Default = "Scrum".| |**link-map**|True|json|List of **links** to map between Jira and Azure DevOps/TFS work item link types.| @@ -120,6 +121,7 @@ Mappers are functions used byt he **Jira Exporter** for transforming the data in |MapArray|Maps an array by replacing comma with semi-colon| |MapRemainingWork|Maps and converts a Jira time to hours| |MapRendered|Maps field to rendered html format value| +|MapLexoRank|Maps and converts a Jira LexoRank to decimal. When mapping this type of field, ensure the correct Jira custom field is used and mapped to the relevant Azure DevOps prioritization field (see: https://learn.microsoft.com/en-us/azure/devops/boards/queries/planning-ranking-priorities?view=azure-devops#fields-used-to-plan-and-prioritize-work)| |(default)|Simply copies source to target| ## Example configuration @@ -349,6 +351,11 @@ Mappers are functions used byt he **Jira Exporter** for transforming the data in "source": "description", "target": "Microsoft.VSTS.TCM.ReproSteps", "for": "Bug" + }, + { + "source": "customfield_10015", + "target": "Microsoft.VSTS.Common.BacklogPriority", + "mapper": "MapLexoRank" } ] } diff --git a/docs/faq.md b/docs/faq.md index 94cb851b..af8b4f0a 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -33,14 +33,15 @@ 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" + "target": "Custom.TargetField" }, +``` -- 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: @@ -51,7 +52,53 @@ Example: } ``` -## 4. How to migrate custom fields having dropdown lists? +### (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). + +### (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) (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 + +### 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 (all parents, epic links and sub items) by setting the following property in the configuration file: + +```json + "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 off all linked issues (parents, epic links and sub items) by setting the following property in the configuration file: + +```json + "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. +- 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 +115,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 +155,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 +180,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 +193,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 +205,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: @@ -179,3 +226,61 @@ 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" + } +``` + +## 12. How to limit the number of issues to be exported during JIRA export (pagination) + +If you export or the whole migration takes too long, you can achieve something similar to pagination by limiting the export to batches of issues through the `query` property of your `config.json` file. Simply enter a JQL query that filters issues on the `ìd` property, for example: + +``` +project = "PROJECTKEY" AND id >= 10000 AND id < 11000 +project = "PROJECTKEY" AND id >= 11000 AND id < 12000 +project = "PROJECTKEY" AND id >= 12000 AND id < 13000 +``` + +And so on. + +You can always use the **issues** view in your Jira project to experiment with different JQL queries. + +## 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. 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 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: + +```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" +``` diff --git a/docs/jira-export.md b/docs/jira-export.md index 7e0c235e..5a9b471d 100644 --- a/docs/jira-export.md +++ b/docs/jira-export.md @@ -5,18 +5,27 @@ 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|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)| -## 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 +``` 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/PRICING.md b/src/WorkItemMigrator.Extension/PRICING.md new file mode 100644 index 00000000..192b326f --- /dev/null +++ b/src/WorkItemMigrator.Extension/PRICING.md @@ -0,0 +1,19 @@ +### 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. + +[Contact us](mailto:support.jira-migrator@solidify.dev) for more information. diff --git a/src/WorkItemMigrator.Extension/README.md b/src/WorkItemMigrator.Extension/README.md index b2ecd4b4..19a631ca 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 diff --git a/src/WorkItemMigrator.Extension/images/extension-icon.png b/src/WorkItemMigrator.Extension/images/extension-icon.png index 27064ab0..25a58ccd 100644 Binary files a/src/WorkItemMigrator.Extension/images/extension-icon.png and b/src/WorkItemMigrator.Extension/images/extension-icon.png differ diff --git a/src/WorkItemMigrator.Extension/vss-extension.json b/src/WorkItemMigrator.Extension/vss-extension.json index 5c509daf..6c00a9ba 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", @@ -57,6 +57,9 @@ }, "privacypolicy": { "path": "PRIVACY.md" + }, + "pricing": { + "path": "PRICING.md" } } } diff --git a/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs b/src/WorkItemMigrator/JiraExport/JiraCommandLine.cs index 34b6fa27..47cddfb3 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, @@ -154,7 +155,7 @@ private bool ExecuteMigration(CommandOption user, CommandOption password, Comman } finally { - EndSession(itemsCount, sw); + EndSession(exportedItemsCount, sw); } return succeeded; } @@ -224,7 +225,7 @@ private static void BeginSession(string configFile, ConfigJson config, bool forc var user = $"{System.Environment.UserDomainName}\\{System.Environment.UserName}"; var jiraVersion = jiraProvider.GetJiraVersion(); - Logger.Log(LogLevel.Info, $"Export started. Exporting {itemsCount} items."); + Logger.Log(LogLevel.Info, $"Export started. Selecting {itemsCount} items."); Logger.StartSession("Jira Export", "jira-export-started", @@ -250,15 +251,15 @@ private static void BeginSession(string configFile, ConfigJson config, bool forc { "hosting-type", jiraVersion.DeploymentType } }); } - private static void EndSession(int itemsCount, Stopwatch sw) + private static void EndSession(int exportedItemsCount, Stopwatch sw) { sw.Stop(); - Logger.Log(LogLevel.Info, $"Export complete. Exported {itemsCount} items ({Logger.Errors} errors, {Logger.Warnings} warnings) in {string.Format("{0:hh\\:mm\\:ss}", sw.Elapsed)}."); + Logger.Log(LogLevel.Info, $"Export complete. Exported {exportedItemsCount} items ({Logger.Errors} errors, {Logger.Warnings} warnings) in {string.Format("{0:hh\\:mm\\:ss}", sw.Elapsed)}."); Logger.EndSession("jira-export-completed", new Dictionary() { - { "item-count", itemsCount.ToString() }, + { "item-count", exportedItemsCount.ToString() }, { "error-count", Logger.Errors.ToString() }, { "warning-count", Logger.Warnings.ToString() }, { "elapsed-time", string.Format("{0:hh\\:mm\\:ss}", sw.Elapsed) }}); diff --git a/src/WorkItemMigrator/JiraExport/JiraItem.cs b/src/WorkItemMigrator/JiraExport/JiraItem.cs index 44221efe..19b2215f 100644 --- a/src/WorkItemMigrator/JiraExport/JiraItem.cs +++ b/src/WorkItemMigrator/JiraExport/JiraItem.cs @@ -34,23 +34,7 @@ private static List BuildRevisions(JiraItem jiraItem, IJiraProvide { string issueKey = jiraItem.Key; var remoteIssue = jiraItem.RemoteIssue; - Dictionary fieldsTemp = ExtractFields(issueKey, remoteIssue, jiraProvider); - - // Add CustomFieldName fields, copy over all non-custom fields. - // These get removed as we loop over the changeLog, so we're left with the original Jira values by the time we reach firstRevision. - Dictionary fields = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - foreach (var field in fieldsTemp) - { - var key = GetCustomFieldName(field.Key, jiraProvider); - if (!String.IsNullOrEmpty(key)) - { - fields[key] = field.Value; - } - else - { - fields[field.Key] = field.Value; - } - } + Dictionary fields = ExtractFields(issueKey, remoteIssue, jiraProvider); List attachments = ExtractAttachments(remoteIssue.SelectTokens("$.fields.attachment[*]").Cast()) ?? new List(); List links = ExtractLinks(issueKey, remoteIssue.SelectTokens("$.fields.issuelinks[*]").Cast()) ?? new List(); @@ -332,11 +316,7 @@ private static (string, string, string) TransformFieldChange(JiraChangeItem item private static string GetCustomFieldId(string fieldName, IJiraProvider jira) { - var customField = jira.GetCustomField(fieldName); - if (customField != null) - return customField.Id; - else return null; - + return jira.GetCustomId(fieldName); } protected static string GetCustomFieldName(string fieldId, IJiraProvider jira) @@ -485,12 +465,19 @@ private static Dictionary ExtractFields(string key, JObject remo { value = prop.Value.Value(); } - // User picker - else if (type == JTokenType.Object && prop.Value["accountId"] != null + // User picker, server ('name' check) + cloud ('accountId' check) + else if (type == JTokenType.Object && (prop.Value["accountId"] != null || prop.Value["name"] != null) + && prop.Value["emailAddress"] != null && prop.Value["avatarUrls"] != null + && prop.Value["displayName"] != null) + { + value = extractAccountIdOrUsername(prop.Value); + } + // 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["accountId"].ToString(); + value = prop.Value["key"].ToString(); } else if (prop.Value.Type == JTokenType.Date) { diff --git a/src/WorkItemMigrator/JiraExport/JiraMapper.cs b/src/WorkItemMigrator/JiraExport/JiraMapper.cs index edd73cbe..08123a56 100644 --- a/src/WorkItemMigrator/JiraExport/JiraMapper.cs +++ b/src/WorkItemMigrator/JiraExport/JiraMapper.cs @@ -75,6 +75,9 @@ internal Dictionary> InitializeFieldMappings( if (item.Source != null) { var isCustomField = item.SourceType == "name"; + if (isCustomField && _jiraProvider.GetCustomId(item.Source) == null) + Logger.Log(LogLevel.Warning, $"Could not find the field id for '{item.Source}', please check the field mapping!"); + Func value; if (item.Mapping?.Values != null) @@ -109,6 +112,9 @@ internal Dictionary> InitializeFieldMappings( case "MapRendered": value = r => FieldMapperUtils.MapRenderedValue(r, item.Source, isCustomField, _jiraProvider.GetCustomId(item.Source), _config); break; + case "MapLexoRank": + value = IfChanged(item.Source, isCustomField, FieldMapperUtils.MapLexoRank); + break; default: value = IfChanged(item.Source, isCustomField); break; @@ -308,7 +314,10 @@ internal List MapFields(JiraRevision r) if (include) { value = TruncateField(value, fieldreference); - + if(value == null) + { + value = ""; + } Logger.Log(LogLevel.Debug, $"Mapped value '{value}' to field '{fieldreference}'."); fields.Add(new WiField() { @@ -369,29 +378,14 @@ private HashSet InitializeTypeMappings() private Func IfChanged(string sourceField, bool isCustomField, Func mapperFunc = null) { - // Store both the customFieldName and the sourceField as the changelog seems to only use the customFieldName, which is then passed into this function as the sourceField. - string customFieldName = ""; if (isCustomField) { - customFieldName = _jiraProvider.GetCustomId(sourceField); + sourceField = _jiraProvider.GetCustomId(sourceField) ?? sourceField; } return (r) => { - object value; - // This sourceField is often actually the customFieldName. - if (r.Fields.TryGetValue(sourceField.ToLower(), out value)) - { - if (mapperFunc != null) - { - return (true, mapperFunc((T)value)); - } - else - { - return (true, (T)value); - } - } - else if (r.Fields.TryGetValue(customFieldName.ToLower(), out value)) + if (r.Fields.TryGetValue(sourceField.ToLower(), out object value)) { if (mapperFunc != null) { @@ -446,4 +440,4 @@ internal object TruncateField(object value, string field) return valueStr; } } -} \ No newline at end of file +} 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/JiraExport/RevisionUtils/Base36.cs b/src/WorkItemMigrator/JiraExport/RevisionUtils/Base36.cs new file mode 100644 index 00000000..273eaaf6 --- /dev/null +++ b/src/WorkItemMigrator/JiraExport/RevisionUtils/Base36.cs @@ -0,0 +1,38 @@ +/* + * Code taken from: https://github.com/bogdanbujdea/csharpbase36/blob/master/Base36Encoder/Base36.cs + */ + +using System.Linq; +using System.Numerics; +using System; + +namespace JiraExport.RevisionUtils +{ + public static class Base36 + { + private const string Digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + public static long Decode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Empty value."); + value = value.ToUpper(); + var negative = false; + if (value[0] == '-') + { + negative = true; + value = value.Substring(1, value.Length - 1); + } + if (value.Any(c => !Digits.Contains(c))) + throw new ArgumentException("Invalid value: \"" + value + "\"."); + + var decoded = 0L; + for (var i = 0; i < value.Length; ++i) + { + decoded += Digits.IndexOf(value[i]) * (long) BigInteger.Pow(Digits.Length, value.Length - i - 1); + } + + return negative ? decoded * -1 : decoded; + } + } +} diff --git a/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs b/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs index 4de29ddb..8b575bfd 100644 --- a/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs +++ b/src/WorkItemMigrator/JiraExport/RevisionUtils/FieldMapperUtils.cs @@ -1,4 +1,5 @@ using Common.Config; +using JiraExport.RevisionUtils; using Migration.Common; using Migration.Common.Config; using Migration.Common.Log; @@ -6,6 +7,7 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -70,11 +72,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 +93,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) @@ -175,6 +178,51 @@ public static object MapSprint(string iterationPathsString) return iterationPath; } + private static readonly Dictionary CalculatedLexoRanks = new Dictionary(); + private static readonly Dictionary CalculatedRanks = new Dictionary(); + + private static readonly Regex LexoRankRegex = new Regex(@"^[0-2]\|[0-9a-zA-Z]*(\:[0-9a-zA-Z]*)?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + + public static object MapLexoRank(string lexoRank) + { + if (string.IsNullOrEmpty(lexoRank) || !LexoRankRegex.IsMatch(lexoRank)) + return decimal.MaxValue; + + if (CalculatedLexoRanks.ContainsKey(lexoRank)) + { + Logger.Log(LogLevel.Warning, "Duplicate rank detected. You may need to re-balance the JIRA LexoRank. see: https://confluence.atlassian.com/adminjiraserver/managing-lexorank-938847803.html"); + return CalculatedLexoRanks[lexoRank]; + } + + // split by bucket and sub-rank delimiters + var lexoSplit = lexoRank.Split(new[] {'|', ':'}, StringSplitOptions.RemoveEmptyEntries); + + // calculate the numeric value of the rank and sub-rank (if available) + var b36Rank = Base36.Decode(lexoSplit[1]); + var b36SubRank = lexoSplit.Length == 3 && !string.IsNullOrEmpty(lexoSplit[2]) + ? Base36.Decode(lexoSplit[2]) + : 0L; + + // calculate final rank value + var rank = Math.Round( + Convert.ToDecimal($"{b36Rank}.{b36SubRank}", CultureInfo.InvariantCulture.NumberFormat), + 7 // DevOps seems to ignore anything over 7 decimal places long + ); + + if (CalculatedRanks.ContainsKey(rank) && CalculatedRanks[rank] != lexoRank) + { + Logger.Log(LogLevel.Warning, "Duplicate rank detected for different LexoRank values. You may need to re-balance the JIRA LexoRank. see: https://confluence.atlassian.com/adminjiraserver/managing-lexorank-938847803.html"); + } + else + { + CalculatedRanks.Add(rank, lexoRank); + } + + CalculatedLexoRanks.Add(lexoRank, rank); + return rank; + } + public static string CorrectRenderedHtmlvalue(object value, JiraRevision revision, bool includeJiraStyle) { if (value == null) 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)) diff --git a/src/WorkItemMigrator/JiraExport/jira-export.csproj b/src/WorkItemMigrator/JiraExport/jira-export.csproj index bfa309d4..3fd3a7ac 100644 --- a/src/WorkItemMigrator/JiraExport/jira-export.csproj +++ b/src/WorkItemMigrator/JiraExport/jira-export.csproj @@ -101,6 +101,7 @@ ..\packages\System.Diagnostics.DiagnosticSource.4.5.0\lib\net46\System.Diagnostics.DiagnosticSource.dll + @@ -125,6 +126,7 @@ + diff --git a/src/WorkItemMigrator/JiraExport/packages.config b/src/WorkItemMigrator/JiraExport/packages.config index dc62d103..3dd17692 100644 --- a/src/WorkItemMigrator/JiraExport/packages.config +++ b/src/WorkItemMigrator/JiraExport/packages.config @@ -8,4 +8,5 @@ + \ No newline at end of file diff --git a/src/WorkItemMigrator/Migration.Common/Config/ConfigJson.cs b/src/WorkItemMigrator/Migration.Common/Config/ConfigJson.cs index 7902326b..dcb9ba66 100644 --- a/src/WorkItemMigrator/Migration.Common/Config/ConfigJson.cs +++ b/src/WorkItemMigrator/Migration.Common/Config/ConfigJson.cs @@ -83,5 +83,9 @@ public class ConfigJson [JsonProperty(PropertyName = "ignore-empty-revisions")] public bool IgnoreEmptyRevisions { get; set; } = false; + + [JsonProperty(PropertyName = "suppress-notifications")] + public bool SuppressNotifications { get; set; } = false; + } } diff --git a/src/WorkItemMigrator/Migration.WIContract/WiField.cs b/src/WorkItemMigrator/Migration.WIContract/WiField.cs index 2f4db59e..7882d8d3 100644 --- a/src/WorkItemMigrator/Migration.WIContract/WiField.cs +++ b/src/WorkItemMigrator/Migration.WIContract/WiField.cs @@ -37,6 +37,7 @@ public static class WiFieldReference public static string BoardColumn => "System.BoardColumn"; public static string BoardColumnDone => "System.BoardColumnDone"; public static string BoardLane => "System.BoardLane"; + public static string AcceptanceCriteria => "Microsoft.VSTS.Common.AcceptanceCriteria"; } } \ No newline at end of file diff --git a/src/WorkItemMigrator/WorkItemImport/Agent.cs b/src/WorkItemMigrator/WorkItemImport/Agent.cs index 0bce88e8..4bf74883 100644 --- a/src/WorkItemMigrator/WorkItemImport/Agent.cs +++ b/src/WorkItemMigrator/WorkItemImport/Agent.cs @@ -52,9 +52,9 @@ public WorkItem GetWorkItem(int wiId) return _witClientUtils.GetWorkItem(wiId); } - public WorkItem CreateWorkItem(string type, DateTime createdDate, string createdBy) + public WorkItem CreateWorkItem(string type, bool suppressNotifications, DateTime createdDate, string createdBy) { - return _witClientUtils.CreateWorkItem(type, createdDate, createdBy); + return _witClientUtils.CreateWorkItem(type, suppressNotifications, createdDate, createdBy); } public bool ImportRevision(WiRevision rev, WorkItem wi, Settings settings) @@ -82,7 +82,7 @@ public bool ImportRevision(WiRevision rev, WorkItem wi, Settings settings) if (rev.Fields.Any() && !UpdateWIHistoryField(rev.Fields, wi)) incomplete = true; - if (rev.Links.Any() && !ApplyAndSaveLinks(rev, wi, settings.IncludeLinkComments)) + if (rev.Links.Any() && !ApplyAndSaveLinks(rev, wi, settings)) incomplete = true; if (incomplete) @@ -94,7 +94,21 @@ public bool ImportRevision(WiRevision rev, WorkItem wi, Settings settings) _witClientUtils.CorrectComment(wi, _context.GetItem(rev.ParentOriginId), rev, _context.Journal.IsAttachmentMigrated); } - _witClientUtils.SaveWorkItemAttachments(rev, wi); + if (wi.Fields.ContainsKey(WiFieldReference.AcceptanceCriteria) && !string.IsNullOrEmpty(wi.Fields[WiFieldReference.AcceptanceCriteria].ToString())) + { + Logger.Log(LogLevel.Debug, $"Correcting acceptance criteria on separate revision on '{rev}'."); + + try + { + _witClientUtils.CorrectAcceptanceCriteria(wi, _context.GetItem(rev.ParentOriginId), rev, _context.Journal.IsAttachmentMigrated); + } + catch (Exception ex) + { + Logger.Log(ex, $"Failed to correct acceptance criteria for '{wi.Id}', rev '{rev}'."); + } + } + + _witClientUtils.SaveWorkItemAttachments(rev, wi, settings); foreach (string attOriginId in rev.Attachments.Select(wiAtt => wiAtt.AttOriginId)) { @@ -114,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 @@ -126,7 +168,7 @@ public bool ImportRevision(WiRevision rev, WorkItem wi, Settings settings) } else { - _witClientUtils.SaveWorkItemFields(wi); + _witClientUtils.SaveWorkItemFields(wi, settings); } if (wi.Id.HasValue) @@ -538,7 +580,7 @@ private bool UpdateWIFields(IEnumerable fields, WorkItem wi) return success; } - private bool ApplyAndSaveLinks(WiRevision rev, WorkItem wi, bool addLinkComments) + private bool ApplyAndSaveLinks(WiRevision rev, WorkItem wi, Settings settings) { bool success = true; @@ -562,11 +604,11 @@ private bool ApplyAndSaveLinks(WiRevision rev, WorkItem wi, bool addLinkComments continue; } - if (link.Change == ReferenceChangeType.Added && !_witClientUtils.AddAndSaveLink(link, wi)) + if (link.Change == ReferenceChangeType.Added && !_witClientUtils.AddAndSaveLink(link, wi, settings)) { success = false; } - else if (link.Change == ReferenceChangeType.Removed && !_witClientUtils.RemoveAndSaveLink(link, wi)) + else if (link.Change == ReferenceChangeType.Removed && !_witClientUtils.RemoveAndSaveLink(link, wi, settings)) { success = false; } @@ -578,7 +620,7 @@ private bool ApplyAndSaveLinks(WiRevision rev, WorkItem wi, bool addLinkComments } } - if (addLinkComments) + if (settings.IncludeLinkComments) { if (rev.Links.Any(l => l.Change == ReferenceChangeType.Removed)) wi.Fields[WiFieldReference.History] = $"Removed link(s): {string.Join(";", rev.Links.Where(l => l.Change == ReferenceChangeType.Removed).Select(l => l.ToString()))}"; diff --git a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs index cdf9a4b5..f5833cb6 100644 --- a/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs +++ b/src/WorkItemMigrator/WorkItemImport/ImportCommandLine.cs @@ -90,7 +90,9 @@ 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, + SuppressNotifications = config.SuppressNotifications }; // initialize Azure DevOps/TFS connection. Creates/fetches project, fills area and iteration caches. @@ -119,6 +121,17 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt continue; } + WorkItem wi = null; + + if (executionItem.WiId > 0) + wi = agent.GetWorkItem(executionItem.WiId); + else + wi = agent.CreateWorkItem(executionItem.WiType, settings.SuppressNotifications, executionItem.Revision.Time, executionItem.Revision.Author); + + Logger.Log(LogLevel.Info, $"Processing {importedItems + 1}/{revisionCount} - wi '{(wi.Id > 0 ? wi.Id.ToString() : "Initial revision")}', jira '{executionItem.OriginId}, rev {executionItem.Revision.Index}'."); + + importedItems++; + if (config.IgnoreEmptyRevisions && executionItem.Revision.Fields.Count == 0 && executionItem.Revision.Links.Count == 0 && @@ -128,17 +141,7 @@ private bool ExecuteMigration(CommandOption token, CommandOption url, CommandOpt continue; } - WorkItem wi = null; - - if (executionItem.WiId > 0) - wi = agent.GetWorkItem(executionItem.WiId); - else - wi = agent.CreateWorkItem(executionItem.WiType, executionItem.Revision.Time, executionItem.Revision.Author); - - 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++; // Artifical wait (optional) to avoid throttling for ADO Services if (config.SleepTimeBetweenRevisionImportMilliseconds > 0) diff --git a/src/WorkItemMigrator/WorkItemImport/Settings.cs b/src/WorkItemMigrator/WorkItemImport/Settings.cs index 168ed5d5..0c47a33e 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,7 @@ 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; } + public bool SuppressNotifications { get; internal set; } } } \ No newline at end of file diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/IWitClientWrapper.cs index 486e270e..cdaf1b03 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; @@ -9,10 +10,11 @@ namespace WorkItemImport { public interface IWitClientWrapper { - WorkItem CreateWorkItem(string wiType, DateTime? createdDate = null, string createdBy = ""); + WorkItem CreateWorkItem(string wiType, bool suppressNotifications, DateTime? createdDate = null, string createdBy = ""); WorkItem GetWorkItem(int wiId); - WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId); + WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId, bool suppressNotifications); TeamProject GetProject(string projectId); + 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..04e845b4 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,7 +57,7 @@ public static JsonPatchOperation CreateJsonArtifactLinkPatchOp(Operation op, str Value = new PatchOperationValue { Rel = "ArtifactLink", - Url = $"vstfs:///Git/Commit/{project}/{repository}/{commitId}", + 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 7235012a..219ca303 100644 --- a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs +++ b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientUtils.cs @@ -25,9 +25,9 @@ public WitClientUtils(IWitClientWrapper witClientWrapper) public delegate V IsAttachmentMigratedDelegate(T input, out U output); - public WorkItem CreateWorkItem(string type, DateTime? createdDate = null, string createdBy = "") + public WorkItem CreateWorkItem(string type, bool suppressNotifications, DateTime? createdDate = null, string createdBy = "") { - return _witClientWrapper.CreateWorkItem(type, createdDate, createdBy); + return _witClientWrapper.CreateWorkItem(type, suppressNotifications, createdDate, createdBy); } public bool IsDuplicateWorkItemLink(IEnumerable links, WorkItemRelation relatedLink) @@ -48,7 +48,7 @@ public bool IsDuplicateWorkItemLink(IEnumerable links, WorkIte return true; } - public bool AddAndSaveLink(WiLink link, WorkItem wi) + public bool AddAndSaveLink(WiLink link, WorkItem wi, Settings settings) { if (link == null) { @@ -75,17 +75,28 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) if (!IsDuplicateWorkItemLink(wi.Relations, relatedLink)) { wi.Relations.Add(relatedLink); - AddSingleLinkToWorkItemAndSave(link, wi, targetWorkItem, "Imported link from JIRA"); + AddSingleLinkToWorkItemAndSave(link, wi, targetWorkItem, settings, "Imported link from JIRA"); return true; } return false; } 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")) + { + ForceSwapLinkAndSave(link, wi, ex2, settings, "Forward", GetWorkItem(link.TargetWiId), "child"); + } + else if (ex2.Message.Contains("TF201036: You cannot add a Parent link between work items")) + { + ForceSwapLinkAndSave(link, wi, ex2, settings, "Reverse", GetWorkItem(link.SourceWiId), "parent"); + } + else + { + Logger.Log(LogLevel.Error, ex2.Message); + } } return false; } @@ -100,7 +111,44 @@ public bool AddAndSaveLink(WiLink link, WorkItem wi) } - public bool RemoveAndSaveLink(WiLink link, WorkItem wi) + private void ForceSwapLinkAndSave(WiLink link, WorkItem wi, Exception ex2, Settings settings, 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, settings); + + // 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, settings); + 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, Settings settings) { if (link == null) { @@ -120,7 +168,7 @@ public bool RemoveAndSaveLink(WiLink link, WorkItem wi) Logger.Log(LogLevel.Warning, $"{link} - cannot identify link to remove for '{wi.Id}'."); return false; } - RemoveSingleLinkFromWorkItemAndSave(link, wi); + RemoveSingleLinkFromWorkItemAndSave(link, wi, settings); wi.Relations.Remove(linkToRemove); return true; } @@ -415,6 +463,72 @@ 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) + { + throw new ArgumentException(nameof(wi)); + } + + if (wiItem == null) + { + throw new ArgumentException(nameof(wiItem)); + } + + if (rev == null) + { + throw new ArgumentException(nameof(rev)); + } + + string acceptanceCriteria = wi.Fields[WiFieldReference.AcceptanceCriteria].ToString(); + if (string.IsNullOrWhiteSpace(acceptanceCriteria)) + return false; + + bool updated = false; + + CorrectImagePath(wi, wiItem, rev, ref acceptanceCriteria, ref updated, isAttachmentMigratedDelegate); + + if (updated) + { + wi.Fields[WiFieldReference.AcceptanceCriteria] = acceptanceCriteria; + } + + return updated; + } + public void CorrectComment(WorkItem wi, WiItem wiItem, WiRevision rev, IsAttachmentMigratedDelegate isAttachmentMigratedDelegate) { if (wi == null) @@ -445,7 +559,7 @@ public WorkItem GetWorkItem(int wiId) return _witClientWrapper.GetWorkItem(wiId); } - public void SaveWorkItemAttachments(WiRevision rev, WorkItem wi) + public void SaveWorkItemAttachments(WiRevision rev, WorkItem wi, Settings settings) { if (rev == null) { @@ -475,7 +589,7 @@ public void SaveWorkItemAttachments(WiRevision rev, WorkItem wi) { try { - AddSingleAttachmentToWorkItemAndSave(attachment, wi, attachmentUpdatedDate, rev.Author); + AddSingleAttachmentToWorkItemAndSave(attachment, wi, settings, attachmentUpdatedDate, rev.Author); } catch (AggregateException e) { @@ -500,12 +614,12 @@ public void SaveWorkItemAttachments(WiRevision rev, WorkItem wi) } else if (attachment.Change == ReferenceChangeType.Removed) { - RemoveSingleAttachmentFromWorkItemAndSave(attachment, wi, attachmentUpdatedDate, rev.Author); + RemoveSingleAttachmentFromWorkItemAndSave(attachment, wi, settings, attachmentUpdatedDate, rev.Author); } } } - public void SaveWorkItemFields(WorkItem wi) + public void SaveWorkItemFields(WorkItem wi, Settings settings) { if (wi == null) { @@ -542,7 +656,7 @@ public void SaveWorkItemFields(WorkItem wi) try { if (wi.Id.HasValue) - _witClientWrapper.UpdateWorkItem(patchDocument, wi.Id.Value); + _witClientWrapper.UpdateWorkItem(patchDocument, wi.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {wi.Url}"); } @@ -568,9 +682,12 @@ 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; + var patchDocument = new JsonPatchDocument { - JsonPatchDocUtils.CreateJsonArtifactLinkPatchOp(Operation.Add, settings.Project, rev.Commit.Repository, rev.Commit.Id), + 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) }; @@ -578,7 +695,7 @@ public void SaveWorkItemArtifacts(WiRevision rev, WorkItem wi, Settings settings try { if (wi.Id.HasValue) - _witClientWrapper.UpdateWorkItem(patchDocument, wi.Id.Value); + _witClientWrapper.UpdateWorkItem(patchDocument, wi.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {wi.Url}"); } @@ -694,12 +811,12 @@ private void CorrectActivatedByAndActivatedDate(WiRevision rev, WorkItem wi) if (!wiState.Equals("New", StringComparison.InvariantCultureIgnoreCase) && revState.Equals("New", StringComparison.InvariantCultureIgnoreCase)) { - rev.Fields.Add(new WiField() { ReferenceName = WiFieldReference.ActivatedDate, Value = null }); - rev.Fields.Add(new WiField() { ReferenceName = WiFieldReference.ActivatedBy, Value = null }); + rev.Fields.Add(new WiField() { ReferenceName = WiFieldReference.ActivatedDate, Value = "" }); + rev.Fields.Add(new WiField() { ReferenceName = WiFieldReference.ActivatedBy, Value = "" }); } } - private void AddSingleAttachmentToWorkItemAndSave(WiAttachment att, WorkItem wi, DateTime? changedDate = null, string changedBy = "") + private void AddSingleAttachmentToWorkItemAndSave(WiAttachment att, WorkItem wi, Settings settings, DateTime? changedDate = null, string changedBy = "") { // Upload attachment AttachmentReference attachment = _witClientWrapper.CreateAttachment(att); @@ -756,7 +873,7 @@ private void AddSingleAttachmentToWorkItemAndSave(WiAttachment att, WorkItem wi, WorkItem result = null; if (wi.Id.HasValue) - result = _witClientWrapper.UpdateWorkItem(attachmentPatchDocument, wi.Id.Value); + result = _witClientWrapper.UpdateWorkItem(attachmentPatchDocument, wi.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {wi.Url}"); @@ -772,7 +889,7 @@ private void AddSingleAttachmentToWorkItemAndSave(WiAttachment att, WorkItem wi, wi.Fields[WiFieldReference.ChangedDate] = result.Fields[WiFieldReference.ChangedDate]; } - private void RemoveSingleAttachmentFromWorkItemAndSave(WiAttachment att, WorkItem wi, DateTime changedDate = default, string changedBy = default) + private void RemoveSingleAttachmentFromWorkItemAndSave(WiAttachment att, WorkItem wi, Settings settings, DateTime changedDate = default, string changedBy = default) { WorkItemRelation existingAttachmentRelation = wi.Relations?.SingleOrDefault( @@ -821,7 +938,7 @@ private void RemoveSingleAttachmentFromWorkItemAndSave(WiAttachment att, WorkIte WorkItem result = null; if (wi.Id.HasValue) - result = _witClientWrapper.UpdateWorkItem(attachmentPatchDocument, wi.Id.Value); + result = _witClientWrapper.UpdateWorkItem(attachmentPatchDocument, wi.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {wi.Url}"); @@ -833,7 +950,7 @@ private void RemoveSingleAttachmentFromWorkItemAndSave(WiAttachment att, WorkIte wi.Relations = result.Relations; } - private void AddSingleLinkToWorkItemAndSave(WiLink link, WorkItem sourceWI, WorkItem targetWI, string comment) + private void AddSingleLinkToWorkItemAndSave(WiLink link, WorkItem sourceWI, WorkItem targetWI, Settings settings, string comment) { // Create a patch document for a new work item. // Specify a relation to the existing work item. @@ -856,14 +973,14 @@ private void AddSingleLinkToWorkItemAndSave(WiLink link, WorkItem sourceWI, Work }; if (sourceWI.Id.HasValue) - _witClientWrapper.UpdateWorkItem(linkPatchDocument, sourceWI.Id.Value); + _witClientWrapper.UpdateWorkItem(linkPatchDocument, sourceWI.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {sourceWI.Url}"); Logger.Log(LogLevel.Info, $"Updated new work item Id:{sourceWI.Id} with link to work item ID:{targetWI.Id}"); } - private void RemoveSingleLinkFromWorkItemAndSave(WiLink link, WorkItem sourceWI) + private void RemoveSingleLinkFromWorkItemAndSave(WiLink link, WorkItem sourceWI, Settings settings) { WorkItemRelation rel = sourceWI.Relations.SingleOrDefault(a => a.Rel == link.WiType @@ -889,7 +1006,7 @@ private void RemoveSingleLinkFromWorkItemAndSave(WiLink link, WorkItem sourceWI) }; if (sourceWI.Id.HasValue) - _witClientWrapper.UpdateWorkItem(linkPatchDocument, sourceWI.Id.Value); + _witClientWrapper.UpdateWorkItem(linkPatchDocument, sourceWI.Id.Value, settings.SuppressNotifications); else throw new MissingFieldException($"Work item ID was null: {sourceWI.Url}"); diff --git a/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs b/src/WorkItemMigrator/WorkItemImport/WitClient/WitClientWrapper.cs index e09c2a20..51626335 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; @@ -8,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; @@ -17,10 +19,15 @@ 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; } private TeamProjectReference TeamProject { get; } + private GitHttpClient GitClient { get; } public WitClientWrapper(string collectionUri, string project, string personalAccessToken) { @@ -29,9 +36,10 @@ 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 = "") + public WorkItem CreateWorkItem(string wiType, bool suppressNotifications, DateTime? createdDate = null, string createdBy = "") { JsonPatchDocument patchDoc = new JsonPatchDocument { @@ -63,7 +71,7 @@ public WorkItem CreateWorkItem(string wiType, DateTime? createdDate = null, stri WorkItem wiOut; try { - wiOut = WitClient.CreateWorkItemAsync(document: patchDoc, project: TeamProject.Name, type: wiType, bypassRules: true, expand: WorkItemExpand.All).Result; + wiOut = WitClient.CreateWorkItemAsync(document: patchDoc, project: TeamProject.Name, type: wiType, bypassRules: true, suppressNotifications: suppressNotifications, expand: WorkItemExpand.All).Result; } catch (Exception e) { @@ -94,14 +102,39 @@ public WorkItem GetWorkItem(int wiId) return wiOut; } - public WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId) + public WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId, bool suppressNotifications) { - return WitClient.UpdateWorkItemAsync(document: patchDocument, id: workItemId, bypassRules: true, expand: WorkItemExpand.All).Result; + return WitClient.UpdateWorkItemAsync(document: patchDocument, id: workItemId, suppressNotifications, bypassRules: true, expand: WorkItemExpand.All).Result; } 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) + { + 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() diff --git a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs index 4d3ba3c4..1c516460 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraItemTests.cs @@ -577,7 +577,7 @@ public void When_an_epic_link_was_removed_Then_the_result_should_be_successful() } [Test] - public void When_a_custom_field_is_added_Then_a_customfield_is_added_to_the_revision_with_name_as_key() + public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_revision_with_name_as_key() { //Arrange var provider = _fixture.Freeze(); @@ -586,7 +586,7 @@ public void When_a_custom_field_is_added_Then_a_customfield_is_added_to_the_revi string customFieldId = _fixture.Create(); string customFieldName = _fixture.Create(); - var fields = JObject.Parse(@"{'issuetype': {'name': 'Story'},'" + customFieldId + @"': {'name':'SomeValue'}}"); + var fields = JObject.Parse(@"{'issuetype': {'name': 'Story'},'" + customFieldId + @"': {'name':'SomeValue', 'key':'" + customFieldId + "'}}"); var renderedFields = new JObject(); var changelog = new List(); @@ -604,10 +604,7 @@ public void When_a_custom_field_is_added_Then_a_customfield_is_added_to_the_revi var jiraSettings = createJiraSettings(); provider.GetSettings().ReturnsForAnyArgs(jiraSettings); - RemoteField remoteField = new RemoteField(); - remoteField.id = customFieldId; - remoteField.name = customFieldName; - CustomField customField = new CustomField(remoteField); + CustomField customField = null; provider.GetCustomField(default).ReturnsForAnyArgs(customField); @@ -618,13 +615,12 @@ public void When_a_custom_field_is_added_Then_a_customfield_is_added_to_the_revi Assert.Multiple(() => { Assert.AreEqual(1, jiraItem.Revisions.Count); - Assert.IsFalse(jiraItem.Revisions[0].Fields.Any(f => f.Key == customFieldId)); - Assert.IsTrue(jiraItem.Revisions[0].Fields.Any(f => f.Key == customFieldName)); + Assert.IsFalse(jiraItem.Revisions[0].Fields.Any(f => f.Key == customFieldName)); }); } [Test] - public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_revision_with_name_as_key() + public void When_an_custom_field_is_changed_Then_it_should_have_the_previous_value_in_the_initial_revision() { //Arrange var provider = _fixture.Freeze(); @@ -632,11 +628,24 @@ public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_rev string issueKey = _fixture.Create(); string customFieldId = _fixture.Create(); string customFieldName = _fixture.Create(); + string customFieldPreviousValue = _fixture.Create(); + string customFieldNewValue = _fixture.Create(); - var fields = JObject.Parse(@"{'issuetype': {'name': 'Story'},'" + customFieldId + @"': {'name':'SomeValue', 'key':'" + customFieldId + "'}}"); + var fields = JObject.Parse(@"{'issuetype': {'name': 'Story'},'" + customFieldId + @"': '" + customFieldNewValue + @"', 'key':'" + issueKey + "'}"); var renderedFields = new JObject(); - var changelog = new List(); + var changelog = new List() + { + new HistoryItem() + { + Field = customFieldName, + FieldType = "custom", + From = customFieldPreviousValue, + FromString = customFieldPreviousValue, + To = customFieldNewValue, + ToString = customFieldNewValue + }.ToJObject() + }; JObject remoteIssue = new JObject { @@ -651,9 +660,61 @@ public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_rev var jiraSettings = createJiraSettings(); provider.GetSettings().ReturnsForAnyArgs(jiraSettings); - CustomField customField = null; + provider.GetCustomId(customFieldName).Returns(customFieldId); - provider.GetCustomField(default).ReturnsForAnyArgs(customField); + //Act + var jiraItem = JiraItem.CreateFromRest(issueKey, provider); + + //Assert + Assert.Multiple(() => + { + Assert.AreEqual(2, jiraItem.Revisions.Count); + Assert.IsTrue(jiraItem.Revisions[0].Fields.ContainsKey(customFieldId)); + Assert.AreEqual(customFieldPreviousValue, jiraItem.Revisions[0].Fields[customFieldId]); + Assert.IsTrue(jiraItem.Revisions[1].Fields.ContainsKey(customFieldId)); + Assert.AreEqual(customFieldNewValue, jiraItem.Revisions[1].Fields[customFieldId]); + }); + } + + [Test] + public void When_an_custom_field_is_added_and_changed_later_Then_it_should_not_be_in_the_initial_revision() + { + //Arrange + var provider = _fixture.Freeze(); + long issueId = _fixture.Create(); + string issueKey = _fixture.Create(); + string customFieldId = _fixture.Create(); + string customFieldName = _fixture.Create(); + string customFieldNewValue = _fixture.Create(); + + var fields = JObject.Parse(@"{'issuetype': {'name': 'Story'},'" + customFieldId + @"': '" + customFieldNewValue + @"', 'key':'" + issueKey + "'}"); + var renderedFields = new JObject(); + + var changelog = new List() + { + new HistoryItem() + { + Field = customFieldName, + FieldType = "custom", + To = customFieldNewValue, + ToString = customFieldNewValue + }.ToJObject() + }; + + JObject remoteIssue = new JObject + { + { "id", issueId }, + { "key", issueKey }, + { "fields", fields }, + { "renderedFields", renderedFields } + }; + + provider.DownloadIssue(default).ReturnsForAnyArgs(remoteIssue); + provider.DownloadChangelog(default).ReturnsForAnyArgs(changelog); + var jiraSettings = createJiraSettings(); + provider.GetSettings().ReturnsForAnyArgs(jiraSettings); + + provider.GetCustomId(customFieldName).Returns(customFieldId); //Act var jiraItem = JiraItem.CreateFromRest(issueKey, provider); @@ -661,14 +722,18 @@ public void When_a_custom_field_is_added_Then_no_customfield_is_added_to_the_rev //Assert Assert.Multiple(() => { - Assert.AreEqual(1, jiraItem.Revisions.Count); - Assert.IsFalse(jiraItem.Revisions[0].Fields.Any(f => f.Key == customFieldName)); + Assert.AreEqual(2, jiraItem.Revisions.Count); + Assert.IsFalse(jiraItem.Revisions[0].Fields.ContainsKey(customFieldId)); + Assert.IsFalse(jiraItem.Revisions[0].Fields.ContainsKey(customFieldName)); + Assert.IsTrue(jiraItem.Revisions[1].Fields.ContainsKey(customFieldId)); + Assert.IsFalse(jiraItem.Revisions[1].Fields.ContainsKey(customFieldName)); + Assert.AreEqual(customFieldNewValue, jiraItem.Revisions[1].Fields[customFieldId]); }); } 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 e4802815..3ab9aa52 100644 --- a/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs +++ b/src/WorkItemMigrator/tests/Migration.Jira-Export.Tests/JiraMapperTests.cs @@ -232,8 +232,8 @@ public void When_calling_initializefieldmappings_Then_the_expected_result_is_ret private JiraSettings createJiraSettings() { - JiraSettings settings = new JiraSettings("userID", "pass", "url", "project"); - settings.EpicLinkField = "EpicLinkField"; + JiraSettings settings = new JiraSettings("userID", "pass", "token", "url", "project"); + settings.EpicLinkField = "Epic Link"; settings.SprintField = "SprintField"; return settings; 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/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 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..d32a1a00 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); @@ -463,6 +463,27 @@ public void When_calling_map_rendered_value_with_null_arguments_Then_and_excepti Assert.Throws(() => { FieldMapperUtils.MapRenderedValue(null, null, false, null, null); }); } + [TestCase("2|hzyxfj:", 1088341183.0)] + [TestCase("2|hzyxfj:rx4", 1088341183.36184)] + public void When_calling_map_lexorank_value_with_valid_argument_Then_the_correct_value_is_returned(string lexoRank, decimal expectedRank) + { + Assert.That(FieldMapperUtils.MapLexoRank(lexoRank), Is.EqualTo(expectedRank)); + } + [TestCase(null)] + [TestCase("Hello World")] + [TestCase("2|jghhdf kjh dkjh sd")] + [TestCase("2|hzyxfj:rx4:bt5")] + public void When_calling_map_lexorank_value_with_invalid_argument_Then_max_value_is_returned(string lexoRank) + { + Assert.That(FieldMapperUtils.MapLexoRank(lexoRank), Is.EqualTo(decimal.MaxValue)); + } + + [Test] + public void + When_calling_map_lexorank_value_with_over_precise_argument_Then_the_correct_devops_precision_value_is_returned() + { + Assert.That(FieldMapperUtils.MapLexoRank("0|hzyxfj:hzyxfj"), Is.EqualTo(1088341183.1088341M)); + } } } \ No newline at end of file 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); 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..cbcd0fc0 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,7 +69,7 @@ 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); }); } } 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 105a75fb..64a00ca1 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; @@ -23,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() @@ -30,7 +33,7 @@ public MockedWitClientWrapper() } - public WorkItem CreateWorkItem(string wiType, DateTime? createdDate = null, string createdBy = "") + public WorkItem CreateWorkItem(string wiType, bool suppressNotifications, DateTime? createdDate = null, string createdBy = "") { WorkItem workItem = new WorkItem(); workItem.Id = _wiIdCounter; @@ -51,7 +54,7 @@ public WorkItem GetWorkItem(int wiId) return _wiCache[wiId]; } - public WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId) + public WorkItem UpdateWorkItem(JsonPatchDocument patchDocument, int workItemId, bool suppressNotifications) { WorkItem wi = _wiCache[workItemId]; foreach (JsonPatchOperation op in patchDocument) @@ -118,12 +121,21 @@ public TeamProject GetProject(string projectId) } else { - tp.Id = Guid.NewGuid(); + tp.Id = this.projectId; tp.Name = projectId; } return tp; } + public GitRepository GetRepository(string project, string repository) + { + GitRepository gr = new GitRepository(); + gr.Id = repositoryId; + gr.Name = repository; + + return gr; + } + public List GetRelationTypes() { WorkItemRelationType hierarchyForward = new WorkItemRelationType @@ -240,7 +252,7 @@ public void When_calling_ensure_assignee_field_with_first_revision_Then_assignee rev.Fields = new List(); rev.Index = 0; - WorkItem createdWI = sut.CreateWorkItem("User Story"); + WorkItem createdWI = sut.CreateWorkItem("User Story", false); IdentityRef assignedTo = new IdentityRef(); assignedTo.UniqueName = "Mr. Test"; @@ -272,7 +284,7 @@ public void When_calling_ensure_date_fields_with_first_revision_Then_dates_are_a MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); DateTime now = DateTime.Now; @@ -324,7 +336,7 @@ public void When_calling_ensure_fields_on_state_change_with_subsequent_revision_ revState.Value = "New"; rev.Fields.Add(revState); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.State] = "Done"; createdWI.Fields[WiFieldReference.ChangedDate] = DateTime.Now; @@ -335,8 +347,8 @@ public void When_calling_ensure_fields_on_state_change_with_subsequent_revision_ Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.State), Is.EqualTo("New")); Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ClosedDate), Is.EqualTo("")); Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ClosedBy), Is.EqualTo("")); - Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ActivatedDate), Is.EqualTo(null)); - Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ActivatedBy), Is.EqualTo(null)); + Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ActivatedDate), Is.EqualTo("")); + Assert.That(rev.Fields.GetFieldValueOrDefault(WiFieldReference.ActivatedBy), Is.EqualTo("")); }); } @@ -355,7 +367,7 @@ public void When_calling_ensure_fields_on_a_closed_user_Story_with_Then_closed_d revState.Value = "New"; rev.Fields.Add(revState); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.State] = "Closed"; wiUtils.EnsureFieldsOnStateChange(rev, createdWI); @@ -414,7 +426,7 @@ public void When_calling_ensure_workitem_fields_initialized_for_user_story_Then_ MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); WiRevision rev = new WiRevision(); rev.Fields = new List(); @@ -442,7 +454,7 @@ public void When_calling_ensure_workitem_fields_initialized_for_bug_Then_title_a MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("Bug"); + WorkItem createdWI = wiUtils.CreateWorkItem("Bug", false); WiRevision rev = new WiRevision(); rev.Fields = new List(); @@ -493,7 +505,7 @@ public void When_calling_create_work_item_Then_work_item_is_created_and_added_to MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("Task"); + WorkItem createdWI = wiUtils.CreateWorkItem("Task", false); WorkItem retrievedWI = null; if (createdWI.Id.HasValue) { @@ -517,7 +529,7 @@ public void When_calling_add_link_with_empty_args_Then_an_exception_is_thrown() WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); Assert.That( - () => wiUtils.AddAndSaveLink(null, null), + () => wiUtils.AddAndSaveLink(null, null, null), Throws.InstanceOf()); } @@ -527,8 +539,10 @@ public void When_calling_add_link_with_valid_args_Then_a_link_is_added() MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); - WorkItem linkedWI = wiUtils.CreateWorkItem("Task"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); + WorkItem linkedWI = wiUtils.CreateWorkItem("Task", false); + + Settings settings = _fixture.Create(); WiLink link = new WiLink(); link.WiType = "System.LinkTypes.Hierarchy-Forward"; @@ -538,7 +552,7 @@ public void When_calling_add_link_with_valid_args_Then_a_link_is_added() link.TargetWiId = 2; link.Change = ReferenceChangeType.Added; - wiUtils.AddAndSaveLink(link, createdWI); + wiUtils.AddAndSaveLink(link, createdWI, settings); WorkItemRelation rel = createdWI.Relations[0]; @@ -555,7 +569,9 @@ public void When_calling_add_link_with_valid_args_and_an_attachment_is_present_o MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + Settings settings = _fixture.Create(); + + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); var attachment = new WorkItemRelation(); attachment.Title = "LinkTitle"; attachment.Rel = "AttachedFile"; @@ -566,7 +582,7 @@ public void When_calling_add_link_with_valid_args_and_an_attachment_is_present_o { "name", "filename.png" } }; createdWI.Relations.Add(attachment); - WorkItem linkedWI = wiUtils.CreateWorkItem("Task"); + WorkItem linkedWI = wiUtils.CreateWorkItem("Task", false); WiLink link = new WiLink(); link.WiType = "System.LinkTypes.Hierarchy-Forward"; @@ -576,7 +592,7 @@ public void When_calling_add_link_with_valid_args_and_an_attachment_is_present_o link.TargetWiId = 2; link.Change = ReferenceChangeType.Added; - wiUtils.AddAndSaveLink(link, createdWI); + wiUtils.AddAndSaveLink(link, createdWI, settings); WorkItemRelation rel = createdWI.Relations.Where(rl => rl.Rel != "AttachedFile").Single(); @@ -593,8 +609,10 @@ public void When_calling_add_link_with_valid_args_and_an_attachment_is_present_o MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); - WorkItem linkedWI = wiUtils.CreateWorkItem("Task"); + Settings settings = _fixture.Create(); + + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); + WorkItem linkedWI = wiUtils.CreateWorkItem("Task", false); var attachment = new WorkItemRelation(); attachment.Title = "LinkTitle"; attachment.Rel = "AttachedFile"; @@ -614,7 +632,7 @@ public void When_calling_add_link_with_valid_args_and_an_attachment_is_present_o link.TargetWiId = 2; link.Change = ReferenceChangeType.Added; - wiUtils.AddAndSaveLink(link, createdWI); + wiUtils.AddAndSaveLink(link, createdWI, settings); WorkItemRelation rel = createdWI.Relations[0]; @@ -632,7 +650,7 @@ public void When_calling_remove_link_with_empty_args_Then_an_exception_is_thrown WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); Assert.That( - () => wiUtils.RemoveAndSaveLink(null, null), + () => wiUtils.RemoveAndSaveLink(null, null, null), Throws.InstanceOf()); } @@ -642,12 +660,14 @@ public void When_calling_remove_link_with_no_link_added_Then_false_is_returned() MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); + + Settings settings = _fixture.Create(); WiLink link = new WiLink(); link.WiType = "System.LinkTypes.Hierarchy-Forward"; - bool result = wiUtils.RemoveAndSaveLink(link, createdWI); + bool result = wiUtils.RemoveAndSaveLink(link, createdWI, settings); Assert.That(result, Is.EqualTo(false)); } @@ -658,8 +678,10 @@ public void When_calling_remove_link_with_link_added_Then_link_is_removed() MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); - WorkItem linkedWI = wiUtils.CreateWorkItem("Task"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); + WorkItem linkedWI = wiUtils.CreateWorkItem("Task", false); + + Settings settings = _fixture.Create(); WiLink link = new WiLink(); link.WiType = "System.LinkTypes.Hierarchy-Forward"; @@ -669,9 +691,9 @@ public void When_calling_remove_link_with_link_added_Then_link_is_removed() link.TargetWiId = 2; link.Change = ReferenceChangeType.Added; - wiUtils.AddAndSaveLink(link, createdWI); + wiUtils.AddAndSaveLink(link, createdWI, settings); - bool result = wiUtils.RemoveAndSaveLink(link, createdWI); + bool result = wiUtils.RemoveAndSaveLink(link, createdWI, settings); Assert.Multiple(() => { @@ -700,7 +722,7 @@ public void When_calling_correct_comment_with_valid_args_Then_history_is_updated MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("Task"); + WorkItem createdWI = wiUtils.CreateWorkItem("Task", false); createdWI.Fields[WiFieldReference.History] = commentBeforeTransformation; createdWI.Relations.Add(new WorkItemRelation() { @@ -741,6 +763,17 @@ public void When_calling_correct_description_with_empty_args_Then_an_exception_i Throws.InstanceOf()); } + [Test] + public void When_calling_correct_acceptance_criteria_with_empty_args_Then_an_exception_is_thrown() + { + MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); + WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); + + Assert.That( + () => wiUtils.CorrectAcceptanceCriteria(null, null, null, MockedIsAttachmentMigratedDelegateTrue), + Throws.InstanceOf()); + } + [Test] public void When_calling_correct_description_for_user_story_Then_description_is_updated_with_correct_image_urls() { @@ -750,7 +783,7 @@ public void When_calling_correct_description_for_user_story_Then_description_is_ MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.Description] = descriptionBeforeTransformation; createdWI.Relations.Add(new WorkItemRelation() { @@ -789,7 +822,7 @@ public void When_calling_correct_description_for_bug_Then_repro_steps_is_updated MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("Bug"); + WorkItem createdWI = wiUtils.CreateWorkItem("Bug", false); createdWI.Fields[WiFieldReference.ReproSteps] = reproStepsBeforeTransformation; createdWI.Relations.Add(new WorkItemRelation() { @@ -819,6 +852,46 @@ public void When_calling_correct_description_for_bug_Then_repro_steps_is_updated Assert.That(createdWI.Fields[WiFieldReference.ReproSteps], Is.EqualTo(reproStepsAfterTransformation)); } + [Test] + public void When_calling_correct_acceptance_criteria_for_user_story_Then_acceptance_criteria_is_updated_with_correct_image_urls() + { + string acceptanceCriteriaBeforeTransformation = "My description, including file: "; + string acceptanceCriteriaAfterTransformation = "My description, including file: "; + + MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); + WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); + + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); + createdWI.Fields[WiFieldReference.AcceptanceCriteria] = acceptanceCriteriaBeforeTransformation; + createdWI.Relations.Add(new WorkItemRelation() + { + Rel = "AttachedFile", + Url = "https://example.com/my_image.png", + Attributes = new Dictionary() { + { "comment", "Imported from Jira, original ID: 100" } + } + }); + + WiAttachment att = new WiAttachment(); + att.Change = ReferenceChangeType.Added; + att.FilePath = "C:\\Temp\\workspace\\Attachments\\100\\my_image.png"; + att.AttOriginId = "100"; + + WiRevision revision = new WiRevision(); + revision.Attachments.Add(att); + + WiItem wiItem = new WiItem(); + wiItem.Revisions = new List + { + revision + }; + + wiUtils.CorrectAcceptanceCriteria(createdWI, wiItem, revision, MockedIsAttachmentMigratedDelegateTrue); + + Assert.That(createdWI.Fields[WiFieldReference.AcceptanceCriteria], Is.EqualTo(acceptanceCriteriaAfterTransformation)); + } + + [Test] public void When_calling_apply_attachments_with_empty_args_Then_an_exception_is_thrown() { @@ -836,7 +909,7 @@ public void When_calling_apply_attachments_with_change_equal_to_added_Then_worki MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); WiAttachment att = new WiAttachment(); att.Change = ReferenceChangeType.Added; @@ -866,7 +939,7 @@ public void When_calling_apply_attachments_with_change_equal_to_removed_Then_wor MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Relations.Add(new WorkItemRelation() { Rel = "AttachedFile", @@ -898,7 +971,7 @@ public void When_calling_apply_attachments_with_already_existing_attachment_Then MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Relations.Add(new WorkItemRelation() { Rel = "AttachedFile", @@ -929,7 +1002,7 @@ public void When_calling_save_workitem_attachments_with_empty_args_Then_an_excep WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); Assert.That( - () => wiUtils.SaveWorkItemAttachments(null, null), + () => wiUtils.SaveWorkItemAttachments(null, null, null), Throws.InstanceOf()); } @@ -940,7 +1013,7 @@ public void When_calling_save_workitem_fields_with_empty_args_Then_an_exception_ WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); Assert.That( - () => wiUtils.SaveWorkItemFields(null), + () => wiUtils.SaveWorkItemFields(null, null), Throws.InstanceOf()); } @@ -950,9 +1023,11 @@ public void When_calling_save_workitem_attachments_with_populated_workitem_Then_ MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.ChangedDate] = DateTime.Now; + Settings settings = _fixture.Create(); + // Add attachment WiAttachment att = new WiAttachment(); att.Change = ReferenceChangeType.Added; @@ -964,8 +1039,8 @@ public void When_calling_save_workitem_attachments_with_populated_workitem_Then_ revision.Attachments.Add(att); // Perform save - wiUtils.SaveWorkItemAttachments(revision, createdWI); - wiUtils.SaveWorkItemFields(createdWI); + wiUtils.SaveWorkItemAttachments(revision, createdWI, settings); + wiUtils.SaveWorkItemFields(createdWI, settings); // Assertions Assert.Multiple(() => @@ -983,9 +1058,11 @@ public void When_calling_save_workitem_fields_with_populated_workitem_Then_worki MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.ChangedDate] = DateTime.Now; + Settings settings = _fixture.Create(); + // Add fields createdWI.Fields[WiFieldReference.Title] = "My work item"; createdWI.Fields[WiFieldReference.Description] = "My description"; @@ -994,8 +1071,8 @@ public void When_calling_save_workitem_fields_with_populated_workitem_Then_worki WiRevision revision = new WiRevision(); // Perform save - wiUtils.SaveWorkItemAttachments(revision, createdWI); - wiUtils.SaveWorkItemFields(createdWI); + wiUtils.SaveWorkItemAttachments(revision, createdWI, settings); + wiUtils.SaveWorkItemFields(createdWI, settings); WorkItem updatedWI = null; @@ -1030,7 +1107,7 @@ public void When_calling_save_workitem_artifacts_with_populated_workitem_Then_wo MockedWitClientWrapper witClientWrapper = new MockedWitClientWrapper(); WitClientUtils wiUtils = new WitClientUtils(witClientWrapper); - WorkItem createdWI = wiUtils.CreateWorkItem("User Story"); + WorkItem createdWI = wiUtils.CreateWorkItem("User Story", false); createdWI.Fields[WiFieldReference.ChangedDate] = DateTime.Now; WiRevision revision = new WiRevision(); @@ -1054,7 +1131,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}")); }); } diff --git a/test/integration/config.json b/test/integration/config.json new file mode 100644 index 00000000..e63f0b3a --- /dev/null +++ b/test/integration/config.json @@ -0,0 +1,216 @@ +{ + "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" + }, + { + "source": "Test", + "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/integration-test.yml b/test/integration/integration-test.yml new file mode 100644 index 00000000..91e5a5ea --- /dev/null +++ b/test/integration/integration-test.yml @@ -0,0 +1,69 @@ +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)\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)' + +- 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)\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 + +- 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\users.txt' 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