├── .azure-pipelines └── azure-pipelines.yml ├── .gitignore ├── .npmignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── SECURITY.md ├── configuration.json ├── package-lock.json ├── package.json └── src ├── browser ├── placeholder.ts └── tsconfig.json ├── common ├── Constants.ts ├── Engine.ts ├── Errors.ts ├── Interfaces.ts ├── Logger.ts ├── ProcessExporter.ts ├── ProcessImporter.ts └── Utilities.ts └── nodejs ├── ConfigurationProcessor.ts ├── FileLogger.ts ├── Main.ts ├── NodeJsUtilities.ts └── tsconfig.json /.azure-pipelines/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - name: version.MajorMinor # Manually adjust the version number as needed for semantic versioning. Patch is auto-incremented. 3 | value: '0.10' 4 | 5 | name: $(version.MajorMinor)$(rev:.r) 6 | 7 | trigger: 8 | branches: 9 | include: 10 | - master 11 | 12 | pr: none 13 | 14 | resources: 15 | repositories: 16 | - repository: pipeline-templates 17 | type: git 18 | name: DevLabs Extensions/pipeline-templates 19 | ref: main 20 | 21 | stages: 22 | - stage: 'Build' 23 | jobs: 24 | - job: 'BuildPack' 25 | displayName: "Build and package" 26 | pool: 27 | vmImage: ubuntu-latest 28 | steps: 29 | 30 | - task: NodeTool@0 31 | inputs: 32 | versionSpec: '16.x' 33 | - template: build.yml@pipeline-templates 34 | - template: package-npm.yml@pipeline-templates 35 | 36 | - stage: 'DeployTest' 37 | displayName: 'Deploy to Test (Azure Artifact)' 38 | dependsOn: Build 39 | condition: succeeded() 40 | jobs: 41 | - template: deploy-npm.yml@pipeline-templates 42 | parameters: 43 | environment: 'test' 44 | publishFeed: 'DevLabs Extensions/extensions' 45 | 46 | - stage: 'DeployPublic' 47 | displayName: 'Deploy to Public (NPM)' 48 | dependsOn: DeployTest 49 | condition: succeeded() 50 | jobs: 51 | - template: deploy-npm.yml@pipeline-templates 52 | parameters: 53 | environment: 'public' 54 | publishEndpoint: 'npm-process-migrator' 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | output/* 3 | build/* 4 | test/* 5 | /.vs 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | output/ 3 | .vscode/ 4 | .azure-pipelines/ 5 | build/browser/ 6 | build/tests/ 7 | test/ 8 | *.js.map 9 | package-lock.json 10 | SECURITY.md 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch process migrate", 8 | "program": "${workspaceFolder}/build/nodejs/nodejs/Main.js", 9 | "sourceMaps": true, 10 | "args": [ 11 | ] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": [ 3 | { 4 | "type": "typescript", 5 | "tsconfig": "src\\nodejs\\tsconfig.json", 6 | "problemMatcher": [ 7 | "$tsc" 8 | ] 9 | }, 10 | { 11 | "type": "typescript", 12 | "tsconfig": "src\\browser\\tsconfig.json", 13 | "problemMatcher": [ 14 | "$tsc" 15 | ] 16 | }, 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSTS Process Migrator for Node.js 2 | 3 | **NOTE:** When running the process migrator on Node.js v23.10+ you will get the below error: 4 | 5 | > C:\Users***\AppData\Roaming\npm\node_modules\process-migrator\build\nodejs\nodejs\NodeJsUtilities.js:23 6 | > if (!util_1.isFunction(stdin.setRawMode)) { 7 | > ^ 8 | > TypeError: util_1.isFunction is not a function 9 | 10 | We are working on resolving this (see issues #107, #108). 11 | 12 | This application provide you ability to automate the [Process](https://docs.microsoft.com/en-us/vsts/work/customize/process/manage-process?view=vsts) export/import across VSTS accounts through Node.js CLI. 13 | 14 | **NOTE:** This only works with 'Inherited Process', for 'XML process' you may upload/download process as ZIP. 15 | 16 | # Getting Started 17 | 18 | ## Run 19 | 20 | To run this tool you must have both NodeJS and NPM installed. They are available as a single package and instructions are below. 21 | 22 | - Install Node from https://nodejs.org/en/download/ or https://nodejs.org/en/download/package-manager/ 23 | - Install this package through `npm install process-migrator -g` 24 | - Create and fill required information in config file *configuration.json*. See [document section](#documentation) for details 25 | 26 | Just run ```process-migrator``` without any argument will create the file if it does not exist. 27 | 28 | ##### ![](https://imgplaceholder.com/100x17/cccccc/fe2904?text=WARNING&font-size=15) CONFIGURATION FILE HAS PAT, RIGHT PROTECT IT ! 29 | - Run `process-migrator [--mode=] [--config=]` 30 | 31 | ## Contribute 32 | 33 | - From the root of source, run `npm install` 34 | - Build by `npm run build` 35 | - Execute through `node build\nodejs\nodejs\Main.js ` 36 | 37 | ## Documentation 38 | 39 | ##### Command line parameters 40 | - --mode: Optional, default as 'migrate'. Mode of the execution, can be 'migrate' (export and then import), 'export' (export only) or 'import' (import only). 41 | - --config: Optional, default as './configuration.json'. Specify the configuration file. 42 | 43 | ##### Configuration file structure 44 | - This file is in [JSONC](https://github.com/Microsoft/node-jsonc-parser) format, you don't have to remove comments lines for it to work. 45 | - The AccountUrl for the source and target is the root URL to the organization. Example: https://dev.azure.com/MyOrgName. 46 | - The Personal Access Token (PAT) for both the source and target must have the Work Items 'Read, Write, & Manage' permission scope. 47 | 48 | ``` json 49 | { 50 | "sourceAccountUrl": "Source account url. Required in export/migrate mode, ignored in import mode.", 51 | "sourceAccountToken": "!!TREAT AS PASSWORD!! In Azure DevOps click on user settings personal access tokens an generate a token for source account. Required in export/migrate mode, ignored in import mode.", 52 | "targetAccountUrl": "Target account url. Required in import/migrate mode, ignored in export mode.", 53 | "targetAccountToken": "!!TREAT AS PASSWORD!! In Azure DevOps click on user settings personal access tokens and generate a token for target account. Required in import/migrate mode, ignored in export mode.", 54 | "sourceProcessName": "Source process name to export. Required in export/migrate mode, ignored in import mode.", 55 | "targetProcessName": "Optional. Set to override process name in import/migrate mode.", 56 | "options": { 57 | "processFilename": "Optional File with process payload. Required in import mode, optional for export/migrate mode.", 58 | "logLevel":"log level for console. Possible values are 'verbose'/'information'/'warning'/'error' or null.", 59 | "logFilename":"Optional, file name for log. defaulted to 'output/processMigrator.log'.", 60 | "overwritePicklist": "Optional, default to 'false'. Set as true to overwrite picklist if exists on target or import will fail when picklist entries varies across source and target.", 61 | "continueOnRuleImportFailure": "Optional, default to 'false', set true to continue import on failure importing rules, warning will be provided.", 62 | "skipImportFormContributions": "Optional, default to 'false', set true to skip import control contributions on work item form.", 63 | } 64 | } 65 | ``` 66 | 67 | ##### Notes 68 | - If extensions used by source account are not available in target account, import MAY fail 69 | 1) Control/Group/Page contributions on work item form are by default imported, so it will fail if the extension is not available on target account. use 'skipImportFormContributions' option to skip importing custom controls. 70 | - If identities used in field default value or rules are not available in target account, import WILL fail 71 | 1) For rules you may use 'continueOnRuleImportFailure' option to proceed with rest of import when such error is occurred. 72 | 2) For identity field default value, you may use 'continueOnFieldDefaultValueFailure' option to proceed with rest of import when such error is occurred. 73 | - Personal Access Token (PAT) needs to allow "Read, write, & manage" access for the "Work Items" scope 74 | 1) The tool needs to be able to modify the definition of work items and work item types (to add custom fields for example). 75 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceAccountUrl": "", 3 | "sourceAccountToken": "", 4 | "targetAccountUrl": "", 5 | "targetAccountToken": "", 6 | "sourceProcessName": "Process name for export, optional in import only mode, required in export/both mode", 7 | "targetProcessName": "Set to override process name on import, remove from param name", 8 | "options": { 9 | "processFilename": "Set to override default export file name, remove from param name", 10 | "logLevel":"Set to override default log level (Information), remove from param name", 11 | "logFilename":"Set to override default log file name, remove from param name", 12 | "overwritePicklist": false 13 | } 14 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "process-migrator", 3 | "version": "0.9.8", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "process-migrator", 9 | "version": "0.9.8", 10 | "license": "MIT", 11 | "dependencies": { 12 | "azure-devops-node-api": "^6.5.0", 13 | "guid-typescript": "~1.0.8", 14 | "jsonc-parser": "~3.1.0", 15 | "minimist": "~1.2.7", 16 | "mkdirp": "^1.0.4", 17 | "url": "^0.11.0", 18 | "vss-web-extension-sdk": "5.141.0" 19 | }, 20 | "bin": { 21 | "process-migrator": "build/nodejs/nodejs/Main.js" 22 | }, 23 | "devDependencies": { 24 | "@types/minimist": "^1.2.2", 25 | "@types/mkdirp": "^0.5.2", 26 | "@types/node": "8.10.0", 27 | "typescript": "^2.8.1" 28 | }, 29 | "engines": { 30 | "node": ">=8.11.2" 31 | } 32 | }, 33 | "node_modules/@types/jquery": { 34 | "version": "3.5.16", 35 | "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", 36 | "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", 37 | "dependencies": { 38 | "@types/sizzle": "*" 39 | } 40 | }, 41 | "node_modules/@types/jqueryui": { 42 | "version": "1.12.16", 43 | "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.16.tgz", 44 | "integrity": "sha512-6huAQDpNlso9ayaUT9amBOA3kj02OCeUWs+UvDmbaJmwkHSg/HLsQOoap/D5uveN9ePwl72N45Bl+Frp5xyG1Q==", 45 | "dependencies": { 46 | "@types/jquery": "*" 47 | } 48 | }, 49 | "node_modules/@types/knockout": { 50 | "version": "3.4.72", 51 | "resolved": "https://registry.npmjs.org/@types/knockout/-/knockout-3.4.72.tgz", 52 | "integrity": "sha512-L/dGG0DdadKj0nsumdvkNonEcHMRe4RflgHEoHFzj1RZ+xuUMayF7+4Jj5pALOD462M/x4cGa9GuadBDiU6nRw==" 53 | }, 54 | "node_modules/@types/minimist": { 55 | "version": "1.2.2", 56 | "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", 57 | "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", 58 | "dev": true 59 | }, 60 | "node_modules/@types/mkdirp": { 61 | "version": "0.5.2", 62 | "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-0.5.2.tgz", 63 | "integrity": "sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg==", 64 | "dev": true, 65 | "dependencies": { 66 | "@types/node": "*" 67 | } 68 | }, 69 | "node_modules/@types/mousetrap": { 70 | "version": "1.5.34", 71 | "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.5.34.tgz", 72 | "integrity": "sha512-a2yhRIADupQfOFM75v7GfcQQLUxU705+i/xcZ3N/3PK3Xdo31SUfuCUByWPGOHB1e38m7MxTx/D8FPVsJXZKJw==" 73 | }, 74 | "node_modules/@types/node": { 75 | "version": "8.10.0", 76 | "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.0.tgz", 77 | "integrity": "sha512-7IGHZQfRfa0bCd7zUBVUGFKFn31SpaLDFfNoCAqkTGQO5JlHC9BwQA/CG9KZlABFxIUtXznyFgechjPQEGrUTg==", 78 | "dev": true 79 | }, 80 | "node_modules/@types/q": { 81 | "version": "0.0.32", 82 | "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", 83 | "integrity": "sha512-qYi3YV9inU/REEfxwVcGZzbS3KG/Xs90lv0Pr+lDtuVjBPGd1A+eciXzVSaRvLify132BfcvhvEjeVahrUl0Ug==" 84 | }, 85 | "node_modules/@types/react": { 86 | "version": "15.7.12", 87 | "resolved": "https://registry.npmjs.org/@types/react/-/react-15.7.12.tgz", 88 | "integrity": "sha512-FbIDKxGEzmf0jM+1ArXAJwJzg7GkUq5nLVBIz/PSBwVUzATuAjbPrN+UUEAW6zpt/A2WF8XMfSKsNfGX95xCsQ==" 89 | }, 90 | "node_modules/@types/requirejs": { 91 | "version": "2.1.34", 92 | "resolved": "https://registry.npmjs.org/@types/requirejs/-/requirejs-2.1.34.tgz", 93 | "integrity": "sha512-iQLGNE1DyIRYih60B47l/hI5X7J0wAnnRBL6Yn85GUYQg8Fm3wl8kvT6NRwncKroUOSx7/lbAagIFNV7y02DiQ==" 94 | }, 95 | "node_modules/@types/sizzle": { 96 | "version": "2.3.3", 97 | "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", 98 | "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" 99 | }, 100 | "node_modules/azure-devops-node-api": { 101 | "version": "6.6.3", 102 | "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-6.6.3.tgz", 103 | "integrity": "sha512-94wSu4O6CSSXoqYWg7Rzt2/IqbW2xVNu2qOtx6e7lnXxnDOcAu4eRzi8tgVNHsXTIGOVEsTqgMvGvFThKr9Pig==", 104 | "dependencies": { 105 | "os": "0.1.1", 106 | "tunnel": "0.0.4", 107 | "typed-rest-client": "1.0.9", 108 | "underscore": "1.8.3" 109 | } 110 | }, 111 | "node_modules/guid-typescript": { 112 | "version": "1.0.9", 113 | "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", 114 | "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==" 115 | }, 116 | "node_modules/jsonc-parser": { 117 | "version": "3.1.0", 118 | "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", 119 | "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==" 120 | }, 121 | "node_modules/minimist": { 122 | "version": "1.2.8", 123 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 124 | "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 125 | "funding": { 126 | "url": "https://github.com/sponsors/ljharb" 127 | } 128 | }, 129 | "node_modules/mkdirp": { 130 | "version": "1.0.4", 131 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", 132 | "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", 133 | "bin": { 134 | "mkdirp": "bin/cmd.js" 135 | }, 136 | "engines": { 137 | "node": ">=10" 138 | } 139 | }, 140 | "node_modules/os": { 141 | "version": "0.1.1", 142 | "resolved": "https://registry.npmjs.org/os/-/os-0.1.1.tgz", 143 | "integrity": "sha512-jg06S2xr5De63mLjZVJDf3/k37tpjppr2LR7MUOsxv8XuUCVpCnvbCksXCBcB5gQqQf/K0+87WGTRlAj5q7r1A==" 144 | }, 145 | "node_modules/punycode": { 146 | "version": "1.3.2", 147 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", 148 | "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" 149 | }, 150 | "node_modules/querystring": { 151 | "version": "0.2.0", 152 | "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", 153 | "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", 154 | "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", 155 | "engines": { 156 | "node": ">=0.4.x" 157 | } 158 | }, 159 | "node_modules/tunnel": { 160 | "version": "0.0.4", 161 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz", 162 | "integrity": "sha512-o9QYRJN5WgS8oCtqvwzzcfnzaTnDPr7HpUsQdSXscTyzXbjvl4wSHPTUKOKzEaDeQvOuyRtt3ui+ujM7x7TReQ==", 163 | "engines": { 164 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3" 165 | } 166 | }, 167 | "node_modules/typed-rest-client": { 168 | "version": "1.0.9", 169 | "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.0.9.tgz", 170 | "integrity": "sha512-iOdwgmnP/tF6Qs+oY4iEtCf/3fnCDl7Gy9LGPJ4E3M4Wj3uaSko15FVwbsaBmnBqTJORnXBWVY5306D4HH8oiA==", 171 | "dependencies": { 172 | "tunnel": "0.0.4", 173 | "underscore": "1.8.3" 174 | } 175 | }, 176 | "node_modules/typescript": { 177 | "version": "2.9.2", 178 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", 179 | "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", 180 | "dev": true, 181 | "bin": { 182 | "tsc": "bin/tsc", 183 | "tsserver": "bin/tsserver" 184 | }, 185 | "engines": { 186 | "node": ">=4.2.0" 187 | } 188 | }, 189 | "node_modules/underscore": { 190 | "version": "1.8.3", 191 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", 192 | "integrity": "sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg==" 193 | }, 194 | "node_modules/url": { 195 | "version": "0.11.0", 196 | "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", 197 | "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==", 198 | "dependencies": { 199 | "punycode": "1.3.2", 200 | "querystring": "0.2.0" 201 | } 202 | }, 203 | "node_modules/vss-web-extension-sdk": { 204 | "version": "5.141.0", 205 | "resolved": "https://registry.npmjs.org/vss-web-extension-sdk/-/vss-web-extension-sdk-5.141.0.tgz", 206 | "integrity": "sha512-c/r/HWQh4hljKOSNQMiFoeICckKFfU/1nxDCVFhioDHOE8B0i5aJN9rrihGilgMXugzl8K5hBsZs42eFqd30AQ==", 207 | "deprecated": "Package no longer supported. Please use https://www.npmjs.com/package/azure-devops-extension-sdk instead.", 208 | "dependencies": { 209 | "@types/jquery": ">=2.0.48", 210 | "@types/jqueryui": ">=1.11.34", 211 | "@types/knockout": "^3.4.49", 212 | "@types/mousetrap": "~1.5.34", 213 | "@types/q": "0.0.32", 214 | "@types/react": "^15.6.12", 215 | "@types/requirejs": ">=2.1.28" 216 | } 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "process-migrator", 3 | "version": "0.9.8", 4 | "description": "Proces import/export Node.js application", 5 | "main": "", 6 | "bin": { 7 | "process-migrator": "build/nodejs/nodejs/Main.js" 8 | }, 9 | "engines": { 10 | "node": ">=8.11.2" 11 | }, 12 | "scripts": { 13 | "build": "tsc -p src/nodejs/tsconfig.json && tsc -p src/browser/tsconfig.json", 14 | "build:release": "tsc -p src/nodejs/tsconfig.json && tsc -p src/browser/tsconfig.json", 15 | "bn": "tsc -p src/nodejs/tsconfig.json", 16 | "bb": "tsc -p src/browser/tsconfig.json" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/Microsoft/process-migrator" 21 | }, 22 | "keywords": [ 23 | "azure devops", 24 | "azure boards", 25 | "process", 26 | "import", 27 | "export", 28 | "migrate", 29 | "migrator", 30 | "inherited" 31 | ], 32 | "author": "Microsoft", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@types/minimist": "^1.2.2", 36 | "@types/mkdirp": "^0.5.2", 37 | "@types/node": "8.10.0", 38 | "typescript": "^2.8.1" 39 | }, 40 | "dependencies": { 41 | "azure-devops-node-api": "^6.5.0", 42 | "guid-typescript": "~1.0.8", 43 | "jsonc-parser": "~3.1.0", 44 | "minimist": "~1.2.7", 45 | "mkdirp": "^1.0.4", 46 | "url": "^0.11.0", 47 | "vss-web-extension-sdk": "5.141.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/browser/placeholder.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import * as WITInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; 3 | import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; 4 | import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; 5 | import { WorkItemTrackingApi } from "azure-devops-node-api/WorkItemTrackingApi"; 6 | import { getCollectionClient } from "VSS/Service"; 7 | import { WorkItemTrackingHttpClient } from "TFS/WorkItemTracking/RestClient"; 8 | 9 | // Placeholder file for proof of concept 10 | const witClient = getCollectionClient(WorkItemTrackingHttpClient); 11 | const booleanType = WITInterfaces.FieldType.Boolean; 12 | const customPageType = WITProcessDefinitionsInterfaces.PageType.Custom; -------------------------------------------------------------------------------- /src/browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "noImplicitThis": true, 5 | "lib": ["es6", "dom", "es2015.iterable"], 6 | "outDir": "../../build/browser", 7 | "preserveConstEnums": true, 8 | "removeComments": true, 9 | "sourceMap": true, 10 | "target": "es2017", // to not transpile async/await 11 | "module": "amd", 12 | "moduleResolution": "node", 13 | "skipLibCheck": true, // to allow requirejs live together with node 14 | "types": [ 15 | "vss-web-extension-sdk" 16 | ] 17 | }, 18 | "include": [ 19 | "**/*.ts", 20 | "../common/**/*.ts", 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } -------------------------------------------------------------------------------- /src/common/Constants.ts: -------------------------------------------------------------------------------- 1 | export const PICKLIST_NO_ACTION = "PICKLIST_NO_ACTION"; 2 | export const defaultEncoding = "utf-8"; 3 | export const defaultConfigurationFilename = "configuration.json"; 4 | export const defaultLogFileName = "output\\processMigrator.log"; 5 | export const defaultProcessFilename = "output\\process.json"; 6 | export const paramMode = "mode"; 7 | export const paramConfig = "config"; 8 | export const paramSourceToken = "sourceToken"; 9 | export const paramTargetToken = "targetToken"; 10 | export const paramOverwriteProcessOnTarget = "overwriteProcessOnTarget"; 11 | export const defaultConfiguration = 12 | `{ 13 | "sourceAccountUrl": "Required in 'export'/'migrate' mode, source account url.", 14 | "sourceAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'export'/'migrate' mode, personal access token for source account.", 15 | "targetAccountUrl": "Required in 'import'/'migrate' mode, target account url.", 16 | "targetAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'import'/'migrate' mode, personal access token for target account.", 17 | "sourceProcessName": "Required in 'export'/'migrate' mode, source process name.", 18 | // "targetProcessName": "Optional, set to override process name in 'import'/'migrate' mode.", 19 | "options": { 20 | // "processFilename": "Required in 'import' mode, optional in 'export'/'migrate' mode to override default value './output/process.json'.", 21 | // "logLevel":"Optional, default as 'Information'. Logs at or higher than this level are outputed to console and rest in log file. Possiblee values are 'Verbose'/'Information'/'Warning'/'Error'.", 22 | // "logFilename":"Optional, default as 'output/processMigrator.log' - Set to override default log file name.", 23 | // "overwritePicklist": "Optional, default is 'false'. Set true to overwrite picklist if exists on target. Import will fail if picklist exists but different from source.", 24 | // "continueOnRuleImportFailure": "Optional, default is 'false', set true to continue import on failure importing rules, warning will be provided.", 25 | // "skipImportFormContributions": "Optional, default is 'false', set true to skip import control/group/form contributions on work item form.", 26 | } 27 | }`; 28 | export const regexRemoveHypen = new RegExp("-", "g"); -------------------------------------------------------------------------------- /src/common/Engine.ts: -------------------------------------------------------------------------------- 1 | import { CancellationError } from "./Errors"; 2 | import { logger } from "./Logger"; 3 | import { Utility } from "./Utilities"; 4 | 5 | export class Engine { 6 | public static async Task(step: () => Promise, stepName?: string): Promise { 7 | if (Utility.didUserCancel()) { 8 | throw new CancellationError(); 9 | } 10 | logger.logVerbose(`Begin step '${stepName}'.`); 11 | const ret: T = await step(); 12 | logger.logVerbose(`Finished step '${stepName}'.`); 13 | return ret; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/Errors.ts: -------------------------------------------------------------------------------- 1 | // NOTE: We need this intermediate class to use 'instanceof' 2 | export class KnownError extends Error { 3 | __proto__: Error; 4 | constructor(message?: string) { 5 | const trueProto = new.target.prototype; 6 | super(message); 7 | 8 | // Alternatively use Object.setPrototypeOf if you have an ES6 environment. 9 | this.__proto__ = trueProto; 10 | } 11 | } 12 | 13 | export class CancellationError extends KnownError { 14 | constructor() { 15 | super("Process import/export cancelled by user input."); 16 | } 17 | } 18 | 19 | export class ValidationError extends KnownError { 20 | constructor(message: string) { 21 | super(`Process import validation failed. ${message}`); 22 | } 23 | } 24 | 25 | export class ImportError extends KnownError { 26 | constructor(message: string) { 27 | super(`Import failed, see log file for details. ${message}`); 28 | } 29 | } 30 | 31 | export class ExportError extends KnownError { 32 | constructor(message: string) { 33 | super(`Export failed, see log file for details. ${message}`); 34 | } 35 | } -------------------------------------------------------------------------------- /src/common/Interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; 2 | import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; 3 | import * as WITInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; 4 | import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi } from "azure-devops-node-api/WorkItemTrackingProcessDefinitionsApi"; 5 | import { IWorkItemTrackingProcessApi as WITProcessApi } from "azure-devops-node-api/WorkItemTrackingProcessApi"; 6 | import { IWorkItemTrackingApi as WITApi } from "azure-devops-node-api/WorkItemTrackingApi"; 7 | 8 | export enum LogLevel { 9 | error, 10 | warning, 11 | information, 12 | verbose 13 | } 14 | 15 | export enum Modes { 16 | import, 17 | export, 18 | migrate 19 | } 20 | 21 | export interface IExportOptions { 22 | processID: string; 23 | } 24 | 25 | export interface ICommandLineOptions { 26 | mode: Modes; 27 | overwriteProcessOnTarget: boolean; 28 | config: string; 29 | sourceToken?: string; 30 | targetToken?: string; 31 | } 32 | 33 | export interface IConfigurationFile { 34 | sourceProcessName?: string; 35 | targetProcessName?: string; 36 | sourceAccountUrl?: string; 37 | targetAccountUrl?: string; 38 | sourceAccountToken?: string; 39 | targetAccountToken?: string; 40 | options?: IConfigurationOptions; 41 | } 42 | 43 | export interface IConfigurationOptions { 44 | logLevel?: string; 45 | logFilename?: string; 46 | processFilename?: string; 47 | overwritePicklist?: boolean; 48 | continueOnRuleImportFailure?: boolean; 49 | continueOnIdentityDefaultValueFailure?: boolean; 50 | skipImportFormContributions?: boolean; 51 | } 52 | 53 | export interface IProcessPayload { 54 | process: WITProcessInterfaces.ProcessModel; 55 | workItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[]; 56 | fields: WITProcessInterfaces.FieldModel[]; 57 | workItemTypeFields: IWITypeFields[]; 58 | witFieldPicklists: IWITFieldPicklist[]; 59 | layouts: IWITLayout[]; 60 | behaviors: WITProcessInterfaces.WorkItemBehavior[]; 61 | workItemTypeBehaviors: IWITBehaviors[]; 62 | states: IWITStates[]; 63 | rules: IWITRules[]; 64 | 65 | // Only populated during import 66 | targetAccountInformation?: ITargetInformation 67 | } 68 | 69 | /** 70 | * For information populated from target account during import 71 | */ 72 | export interface ITargetInformation { 73 | collectionFields?: WITInterfaces.WorkItemField[]; 74 | fieldRefNameToPicklistId?: IDictionaryStringTo; 75 | } 76 | 77 | export interface IWITypeFields { 78 | workItemTypeRefName: string; 79 | fields: WITProcessDefinitionsInterfaces.WorkItemTypeFieldModel[]; 80 | } 81 | 82 | export interface IWITLayout { 83 | workItemTypeRefName: string; 84 | layout: WITProcessDefinitionsInterfaces.FormLayout; 85 | } 86 | 87 | export interface IWITStates { 88 | workItemTypeRefName: string; 89 | states: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[]; 90 | } 91 | 92 | export interface IWITRules { 93 | workItemTypeRefName: string; 94 | rules: WITProcessInterfaces.FieldRuleModel[]; 95 | } 96 | 97 | export interface IWITBehaviors { 98 | workItemType: IWITBehaviorsInfo; 99 | behaviors: WITProcessDefinitionsInterfaces.WorkItemTypeBehavior[]; 100 | } 101 | 102 | export interface IWITBehaviorsInfo { 103 | refName: string; 104 | workItemTypeClass: WITProcessDefinitionsInterfaces.WorkItemTypeClass; 105 | } 106 | 107 | export interface IValidationStatus { 108 | status: boolean; 109 | message: string; 110 | } 111 | 112 | export interface IWITFieldPicklist { 113 | workitemtypeRefName: string; 114 | fieldRefName: string; 115 | picklist: WITProcessDefinitionsInterfaces.PickListModel; 116 | } 117 | 118 | export interface IDictionaryStringTo { 119 | [key: string]: T; 120 | } 121 | 122 | export interface ILogger { 123 | logVerbose(message: string); 124 | logInfo(message: string); 125 | logWarning(message: string); 126 | logError(message: string); 127 | logException(error: Error); 128 | } 129 | 130 | export interface IRestClients { 131 | witApi: WITApi; 132 | witProcessApi: WITProcessApi; 133 | witProcessDefinitionApi: WITProcessDefinitionApi; 134 | } -------------------------------------------------------------------------------- /src/common/Logger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel, ILogger } from "./Interfaces"; 2 | 3 | class ConsoleLogger implements ILogger { 4 | public logVerbose(message: string) { 5 | this._log(message, LogLevel.verbose); 6 | } 7 | 8 | public logInfo(message: string) { 9 | this._log(message, LogLevel.information); 10 | } 11 | 12 | public logWarning(message: string) { 13 | this._log(message, LogLevel.warning); 14 | } 15 | 16 | public logError(message: string) { 17 | this._log(message, LogLevel.error); 18 | } 19 | 20 | public logException(error: Error) { 21 | if (error instanceof Error) { 22 | this._log(`Exception message:${error.message}\r\nCall stack:${error.stack}`, LogLevel.verbose); 23 | } 24 | else { 25 | this._log(`Unknown exception: ${JSON.stringify(error)}`, LogLevel.verbose); 26 | } 27 | } 28 | 29 | private _log(message: string, logLevel: LogLevel) { 30 | const outputMessage: string = `[${LogLevel[logLevel].toUpperCase()}] [${(new Date(Date.now())).toISOString()}] ${message}`; 31 | console.log(outputMessage); 32 | } 33 | } 34 | 35 | export var logger: ILogger = new ConsoleLogger(); 36 | 37 | /** 38 | * DO NOT CALL - This is exposed for other logger implementation 39 | * @param newLogger 40 | */ 41 | export function SetLogger(newLogger: ILogger) { 42 | logger = newLogger; 43 | } -------------------------------------------------------------------------------- /src/common/ProcessExporter.ts: -------------------------------------------------------------------------------- 1 | import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; 2 | import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; 3 | import * as vsts_NOREQUIRE from "azure-devops-node-api/WebApi"; 4 | import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessDefinitionsApi"; 5 | import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessApi"; 6 | import { IWorkItemTrackingApi as WITApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingApi"; 7 | 8 | import { IConfigurationFile, IDictionaryStringTo, IProcessPayload, IWITBehaviors, IWITBehaviorsInfo, IWITFieldPicklist, IWITLayout, IWITRules, IWITStates, IWITypeFields, IRestClients } from "./Interfaces"; 9 | import { ExportError } from "./Errors"; 10 | import { logger } from "./Logger"; 11 | import { Engine } from "./Engine"; 12 | import { Utility } from "./Utilities"; 13 | 14 | export class ProcessExporter { 15 | private _vstsWebApi: vsts_NOREQUIRE.WebApi; 16 | private _witProcessApi: WITProcessApi_NOREQUIRE; 17 | private _witProcessDefinitionApi: WITProcessDefinitionApi_NOREQUIRE; 18 | private _witApi: WITApi_NOREQUIRE; 19 | 20 | constructor(restClients: IRestClients, private _config: IConfigurationFile) { 21 | this._witApi = restClients.witApi; 22 | this._witProcessApi = restClients.witProcessApi; 23 | this._witProcessDefinitionApi = restClients.witProcessDefinitionApi; 24 | } 25 | 26 | private async _getSourceProcessId(): Promise { 27 | const processes = await Utility.tryCatchWithKnownError(() => this._witProcessApi.getProcesses(), 28 | () => new ExportError(`Error getting processes on source account '${this._config.sourceAccountUrl}, check account url, token and token permissions.`)); 29 | 30 | if (!processes) { // most likely 404 31 | throw new ExportError(`Failed to get processes on source account '${this._config.sourceAccountUrl}', check account url.`); 32 | } 33 | 34 | const lowerCaseSourceProcessName = this._config.sourceProcessName.toLocaleLowerCase(); 35 | const matchProcesses = processes.filter(p => p.name.toLocaleLowerCase() === lowerCaseSourceProcessName); 36 | if (matchProcesses.length === 0) { 37 | throw new ExportError(`Process '${this._config.sourceProcessName}' is not found on source account.`); 38 | } 39 | 40 | const process = matchProcesses[0]; 41 | if (process.properties.class !== WITProcessInterfaces.ProcessClass.Derived) { 42 | throw new ExportError(`Proces '${this._config.sourceProcessName}' is not a derived process, not supported.`); 43 | } 44 | return process.typeId; 45 | } 46 | 47 | private async _getComponents(processId: string): Promise { 48 | let _process: WITProcessInterfaces.ProcessModel; 49 | let _behaviorsCollectionScope: WITProcessInterfaces.WorkItemBehavior[]; 50 | let _fieldsCollectionScope: WITProcessInterfaces.FieldModel[]; 51 | const _fieldsWorkitemtypeScope: IWITypeFields[] = []; 52 | const _layouts: IWITLayout[] = []; 53 | const _states: IWITStates[] = []; 54 | const _rules: IWITRules[] = []; 55 | const _behaviorsWITypeScope: IWITBehaviors[] = []; 56 | const _picklists: IWITFieldPicklist[] = []; 57 | const knownPicklists: IDictionaryStringTo = {}; 58 | const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = []; 59 | const processPromises: Promise[] = []; 60 | 61 | processPromises.push(this._witProcessApi.getProcessById(processId).then(process => _process = process)); 62 | processPromises.push(this._witProcessApi.getFields(processId).then(fields => _fieldsCollectionScope = fields)); 63 | processPromises.push(this._witProcessApi.getBehaviors(processId).then(behaviors => _behaviorsCollectionScope = behaviors)); 64 | processPromises.push(this._witProcessApi.getWorkItemTypes(processId).then(workitemtypes => { 65 | const perWitPromises: Promise[] = []; 66 | 67 | for (const workitemtype of workitemtypes) { 68 | const currentWitPromises: Promise[] = []; 69 | 70 | currentWitPromises.push(this._witProcessDefinitionApi.getBehaviorsForWorkItemType(processId, workitemtype.id).then(behaviors => { 71 | const witBehaviorsInfo: IWITBehaviorsInfo = { refName: workitemtype.id, workItemTypeClass: workitemtype.class }; 72 | const witBehaviors: IWITBehaviors = { 73 | workItemType: witBehaviorsInfo, 74 | behaviors: behaviors 75 | } 76 | _behaviorsWITypeScope.push(witBehaviors); 77 | })); 78 | 79 | if (workitemtype.class !== WITProcessInterfaces.WorkItemTypeClass.System) { 80 | _nonSystemWorkItemTypes.push(workitemtype); 81 | 82 | currentWitPromises.push(this._witProcessDefinitionApi.getWorkItemTypeFields(processId, workitemtype.id).then(fields => { 83 | const witFields: IWITypeFields = { 84 | workItemTypeRefName: workitemtype.id, 85 | fields: fields 86 | }; 87 | _fieldsWorkitemtypeScope.push(witFields); 88 | 89 | const picklistPromises: Promise[] = []; 90 | for (const field of fields) { 91 | if (field.pickList && !knownPicklists[field.referenceName]) { // Same picklist field may exist in multiple work item types but we only need to export once (At this moment the picklist is still collection-scoped) 92 | knownPicklists[field.pickList.id] = true; 93 | picklistPromises.push(this._witProcessDefinitionApi.getList(field.pickList.id).then(picklist => _picklists.push( 94 | { 95 | workitemtypeRefName: workitemtype.id, 96 | fieldRefName: field.referenceName, 97 | picklist: picklist 98 | }))); 99 | } 100 | } 101 | return Promise.all(picklistPromises) 102 | })); 103 | 104 | let layoutForm: WITProcessDefinitionsInterfaces.FormLayout; 105 | currentWitPromises.push(this._witProcessDefinitionApi.getFormLayout(processId, workitemtype.id).then(layout => { 106 | const witLayout: IWITLayout = { 107 | workItemTypeRefName: workitemtype.id, 108 | layout: layout 109 | } 110 | _layouts.push(witLayout); 111 | })); 112 | 113 | currentWitPromises.push(this._witProcessDefinitionApi.getStateDefinitions(processId, workitemtype.id).then(states => { 114 | const witStates: IWITStates = { 115 | workItemTypeRefName: workitemtype.id, 116 | states: states 117 | } 118 | _states.push(witStates); 119 | })); 120 | 121 | currentWitPromises.push(this._witProcessApi.getWorkItemTypeRules(processId, workitemtype.id).then(rules => { 122 | const witRules: IWITRules = { 123 | workItemTypeRefName: workitemtype.id, 124 | rules: rules 125 | } 126 | _rules.push(witRules); 127 | })); 128 | } 129 | perWitPromises.push(Promise.all(currentWitPromises)); 130 | } 131 | 132 | return Promise.all(perWitPromises); 133 | })); 134 | 135 | //NOTE: it maybe out of order for per-workitemtype artifacts for different work item types 136 | // for example, you may have Bug and then Feature for 'States' but Feature comes before Bug for 'Rules' 137 | // the order does not matter since we stamp the work item type information 138 | await Promise.all(processPromises); 139 | 140 | const processPayload: IProcessPayload = { 141 | process: _process, 142 | fields: _fieldsCollectionScope, 143 | workItemTypeFields: _fieldsWorkitemtypeScope, 144 | workItemTypes: _nonSystemWorkItemTypes, 145 | layouts: _layouts, 146 | states: _states, 147 | rules: _rules, 148 | behaviors: _behaviorsCollectionScope, 149 | workItemTypeBehaviors: _behaviorsWITypeScope, 150 | witFieldPicklists: _picklists 151 | }; 152 | 153 | return processPayload; 154 | } 155 | 156 | public async exportProcess(): Promise { 157 | logger.logInfo("Export process started."); 158 | 159 | const processId = await Engine.Task(() => this._getSourceProcessId(), "Get source process Id from name"); 160 | const payload = await Engine.Task(() => this._getComponents(processId), "Get artifacts from source process"); 161 | 162 | logger.logInfo("Export process completed."); 163 | return payload; 164 | } 165 | } -------------------------------------------------------------------------------- /src/common/ProcessImporter.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { Guid } from "guid-typescript"; 3 | 4 | import * as WITInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; 5 | import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; 6 | import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; 7 | import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessDefinitionsApi"; 8 | import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessApi"; 9 | import { IWorkItemTrackingApi as WITApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingApi"; 10 | 11 | import { PICKLIST_NO_ACTION } from "./Constants"; 12 | import { Engine } from "./Engine"; 13 | import { ImportError, ValidationError } from "./Errors"; 14 | import { ICommandLineOptions, IConfigurationFile, IDictionaryStringTo, IProcessPayload, IWITLayout, IWITRules, IWITStates, IRestClients } from "./Interfaces"; 15 | import { logger } from "./Logger"; 16 | import { Utility } from "./Utilities"; 17 | 18 | export class ProcessImporter { 19 | private _witProcessApi: WITProcessApi_NOREQUIRE; 20 | private _witProcessDefinitionApi: WITProcessDefinitionApi_NOREQUIRE; 21 | private _witApi: WITApi_NOREQUIRE; 22 | 23 | constructor(restClients: IRestClients, private _config?: IConfigurationFile, private _commandLineOptions?: ICommandLineOptions) { 24 | this._witApi = restClients.witApi; 25 | this._witProcessApi = restClients.witProcessApi; 26 | this._witProcessDefinitionApi = restClients.witProcessDefinitionApi; 27 | } 28 | 29 | private async _importWorkItemTypes(payload: IProcessPayload): Promise { 30 | for (const wit of payload.workItemTypes) { 31 | if (wit.class === WITProcessInterfaces.WorkItemTypeClass.System) { 32 | //The exported payload should not have exported System WITypes, so fail on import. 33 | throw new ImportError(`Work item type '${wit.name}' is a system work item type with no modifications, cannot import.`); 34 | } 35 | else { 36 | const createdWorkItemType = await Utility.tryCatchWithKnownError(() => this._witProcessDefinitionApi.createWorkItemType(wit, payload.process.typeId), 37 | () => new ImportError(`Failed to create work item type '${wit.id}, see logs for details.`)); 38 | if (!createdWorkItemType || createdWorkItemType.id !== wit.id) { 39 | throw new ImportError(`Failed to create work item type '${wit.id}', server returned empty or reference name does not match.`); 40 | } 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * This process payload from export and return fields that need create also fix Identity field type and picklist id 47 | */ 48 | private async _getFieldsToCreate(payload: IProcessPayload): Promise { 49 | assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); 50 | 51 | let fieldsOnTarget: WITInterfaces.WorkItemField[]; 52 | try { 53 | fieldsOnTarget = await this._witApi.getFields(); 54 | if (!fieldsOnTarget || fieldsOnTarget.length <= 0) { // most likely 404 55 | throw new ImportError("Failed to get fields from target account, server returned empty result"); 56 | } 57 | } 58 | catch (error) { 59 | Utility.handleKnownError(error); 60 | throw new ImportError("Failed to get fields from target account, see logs for details.") 61 | } 62 | 63 | // Build a lookup to know if a field is picklist field. 64 | const isPicklistField: IDictionaryStringTo = {}; 65 | for (const e of payload.witFieldPicklists) { 66 | isPicklistField[e.fieldRefName] = true; 67 | } 68 | 69 | const outputFields: WITProcessDefinitionsInterfaces.FieldModel[] = []; 70 | for (const sourceField of payload.fields) { 71 | const fieldExist = fieldsOnTarget.some(targetField => targetField.referenceName === sourceField.id); 72 | if (!fieldExist) { 73 | const createField: WITProcessDefinitionsInterfaces.FieldModel = Utility.WITProcessToWITProcessDefinitionsFieldModel(sourceField); 74 | if (sourceField.isIdentity) { 75 | createField.type = WITProcessDefinitionsInterfaces.FieldType.Identity; 76 | } 77 | if (isPicklistField[sourceField.id]) { 78 | const picklistId = payload.targetAccountInformation.fieldRefNameToPicklistId[sourceField.id]; 79 | assert(picklistId !== PICKLIST_NO_ACTION, "[Unexpected] We are creating the field which we found the matching field earlier on collection") 80 | createField.pickList = { 81 | id: picklistId, 82 | isSuggested: null, 83 | name: null, 84 | type: null, 85 | url: null 86 | }; 87 | } 88 | outputFields.push(createField); 89 | } 90 | } 91 | return outputFields; 92 | } 93 | 94 | /**Create fields at a collection scope*/ 95 | private async _importFields(payload: IProcessPayload): Promise { 96 | const fieldsToCreate: WITProcessDefinitionsInterfaces.FieldModel[] = await Engine.Task(() => this._getFieldsToCreate(payload), "Get fields to be created on target process"); 97 | 98 | if (fieldsToCreate.length > 0) { 99 | for (const field of fieldsToCreate) { 100 | try { 101 | const fieldCreated = await Engine.Task(() => this._witProcessDefinitionApi.createField(field, payload.process.typeId), `Create field '${field.id}'`); 102 | if (!fieldCreated) { 103 | throw new ImportError(`Create field '${field.name}' failed, server returned empty object`); 104 | } 105 | if (fieldCreated.id !== field.id) { 106 | throw new ImportError(`Create field '${field.name}' actually returned referenace name '${fieldCreated.id}' instead of anticipated '${field.id}', are you on latest VSTS?`); 107 | } 108 | 109 | } 110 | catch (error) { 111 | Utility.handleKnownError(error); 112 | throw new ImportError(`Create field '${field.name}' failed, see log for details.`); 113 | } 114 | }; 115 | } 116 | } 117 | 118 | /**Add fields at a Work Item Type scope*/ 119 | private async _addFieldsToWorkItemTypes(payload: IProcessPayload): Promise { 120 | for (const entry of payload.workItemTypeFields) { 121 | for (const field of entry.fields) { 122 | try { 123 | // Make separate call to set default value on identity field allow failover 124 | const defaultValue = field.defaultValue; 125 | field.defaultValue = field.type === WITProcessDefinitionsInterfaces.FieldType.Identity ? null : defaultValue; 126 | 127 | const fieldAdded = await Engine.Task( 128 | () => this._witProcessDefinitionApi.addFieldToWorkItemType(field, payload.process.typeId, entry.workItemTypeRefName), 129 | `Add field '${field.referenceName}' to work item type '${entry.workItemTypeRefName}'`); 130 | 131 | if (!fieldAdded || fieldAdded.referenceName !== field.referenceName) { 132 | throw new ImportError(`Failed to add field '${field.referenceName}' to work item type '${entry.workItemTypeRefName}', server returned empty result or reference name does not match.`); 133 | } 134 | 135 | if (defaultValue) { 136 | field.defaultValue = defaultValue; 137 | try { 138 | const fieldAddedWithDefaultValue = await Engine.Task( 139 | () => this._witProcessDefinitionApi.addFieldToWorkItemType(field, payload.process.typeId, entry.workItemTypeRefName), 140 | `Updated field '${field.referenceName}' with default value to work item type '${entry.workItemTypeRefName}'`); 141 | } 142 | catch (error) { 143 | if (this._config.options && this._config.options.continueOnIdentityDefaultValueFailure === true) { 144 | logger.logWarning(`Failed to set field '${field.referenceName}' with default value '${JSON.stringify(defaultValue, null, 2)}' to work item type '${entry.workItemTypeRefName}', continue because 'skipImportControlContributions' is set to true`); 145 | } 146 | else { 147 | logger.logException(error); 148 | throw new ImportError(`Failed to set field '${field.referenceName}' with default value '${JSON.stringify(defaultValue, null, 2)}' to work item type '${entry.workItemTypeRefName}'. You may set skipImportControlContributions = true in configuraiton file to continue.`); 149 | } 150 | } 151 | } 152 | } 153 | catch (error) { 154 | Utility.handleKnownError(error); 155 | throw new ImportError(`Failed to add field '${field.referenceName}' to work item type '${entry.workItemTypeRefName}', see logs for details.`); 156 | } 157 | } 158 | } 159 | } 160 | 161 | private async _createGroup(createGroup: WITProcessDefinitionsInterfaces.Group, 162 | page: WITProcessDefinitionsInterfaces.Page, 163 | section: WITProcessDefinitionsInterfaces.Section, 164 | witLayout: IWITLayout, 165 | payload: IProcessPayload 166 | ) { 167 | let newGroup: WITProcessDefinitionsInterfaces.Group; 168 | try { 169 | newGroup = await Engine.Task( 170 | () => this._witProcessDefinitionApi.addGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id), 171 | `Create group '${createGroup.id}' in page '${page.id}'`); 172 | } 173 | catch (error) { 174 | logger.logException(error); 175 | throw new ImportError(`Failed to create group '${createGroup.id}' in page '${page.id}', see logs for details.`) 176 | } 177 | 178 | if (!newGroup || !newGroup.id) { 179 | throw new ImportError(`Failed to create group '${createGroup.id}' in page '${page.id}', server returned empty result or non-matching id.`) 180 | } 181 | 182 | return newGroup; 183 | } 184 | 185 | private async _editGroup(createGroup: WITProcessDefinitionsInterfaces.Group, 186 | page: WITProcessDefinitionsInterfaces.Page, 187 | section: WITProcessDefinitionsInterfaces.Section, 188 | group: WITProcessDefinitionsInterfaces.Group, 189 | witLayout: IWITLayout, 190 | payload: IProcessPayload 191 | ) { 192 | let newGroup: WITProcessDefinitionsInterfaces.Group; 193 | try { 194 | newGroup = await Engine.Task( 195 | () => this._witProcessDefinitionApi.editGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id, group.id), 196 | `edit group '${group.id}' in page '${page.id}'`); 197 | } 198 | catch (error) { 199 | logger.logException(error); 200 | throw new ImportError(`Failed to edit group '${group.id}' in page '${page.id}', see logs for details.`) 201 | } 202 | 203 | if (!newGroup || newGroup.id !== group.id) { 204 | throw new ImportError(`Failed to create group '${group.id}' in page '${page.id}', server returned empty result or id.`) 205 | } 206 | return newGroup; 207 | } 208 | 209 | private async _importPage(targetLayout: WITProcessDefinitionsInterfaces.FormLayout, witLayout: IWITLayout, page: WITProcessDefinitionsInterfaces.Page, payload: IProcessPayload) { 210 | if (!page) { 211 | throw new ImportError(`Encountered null page in work item type '${witLayout.workItemTypeRefName}'`); 212 | } 213 | 214 | if (page.isContribution && this._config.options.skipImportFormContributions === true) { 215 | // skip import page contriubtion unless user explicitly asks so 216 | return; 217 | } 218 | 219 | let newPage: WITProcessDefinitionsInterfaces.Page; //The newly created page, contains the pageId required to create groups. 220 | const createPage = Utility.toCreatePage(page); 221 | const sourcePagesOnTarget = targetLayout.pages.filter(p => p.id === page.id); 222 | try { 223 | newPage = sourcePagesOnTarget.length === 0 224 | ? await Engine.Task(() => this._witProcessDefinitionApi.addPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName), 225 | `Create '${page.id}' page in ${witLayout.workItemTypeRefName}`) 226 | : await Engine.Task(() => this._witProcessDefinitionApi.editPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName), 227 | `Edit '${page.id}' page in ${witLayout.workItemTypeRefName}`); 228 | } 229 | catch (error) { 230 | logger.logException(error); 231 | throw new ImportError(`Failed to create or edit '${page.id}' page in ${witLayout.workItemTypeRefName}, see logs for details.`); 232 | } 233 | if (!newPage || !newPage.id) { 234 | throw new ImportError(`Failed to create or edit '${page.id}' page in ${witLayout.workItemTypeRefName}, server returned empty result.`); 235 | } 236 | 237 | page.id = newPage.id; 238 | // First pass - process inherited groups first (in case a custom group uses inherited group name causing conflict) 239 | await this._importInheritedGroups(witLayout, page, payload); 240 | 241 | // Second pass - process custom groups and controls 242 | await this._importOtherGroupsAndControls(witLayout, page, payload); 243 | } 244 | 245 | private async _importInheritedGroups( 246 | witLayout: IWITLayout, 247 | page: WITProcessDefinitionsInterfaces.Page, 248 | payload: IProcessPayload 249 | ) { 250 | logger.logVerbose(`Start import inherited group changes`); 251 | for (const section of page.sections) { 252 | for (const group of section.groups) { 253 | if (group.inherited && group.overridden) { 254 | const updatedGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); 255 | await this._editGroup(updatedGroup, page, section, group, witLayout, payload); 256 | } 257 | } 258 | } 259 | } 260 | 261 | private async _importOtherGroupsAndControls( 262 | witLayout: IWITLayout, 263 | page: WITProcessDefinitionsInterfaces.Page, 264 | payload: IProcessPayload 265 | ) { 266 | logger.logVerbose(`Start import custom groups and all controls`); 267 | for (const section of page.sections) { 268 | for (const group of section.groups) { 269 | let newGroup: WITProcessDefinitionsInterfaces.Group; 270 | 271 | if (group.isContribution === true && this._config.options.skipImportFormContributions === true) { 272 | // skip import group contriubtion unless user explicitly asks so 273 | continue; 274 | } 275 | 276 | if (group.controls.length !== 0 && group.controls[0].controlType === "HtmlFieldControl") { 277 | //Handle groups with HTML Controls 278 | if (group.inherited) { 279 | if (group.overridden) { 280 | // No handling on group update since we have done this already in 1st pass 281 | const htmlControl = group.controls[0]; 282 | if (htmlControl.overridden) { 283 | // If the HTML control is overriden, we must update that as well 284 | let updatedHtmlControl: WITProcessDefinitionsInterfaces.Control; 285 | try { 286 | updatedHtmlControl = await Engine.Task( 287 | () => this._witProcessDefinitionApi.editControl(htmlControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, htmlControl.id), 288 | `Edit HTML control '${htmlControl.id} in group'${group.id}' in page '${page.id}'`); 289 | } 290 | catch (error) { 291 | logger.logException(error); 292 | throw new ImportError(`Failed to edit HTML control '${htmlControl.id} in group'${group.id}' in page '${page.id}', see logs for details.`) 293 | } 294 | 295 | if (!updatedHtmlControl || updatedHtmlControl.id !== htmlControl.id) { 296 | throw new ImportError(`Failed to edit group '${group.id}' in page '${page.id}', server returned empty result or non-matching id.`) 297 | } 298 | } 299 | } 300 | else { 301 | // no-op since the group is not overriden 302 | } 303 | } 304 | else { 305 | // special handling for HTML control - we must create a group containing the HTML control at same time. 306 | const createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); 307 | createGroup.controls = group.controls; 308 | await this._createGroup(createGroup, page, section, witLayout, payload); 309 | } 310 | } 311 | else { 312 | //Groups with no HTML Controls 313 | 314 | if (!group.inherited) { 315 | //create the group if it's not inherited 316 | const createGroup = Utility.toCreateGroup(group); 317 | newGroup = await this._createGroup(createGroup, page, section, witLayout, payload); 318 | group.id = newGroup.id; 319 | } 320 | 321 | for (const control of group.controls) { 322 | if (!control.inherited || control.overridden) { 323 | try { 324 | let createControl: WITProcessDefinitionsInterfaces.Control = Utility.toCreateControl(control); 325 | 326 | if (control.controlType === "WebpageControl" || (control.isContribution === true && this._config.options.skipImportFormContributions === true)) { 327 | // Skip web page control for now since not supported in inherited process. 328 | continue; 329 | } 330 | 331 | if (control.inherited) { 332 | if (control.overridden) { 333 | //edit 334 | await Engine.Task(() => this._witProcessDefinitionApi.editControl(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, control.id), 335 | `Edit control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`); 336 | } 337 | } 338 | else { 339 | //create 340 | await Engine.Task(() => this._witProcessDefinitionApi.addControlToGroup(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id), 341 | `Create control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`); 342 | } 343 | } 344 | catch (error) { 345 | Utility.handleKnownError(error); 346 | throw new ImportError(`Unable to add '${control.id}' control to group '${group.id}' in page '${page.id}' in '${witLayout.workItemTypeRefName}'. ${error}`); 347 | } 348 | } 349 | } 350 | } 351 | } 352 | } 353 | } 354 | 355 | private async _importLayouts(payload: IProcessPayload): Promise { 356 | /** Notes: 357 | * HTML controls need to be created at the same tme as the group they are in. 358 | * Non HTML controls need to be added 1 by 1 after the group they are in has been created. 359 | */ 360 | for (const witLayoutEntry of payload.layouts) { 361 | const targetLayout: WITProcessDefinitionsInterfaces.FormLayout = await Engine.Task( 362 | () => this._witProcessDefinitionApi.getFormLayout(payload.process.typeId, witLayoutEntry.workItemTypeRefName), 363 | `Get layout on target process for work item type '${witLayoutEntry.workItemTypeRefName}'`); 364 | for (const page of witLayoutEntry.layout.pages) { 365 | if (page.pageType === WITProcessDefinitionsInterfaces.PageType.Custom) { 366 | await this._importPage(targetLayout, witLayoutEntry, page, payload); 367 | } 368 | } 369 | } 370 | } 371 | 372 | private async _importWITStates(witStateEntry: IWITStates, payload: IProcessPayload) { 373 | let targetWITStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[]; 374 | try { 375 | targetWITStates = await Engine.Task( 376 | () => this._witProcessApi.getStateDefinitions(payload.process.typeId, witStateEntry.workItemTypeRefName), 377 | `Get states on target process for work item type '${witStateEntry.workItemTypeRefName}'`); 378 | if (!targetWITStates || targetWITStates.length <= 0) { 379 | throw new ImportError(`Failed to get states definitions from work item type '${witStateEntry.workItemTypeRefName}' on target account, server returned empty result.`) 380 | } 381 | } 382 | catch (error) { 383 | Utility.handleKnownError(error); 384 | throw new ImportError(`Failed to get states definitions from work item type '${witStateEntry.workItemTypeRefName}' on target account, see logs for details.`) 385 | } 386 | 387 | for (const sourceState of witStateEntry.states) { 388 | try { 389 | const existingStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = targetWITStates.filter(targetState => sourceState.name === targetState.name); 390 | if (existingStates.length === 0) { // does not exist on target 391 | const createdState = await Engine.Task( 392 | () => this._witProcessDefinitionApi.createStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName), 393 | `Create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); 394 | if (!createdState || !createdState.id) { 395 | throw new ImportError(`Unable to create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result or id.`); 396 | } 397 | } 398 | else { 399 | if (sourceState.hidden) { // if state exists on target, only update if hidden 400 | const hiddenState = await Engine.Task( 401 | () => this._witProcessDefinitionApi.hideStateDefinition({ hidden: true }, payload.process.typeId, witStateEntry.workItemTypeRefName, existingStates[0].id), 402 | `Hide state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); 403 | if (!hiddenState || hiddenState.name !== sourceState.name || !hiddenState.hidden) { 404 | throw new ImportError(`Unable to hide state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result, id or state is not hidden.`); 405 | } 406 | } 407 | 408 | const existingState = existingStates[0]; 409 | if (sourceState.color !== existingState.color || sourceState.stateCategory !== existingState.stateCategory || sourceState.name !== existingState.name) { 410 | // Inherited state can be edited in custom work item types. 411 | const updatedState = await Engine.Task( 412 | () => this._witProcessDefinitionApi.updateStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName, existingState.id), 413 | `Update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); 414 | if (!updatedState || updatedState.name !== sourceState.name) { 415 | throw new ImportError(`Unable to update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result, id or state is not hidden.`); 416 | } 417 | } 418 | } 419 | } 420 | catch (error) { 421 | Utility.handleKnownError(error); 422 | throw new ImportError(`Unable to create/hide/update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, see logs for details`); 423 | } 424 | } 425 | 426 | for (const targetState of targetWITStates) { 427 | const sourceStateMatchingTarget: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = witStateEntry.states.filter(sourceState => sourceState.name === targetState.name); 428 | if (sourceStateMatchingTarget.length === 0) { 429 | try { 430 | await Engine.Task(() => this._witProcessDefinitionApi.deleteStateDefinition(payload.process.typeId, witStateEntry.workItemTypeRefName, targetState.id), 431 | `Delete state '${targetState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); 432 | } 433 | catch (error) { 434 | throw new ImportError(`Unable to delete state '${targetState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, see logs for details`); 435 | } 436 | } 437 | } 438 | } 439 | 440 | private async _importStates(payload: IProcessPayload): Promise { 441 | for (const witStateEntry of payload.states) { 442 | await this._importWITStates(witStateEntry, payload); 443 | } 444 | } 445 | 446 | private async _importWITRule(rule: WITProcessInterfaces.FieldRuleModel, witRulesEntry: IWITRules, payload: IProcessPayload) { 447 | try { 448 | const createdRule = await Engine.Task( 449 | () => this._witProcessApi.addWorkItemTypeRule(rule, payload.process.typeId, witRulesEntry.workItemTypeRefName), 450 | `Create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}'`); 451 | 452 | if (!createdRule || !createdRule.id) { 453 | throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', server returned empty result or id.`); 454 | } 455 | } 456 | catch (error) { 457 | if (this._config.options.continueOnRuleImportFailure === true) { 458 | logger.logWarning(`Failed to import rule below, continue importing rest of process.\r\n:Error:${error}\r\n${JSON.stringify(rule, null, 2)}`); 459 | } 460 | else { 461 | Utility.handleKnownError(error); 462 | throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', see logs for details.`); 463 | } 464 | } 465 | } 466 | 467 | private async _importRules(payload: IProcessPayload): Promise { 468 | for (const witRulesEntry of payload.rules) { 469 | for (const rule of witRulesEntry.rules) { 470 | if (!rule.isSystem) { 471 | await this._importWITRule(rule, witRulesEntry, payload); 472 | } 473 | } 474 | } 475 | } 476 | 477 | private async _importBehaviors(payload: IProcessPayload): Promise { 478 | const behaviorsOnTarget = await Utility.tryCatchWithKnownError( 479 | async () => { 480 | return await Engine.Task( 481 | () => this._witProcessApi.getBehaviors(payload.process.typeId), 482 | `Get behaviors on target account`); 483 | }, () => new ImportError(`Failed to get behaviors on target account.`)); 484 | 485 | const behaviorIdToRealNameBehavior: { [id: string]: WITProcessDefinitionsInterfaces.BehaviorReplaceModel } = {}; 486 | 487 | for (const behavior of payload.behaviors) { 488 | try { 489 | const existing = behaviorsOnTarget.some(b => b.id === behavior.id); 490 | if (!existing) { 491 | const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = Utility.toCreateBehavior(behavior); 492 | // Use a random name to avoid conflict on scenarios involving a name swap 493 | behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior); 494 | createBehavior.name = Utility.createGuidWithoutHyphen(); 495 | const createdBehavior = await Engine.Task( 496 | () => this._witProcessDefinitionApi.createBehavior(createBehavior, payload.process.typeId), 497 | `Create behavior '${behavior.id}' with fake name '${behavior.name}'`); 498 | if (!createdBehavior || createdBehavior.id !== behavior.id) { 499 | throw new ImportError(`Failed to create behavior '${behavior.name}', server returned empty result or id does not match.`) 500 | } 501 | } 502 | else { 503 | const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = Utility.toReplaceBehavior(behavior); 504 | behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior); 505 | replaceBehavior.name = Utility.createGuidWithoutHyphen(); 506 | const replacedBehavior = await Engine.Task( 507 | () => this._witProcessDefinitionApi.replaceBehavior(replaceBehavior, payload.process.typeId, behavior.id), 508 | `Replace behavior '${behavior.id}' with fake name '${behavior.name}'`); 509 | if (!replacedBehavior) { 510 | throw new ImportError(`Failed to replace behavior '${behavior.name}', server returned empty result.`) 511 | } 512 | } 513 | } 514 | catch (error) { 515 | logger.logException(error); 516 | throw new ImportError(`Failed to import behavior ${behavior.name}, see logs for details.`); 517 | } 518 | } 519 | 520 | // Recover the behavior names to what they should be 521 | for (const id in behaviorIdToRealNameBehavior) { 522 | const behaviorWithRealName = behaviorIdToRealNameBehavior[id]; 523 | const replacedBehavior = await Engine.Task( 524 | () => this._witProcessDefinitionApi.replaceBehavior(behaviorWithRealName, payload.process.typeId, id), 525 | `Replace behavior '${id}' to it's real name '${behaviorWithRealName.name}'`); 526 | if (!replacedBehavior) { 527 | throw new ImportError(`Failed to replace behavior id '${id}' to its real name, server returned empty result.`) 528 | } 529 | } 530 | } 531 | 532 | private async _addBehaviorsToWorkItemTypes(payload: IProcessPayload): Promise { 533 | for (const witBehaviorsEntry of payload.workItemTypeBehaviors) { 534 | for (const behavior of witBehaviorsEntry.behaviors) { 535 | try { 536 | if (witBehaviorsEntry.workItemType.workItemTypeClass === WITProcessDefinitionsInterfaces.WorkItemTypeClass.Custom) { 537 | const addedBehavior = await Engine.Task( 538 | () => this._witProcessDefinitionApi.addBehaviorToWorkItemType(behavior, payload.process.typeId, witBehaviorsEntry.workItemType.refName), 539 | `Add behavior '${behavior.behavior.id}' to work item type '${witBehaviorsEntry.workItemType.refName}'`); 540 | 541 | if (!addedBehavior || addedBehavior.behavior.id !== behavior.behavior.id) { 542 | throw new ImportError(`Failed to add behavior '${behavior.behavior.id}' to work item type '${witBehaviorsEntry.workItemType.refName}, server returned empty result or id does not match`); 543 | } 544 | } 545 | } 546 | catch (error) { 547 | Utility.handleKnownError(error); 548 | throw new ImportError(`Failed to add behavior '${behavior.behavior.id}' to work item type '${witBehaviorsEntry.workItemType.refName}', check logs for details.`); 549 | } 550 | } 551 | } 552 | } 553 | 554 | private async _importPicklists(payload: IProcessPayload): Promise { 555 | assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); 556 | 557 | const targetFieldToPicklistId = payload.targetAccountInformation.fieldRefNameToPicklistId; 558 | const processedFieldRefNames: IDictionaryStringTo = {}; 559 | for (const picklistEntry of payload.witFieldPicklists) { 560 | if (processedFieldRefNames[picklistEntry.fieldRefName] === true) { 561 | continue; // Skip since we already processed the field, it might be referenced by different work item type 562 | } 563 | 564 | const targetPicklistId = targetFieldToPicklistId[picklistEntry.fieldRefName]; 565 | if (targetPicklistId && targetPicklistId !== PICKLIST_NO_ACTION) { 566 | // Picklist exists but items not match, update items 567 | let newpicklist: WITProcessDefinitionsInterfaces.PickListModel = {}; 568 | Object.assign(newpicklist, picklistEntry.picklist); 569 | newpicklist.id = targetPicklistId; 570 | try { 571 | const updatedPicklist = await Engine.Task( 572 | () => this._witProcessDefinitionApi.updateList(newpicklist, targetPicklistId), 573 | `Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}'`); 574 | 575 | // validate the updated list matches expectation 576 | if (!updatedPicklist || !updatedPicklist.id) { 577 | throw new ImportError(`Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, result is emtpy, possibly the picklist does not exist on target collection`); 578 | } 579 | 580 | if (updatedPicklist.items.length !== picklistEntry.picklist.items.length) { 581 | throw new ImportError(`Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, items number does not match.`); 582 | } 583 | 584 | for (const item of updatedPicklist.items) { 585 | if (!picklistEntry.picklist.items.some(i => i.value === item.value)) { 586 | throw new ImportError(`Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, item '${item.value}' does not match expected`); 587 | } 588 | } 589 | } 590 | catch (error) { 591 | Utility.handleKnownError(error); 592 | throw new ImportError(`Failed to update picklist '${targetPicklistId} for field '${picklistEntry.fieldRefName}', check logs for details.`); 593 | } 594 | } 595 | else if (!targetPicklistId) { 596 | // Target field does not exist we need create picklist to be used when create field. 597 | picklistEntry.picklist.name = `picklist_${Guid.create()}`; // Avoid conflict on target 598 | try { 599 | const createdPicklist = await Engine.Task( 600 | () => this._witProcessDefinitionApi.createList(picklistEntry.picklist), 601 | `Create picklist for field ${picklistEntry.fieldRefName}`); 602 | 603 | if (!createdPicklist || !createdPicklist.id) { 604 | throw new ImportError(`Failed to create picklist for field ${picklistEntry.fieldRefName}, server returned empty result or id.`); 605 | } 606 | 607 | targetFieldToPicklistId[picklistEntry.fieldRefName] = createdPicklist.id; 608 | } 609 | catch (error) { 610 | Utility.handleKnownError(error); 611 | throw new ImportError(`Failed to create picklist for field ${picklistEntry.fieldRefName}, see logs for details.`); 612 | } 613 | } 614 | 615 | processedFieldRefNames[picklistEntry.fieldRefName] = true; 616 | } 617 | } 618 | 619 | private async _createComponents(payload: IProcessPayload): Promise { 620 | await Engine.Task(() => this._importPicklists(payload), "Import picklists on target account"); // This must be before field import 621 | await Engine.Task(() => this._importFields(payload), "Import fields on target account"); 622 | await Engine.Task(() => this._importWorkItemTypes(payload), "Import work item types on target process"); 623 | await Engine.Task(() => this._addFieldsToWorkItemTypes(payload), "Add field to work item types on target process"); 624 | await Engine.Task(() => this._importLayouts(payload), "Import work item form layouts on target process"); 625 | await Engine.Task(() => this._importStates(payload), "Import states on target process"); 626 | await Engine.Task(() => this._importRules(payload), "Import rules on target process"); 627 | await Engine.Task(() => this._importBehaviors(payload), "Import behaviors on target process"); 628 | await Engine.Task(() => this._addBehaviorsToWorkItemTypes(payload), "Add behavior to work item types on target process"); 629 | } 630 | 631 | private async _validateProcess(payload: IProcessPayload): Promise { 632 | if (payload.process.properties.class != WITProcessInterfaces.ProcessClass.Derived) { 633 | throw new ValidationError("Only inherited process is supported to be imported."); 634 | } 635 | 636 | const targetProcesses: WITProcessInterfaces.ProcessModel[] = 637 | await Utility.tryCatchWithKnownError(async () => { 638 | return await Engine.Task(() => this._witProcessApi.getProcesses(), `Get processes on target account`); 639 | }, () => new ValidationError("Failed to get processes on target acccount, check account url, token and token permission.")); 640 | 641 | if (!targetProcesses) { // most likely 404 642 | throw new ValidationError("Failed to get processes on target acccount, check account url."); 643 | } 644 | 645 | for (const process of targetProcesses) { 646 | if (payload.process.name.toLowerCase() === process.name.toLowerCase()) { 647 | throw new ValidationError("Process with same name already exists on target account."); 648 | } 649 | } 650 | } 651 | 652 | private async _validateFields(payload: IProcessPayload): Promise { 653 | const currentFieldsOnTarget: WITInterfaces.WorkItemField[] = 654 | await Utility.tryCatchWithKnownError(async () => { 655 | return await Engine.Task( 656 | () => this._witApi.getFields(), 657 | `Get fields on target account`); 658 | }, () => new ValidationError("Failed to get fields on target account.")); 659 | 660 | if (!currentFieldsOnTarget) { // most likely 404 661 | throw new ValidationError("Failed to get fields on target account.") 662 | } 663 | 664 | payload.targetAccountInformation.collectionFields = currentFieldsOnTarget; 665 | for (const sourceField of payload.fields) { 666 | const convertedSrcFieldType: number = Utility.WITProcessToWITFieldType(sourceField.type, sourceField.isIdentity); 667 | const conflictingFields: WITInterfaces.WorkItemField[] = currentFieldsOnTarget.filter(targetField => 668 | ((targetField.referenceName === sourceField.id) || (targetField.name === sourceField.name)) // match by name or reference name 669 | && convertedSrcFieldType !== targetField.type // but with a different type 670 | && (!sourceField.isIdentity || !targetField.isIdentity)); // with exception if both are identity - known issue we export identity field type = string 671 | 672 | if (conflictingFields.length > 0) { 673 | throw new ValidationError(`Field in target Collection conflicts with '${sourceField.name}' field with a different reference name or type.`); 674 | } 675 | } 676 | } 677 | 678 | private async _populatePicklistDictionary(fields: WITInterfaces.WorkItemField[]): Promise> { 679 | const ret: IDictionaryStringTo = {}; 680 | const promises: Promise[] = []; 681 | for (const field of fields) { 682 | const anyField = field; // TODO: When vso-node-api updates, remove this hack 683 | assert(field.isPicklist || !anyField.picklistId, "Non picklist field should not have picklist") 684 | if (field.isPicklist && anyField.picklistId) { 685 | promises.push(this._witProcessDefinitionApi.getList(anyField.picklistId).then(list => ret[field.referenceName] = list)); 686 | } 687 | } 688 | await Promise.all(promises); 689 | return ret; 690 | } 691 | 692 | /** 693 | * Validate picklist and output to payload.targetAccountInformation.fieldRefNameToPicklistId for directions under different case 694 | * 1) Picklist field does not exist -> importPicklists will create picklist and importFields will use the picklist created 695 | * 2) Picklist field exist and items match -> no-op for importPicklists/importFields 696 | * 3) Picklist field exists but items does not match -> if 'overwritePicklist' enabled, importPicklists will update items and importFields will skip 697 | * @param payload 698 | */ 699 | private async _validatePicklists(payload: IProcessPayload): Promise { 700 | assert(payload.targetAccountInformation && payload.targetAccountInformation.collectionFields, "[Unexpected] - targetInformation not properly populated"); 701 | 702 | const fieldToPicklistIdMapping = payload.targetAccountInformation.fieldRefNameToPicklistId; // This is output for import picklist/field 703 | const currentTargetFieldToPicklist = await this._populatePicklistDictionary(payload.targetAccountInformation.collectionFields); 704 | 705 | for (const picklistEntry of payload.witFieldPicklists) { 706 | const fieldRefName = picklistEntry.fieldRefName; 707 | const currentTargetPicklist = currentTargetFieldToPicklist[fieldRefName]; 708 | if (currentTargetPicklist) { 709 | // Compare the pick list items 710 | let conflict: boolean; 711 | if (currentTargetPicklist.items.length === picklistEntry.picklist.items.length && !currentTargetPicklist.isSuggested === !picklistEntry.picklist.isSuggested) { 712 | for (const sourceItem of picklistEntry.picklist.items) { 713 | if (currentTargetPicklist.items.filter(targetItem => targetItem.value === sourceItem.value).length !== 1) { 714 | conflict = true; 715 | break; 716 | } 717 | } 718 | } 719 | else { 720 | conflict = true; 721 | } 722 | 723 | if (conflict) { 724 | if (!(this._config.options && this._config.options.overwritePicklist === true)) { 725 | throw new ValidationError(`Picklist field ${fieldRefName} exist on target account but have different items than source, set 'overwritePicklist' option to overwrite`); 726 | } 727 | else { 728 | fieldToPicklistIdMapping[fieldRefName] = currentTargetPicklist.id; // We will need to update the picklist later when import picklists 729 | } 730 | } 731 | else { 732 | fieldToPicklistIdMapping[fieldRefName] = PICKLIST_NO_ACTION; // No action needed since picklist values match. 733 | } 734 | } 735 | else { 736 | // No-op, leave payload.targetAccountInformation.fieldRefNameToPicklistId[picklistEntry.fieldRefName] = undefined, which indicates creating new picklist. 737 | } 738 | } 739 | } 740 | 741 | private async _preImportValidation(payload: IProcessPayload): Promise { 742 | payload.targetAccountInformation = { 743 | fieldRefNameToPicklistId: {} 744 | }; // set initial value for target account information 745 | 746 | if (!this._commandLineOptions.overwriteProcessOnTarget) { // only validate if we are not cleaning up target 747 | await Engine.Task(() => this._validateProcess(payload), "Validate process existence on target account"); 748 | } 749 | await Engine.Task(() => this._validateFields(payload), "Validate fields on target account"); 750 | await Engine.Task(() => this._validatePicklists(payload), "Validate picklists on target account"); 751 | } 752 | 753 | private async _deleteProcessOnTarget(targetProcessName: string) { 754 | const processes = await this._witProcessApi.getProcesses(); 755 | for (const process of processes.filter(p => p.name.toLocaleLowerCase() === targetProcessName.toLocaleLowerCase())) { 756 | await Utility.tryCatchWithKnownError( 757 | async () => await Engine.Task( 758 | () => this._witProcessApi.deleteProcess(process.typeId), 759 | `Delete process '${process.name}' on target account`), 760 | () => new ImportError(`Failed to delete process on target, do you have projects created using that project?`)); 761 | } 762 | } 763 | 764 | private async _createProcess(payload: IProcessPayload) { 765 | const createProcessModel: WITProcessInterfaces.CreateProcessModel = Utility.ProcessModelToCreateProcessModel(payload.process); 766 | const createdProcess = await Engine.Task( 767 | () => this._witProcessApi.createProcess(createProcessModel), 768 | `Create process '${createProcessModel.name}'`); 769 | if (!createdProcess) { 770 | throw new ImportError(`Failed to create process '${createProcessModel.name}' on target account.`); 771 | } 772 | payload.process.typeId = createdProcess.typeId; 773 | } 774 | 775 | public async importProcess(payload: IProcessPayload): Promise { 776 | logger.logInfo("Process import started."); 777 | 778 | try { 779 | if (this._config.targetProcessName) { 780 | payload.process.name = this._config.targetProcessName; 781 | } 782 | 783 | await Engine.Task(() => this._preImportValidation(payload), "Pre-import validation on target account"); 784 | 785 | if (this._commandLineOptions.overwriteProcessOnTarget) { 786 | await Engine.Task(() => this._deleteProcessOnTarget(payload.process.name), "Delete process (if exist) on target account"); 787 | } 788 | 789 | await Engine.Task(() => this._createProcess(payload), "Create process on target account"); 790 | await Engine.Task(() => this._createComponents(payload), "Create artifacts on target process"); 791 | } 792 | catch (error) { 793 | if (error instanceof ValidationError) { 794 | logger.logError("Pre-Import validation failed. No artifacts were created on target process") 795 | } 796 | throw error; 797 | } 798 | 799 | logger.logInfo("Process import completed successfully."); 800 | } 801 | } 802 | -------------------------------------------------------------------------------- /src/common/Utilities.ts: -------------------------------------------------------------------------------- 1 | import * as WITInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; 2 | import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; 3 | import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; 4 | import { KnownError } from "./Errors"; 5 | import { logger } from "./Logger"; 6 | import { Modes, IConfigurationFile, LogLevel, ICommandLineOptions } from "./Interfaces"; 7 | import * as url from "url"; 8 | import { Guid } from "guid-typescript"; 9 | import { regexRemoveHypen } from "./Constants"; 10 | 11 | export class Utility { 12 | /** Convert from WITProcess FieldModel to WITProcessDefinitions FieldModel 13 | * @param fieldModel 14 | */ 15 | public static WITProcessToWITProcessDefinitionsFieldModel(fieldModel: WITProcessInterfaces.FieldModel): WITProcessDefinitionsInterfaces.FieldModel { 16 | 17 | let outField: WITProcessDefinitionsInterfaces.FieldModel = { 18 | description: fieldModel.description, 19 | id: fieldModel.id, 20 | name: fieldModel.name, 21 | type: fieldModel.isIdentity ? WITProcessDefinitionsInterfaces.FieldType.Identity : fieldModel.type, 22 | url: fieldModel.url, 23 | pickList: null 24 | } 25 | return outField; 26 | } 27 | 28 | /** Convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType 29 | * @param witProcessFieldType 30 | */ 31 | public static WITProcessToWITFieldType(witProcessFieldType: number, fieldIsIdentity: boolean): number { 32 | if (fieldIsIdentity) { return WITInterfaces.FieldType.Identity; } 33 | 34 | switch (witProcessFieldType) { 35 | case WITProcessInterfaces.FieldType.String: { return WITInterfaces.FieldType.String; } 36 | case WITProcessInterfaces.FieldType.Integer: { return WITInterfaces.FieldType.Integer; } 37 | case WITProcessInterfaces.FieldType.DateTime: { return WITInterfaces.FieldType.DateTime; } 38 | case WITProcessInterfaces.FieldType.PlainText: { return WITInterfaces.FieldType.PlainText; } 39 | case WITProcessInterfaces.FieldType.Html: { return WITInterfaces.FieldType.Html; } 40 | case WITProcessInterfaces.FieldType.TreePath: { return WITInterfaces.FieldType.TreePath; } 41 | case WITProcessInterfaces.FieldType.History: { return WITInterfaces.FieldType.History; } 42 | case WITProcessInterfaces.FieldType.Double: { return WITInterfaces.FieldType.Double; } 43 | case WITProcessInterfaces.FieldType.Guid: { return WITInterfaces.FieldType.Guid; } 44 | case WITProcessInterfaces.FieldType.Boolean: { return WITInterfaces.FieldType.Boolean; } 45 | case WITProcessInterfaces.FieldType.Identity: { return WITInterfaces.FieldType.Identity; } 46 | case WITProcessInterfaces.FieldType.PicklistInteger: { return WITInterfaces.FieldType.PicklistInteger; } 47 | case WITProcessInterfaces.FieldType.PicklistString: { return WITInterfaces.FieldType.PicklistString; } 48 | case WITProcessInterfaces.FieldType.PicklistDouble: { return WITInterfaces.FieldType.PicklistDouble; } 49 | default: { throw new Error(`Failed to convert from WorkItemTrackingProcess.FieldType to WorkItemTracking.FieldType, unrecognized enum value '${witProcessFieldType}'`) } 50 | } 51 | } 52 | 53 | /**Convert process from ProcessModel to CreateProcessModel 54 | * @param processModel 55 | */ 56 | public static ProcessModelToCreateProcessModel(processModel: WITProcessInterfaces.ProcessModel): WITProcessInterfaces.CreateProcessModel { 57 | const createModel: WITProcessInterfaces.CreateProcessModel = { 58 | description: processModel.description, 59 | name: processModel.name, 60 | parentProcessTypeId: processModel.properties.parentProcessTypeId, 61 | referenceName: Utility.createGuidWithoutHyphen() // Reference name does not really matter since we already have typeId 62 | }; 63 | return createModel; 64 | } 65 | 66 | /**Convert group from getLayout group interface to WITProcessDefinitionsInterfaces.Group 67 | * @param group 68 | */ 69 | public static toCreateGroup(group: WITProcessDefinitionsInterfaces.Group): WITProcessDefinitionsInterfaces.Group { 70 | let createGroup: WITProcessDefinitionsInterfaces.Group = { 71 | id: group.id, 72 | inherited: group.inherited, 73 | label: group.label, 74 | isContribution: group.isContribution, 75 | visible: group.visible, 76 | controls: null, 77 | contribution: group.contribution, 78 | height: group.height, 79 | order: null, 80 | overridden: null 81 | } 82 | return createGroup; 83 | } 84 | 85 | /**Convert control from getLayout control interface to WITProcessDefinitionsInterfaces.Control 86 | * @param control 87 | */ 88 | public static toCreateControl(control: WITProcessDefinitionsInterfaces.Control): WITProcessDefinitionsInterfaces.Control { 89 | let createControl: WITProcessDefinitionsInterfaces.Control = { 90 | id: control.id, 91 | inherited: control.inherited, 92 | label: control.label, 93 | controlType: control.controlType, 94 | readOnly: control.readOnly, 95 | watermark: control.watermark, 96 | metadata: control.metadata, 97 | visible: control.visible, 98 | isContribution: control.isContribution, 99 | contribution: control.contribution, 100 | height: control.height, 101 | order: null, 102 | overridden: null 103 | } 104 | return createControl; 105 | } 106 | 107 | /**Convert page from getLayout page interface to WITProcessDefinitionsInterfaces.Page 108 | * @param control 109 | */ 110 | public static toCreatePage(page: WITProcessDefinitionsInterfaces.Page): WITProcessDefinitionsInterfaces.Page { 111 | let createPage: WITProcessDefinitionsInterfaces.Page = { 112 | id: page.id, 113 | inherited: page.inherited, 114 | label: page.label, 115 | pageType: page.pageType, 116 | locked: page.locked, 117 | visible: page.visible, 118 | isContribution: page.isContribution, 119 | sections: null, 120 | contribution: page.contribution, 121 | order: null, 122 | overridden: null 123 | } 124 | return createPage; 125 | } 126 | 127 | /**Convert a state result to state input 128 | * @param group 129 | */ 130 | public static toCreateOrUpdateStateDefinition(state: WITProcessInterfaces.WorkItemStateResultModel): WITProcessDefinitionsInterfaces.WorkItemStateInputModel { 131 | const updateState: WITProcessDefinitionsInterfaces.WorkItemStateInputModel = { 132 | color: state.color, 133 | name: state.name, 134 | stateCategory: state.stateCategory, 135 | order: null 136 | } 137 | return updateState; 138 | } 139 | 140 | public static toCreateBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorCreateModel { 141 | const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = { 142 | color: behavior.color, 143 | inherits: behavior.inherits.id, 144 | name: behavior.name 145 | }; 146 | // TODO: Move post S135 when generated model has id. 147 | (createBehavior).id = behavior.id; 148 | return createBehavior; 149 | } 150 | 151 | public static toReplaceBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorReplaceModel { 152 | const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = { 153 | color: behavior.color, 154 | name: behavior.name 155 | } 156 | return replaceBehavior; 157 | } 158 | 159 | public static handleKnownError(error: any) { 160 | if (error instanceof KnownError) { throw error; } 161 | logger.logException(error); 162 | } 163 | 164 | public static async tryCatchWithKnownError(action: () => Promise | T, thrower: () => Error): Promise { 165 | try { 166 | return await action(); 167 | } 168 | catch (error) { 169 | Utility.handleKnownError(error); 170 | throw thrower(); 171 | } 172 | } 173 | 174 | public static validateConfiguration(configuration: IConfigurationFile, mode: Modes): boolean { 175 | if (mode === Modes.export || mode === Modes.migrate) { 176 | if (!configuration.sourceAccountUrl || !url.parse(configuration.sourceAccountUrl).host) { 177 | logger.logError(`[Configuration validation] Missing or invalid source account url: '${configuration.sourceAccountUrl}'.`); 178 | return false; 179 | } 180 | if (!configuration.sourceAccountToken) { 181 | logger.logError(`[Configuration validation] Missing personal access token for source account.`); 182 | return false; 183 | } 184 | if (!configuration.sourceProcessName) { 185 | logger.logError(`[Configuration validation] Missing source process name.`); 186 | return false; 187 | } 188 | } 189 | 190 | if (mode === Modes.import || mode === Modes.migrate) { 191 | if (!configuration.targetAccountUrl || !url.parse(configuration.targetAccountUrl).host) { 192 | logger.logError(`[Configuration validation] Missing or invalid target account url: '${configuration.targetAccountUrl}'.`); 193 | return false; 194 | } 195 | if (!configuration.targetAccountToken) { 196 | logger.logError(`[Configuration validation] Missing personal access token for target account.`); 197 | return false; 198 | } 199 | if (configuration.options && configuration.options.overwritePicklist && (configuration.options.overwritePicklist !== true && configuration.options.overwritePicklist !== false)) { 200 | logger.logError(`[Configuration validation] Option 'overwritePicklist' is not a valid boolean.`); 201 | return false; 202 | } 203 | if (configuration.options && configuration.options.continueOnRuleImportFailure && (configuration.options.continueOnRuleImportFailure !== true && configuration.options.continueOnRuleImportFailure !== false)) { 204 | logger.logError(`[Configuration validation] Option 'continueOnRuleImportFailure' is not a valid boolean.`); 205 | return false; 206 | } 207 | if (configuration.options && configuration.options.continueOnIdentityDefaultValueFailure && (configuration.options.continueOnIdentityDefaultValueFailure !== true && configuration.options.continueOnIdentityDefaultValueFailure !== false)) { 208 | logger.logError(`[Configuration validation] Option 'continueOnFieldImportDefaultValueFailure' is not a valid boolean.`); 209 | return false; 210 | } 211 | if (configuration.options && configuration.options.skipImportFormContributions && (configuration.options.skipImportFormContributions !== true && configuration.options.skipImportFormContributions !== false)) { 212 | logger.logError(`[Configuration validation] Option 'skipImportFormContributions' is not a valid boolean.`); 213 | return false; 214 | } 215 | } 216 | 217 | if (configuration.options && configuration.options.logLevel && LogLevel[configuration.options.logLevel] === undefined) { 218 | logger.logError(`[Configuration validation] Option 'logLevel' is not a valid log level.`); 219 | return false; 220 | } 221 | 222 | return true; 223 | } 224 | 225 | public static didUserCancel(): boolean { 226 | return Utility.isCancelled; 227 | } 228 | 229 | public static createGuidWithoutHyphen(): string { 230 | return Guid.create().toString().replace(regexRemoveHypen, ""); 231 | } 232 | 233 | protected static isCancelled = false; 234 | } 235 | -------------------------------------------------------------------------------- /src/nodejs/ConfigurationProcessor.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from "fs"; 2 | import { normalize } from "path"; 3 | import * as minimist from "minimist"; 4 | import * as url from "url"; 5 | import { defaultConfiguration, defaultConfigurationFilename, defaultEncoding, paramConfig, paramMode, paramSourceToken, paramTargetToken } from "../common/Constants"; 6 | import { IConfigurationFile, LogLevel, Modes, ICommandLineOptions } from "../common/Interfaces"; 7 | import { logger } from "../common/Logger"; 8 | import { Utility } from "../common/Utilities"; 9 | import { parse as jsoncParse } from "jsonc-parser"; 10 | 11 | export function ProcesCommandLine(): ICommandLineOptions { 12 | const parseOptions: minimist.Opts = { 13 | boolean: true, 14 | alias: { 15 | "help": "h", 16 | "mode": "m", 17 | "config": "c", 18 | "sourcetoken": "s", 19 | "targettoken": "t" 20 | } 21 | } 22 | const parsedArgs = minimist(process.argv, parseOptions); 23 | 24 | if (parsedArgs["h"]) { 25 | logger.logInfo(`Usage: process-migrator [--mode= [--config=]`); 26 | process.exit(0); 27 | } 28 | 29 | const configFileName = parsedArgs[paramConfig] || normalize(defaultConfigurationFilename); 30 | 31 | const userSpecifiedMode = parsedArgs[paramMode] as string; 32 | let mode; 33 | if (userSpecifiedMode) { 34 | switch (userSpecifiedMode.toLocaleLowerCase()) { 35 | case Modes[Modes.export]: mode = Modes.export; break; 36 | case Modes[Modes.import]: mode = Modes.import; break; 37 | case Modes[Modes.migrate]: mode = Modes.migrate; break; 38 | default: logger.logError(`Invalid mode argument, allowed values are 'import', 'export' or 'migrate'.`); process.exit(1); 39 | } 40 | } else { 41 | mode = Modes.migrate; 42 | } 43 | 44 | const ret = {}; 45 | ret[paramMode] = mode; 46 | ret[paramConfig] = configFileName; 47 | ret[paramSourceToken] = parsedArgs[paramSourceToken]; 48 | ret[paramTargetToken] = parsedArgs[paramTargetToken]; 49 | 50 | return ret; 51 | } 52 | 53 | export async function ProcessConfigurationFile(commandLineOptions: ICommandLineOptions): Promise { 54 | // Load configuration file 55 | const configFile = commandLineOptions.config; 56 | if (!existsSync(configFile)) { 57 | logger.logError(`Cannot find configuration file '${configFile}'`); 58 | const normalizedConfiguraitonFilename = normalize(defaultConfigurationFilename); 59 | if (!existsSync(normalizedConfiguraitonFilename)) { 60 | writeFileSync(normalizedConfiguraitonFilename, defaultConfiguration); 61 | logger.logInfo(`Generated configuration file as '${defaultConfigurationFilename}', please fill in required information and retry.`); 62 | } 63 | process.exit(1); 64 | } 65 | 66 | const configuration = jsoncParse(readFileSync(configFile, defaultEncoding)) as IConfigurationFile; 67 | 68 | // replace token if overriden from command line 69 | configuration.sourceAccountToken = commandLineOptions.sourceToken ? commandLineOptions.sourceToken : configuration.sourceAccountToken; 70 | configuration.targetAccountToken = commandLineOptions.targetToken ? commandLineOptions.targetToken : configuration.targetAccountToken; 71 | 72 | if (!Utility.validateConfiguration(configuration, commandLineOptions.mode)) { 73 | process.exit(1); 74 | } 75 | 76 | return configuration; 77 | } 78 | 79 | -------------------------------------------------------------------------------- /src/nodejs/FileLogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel, ILogger } from "../common/Interfaces"; 2 | import { SetLogger } from "../common/Logger"; 3 | import { appendFileSync, existsSync, unlinkSync, mkdirSync } from "fs"; 4 | import { dirname } from "path"; 5 | import { sync as mkdirpSync } from "mkdirp"; 6 | 7 | export class FileLogger implements ILogger { 8 | constructor(private _logFilename: string, private _maxLogLevel: LogLevel) { 9 | if (existsSync(_logFilename)) { 10 | unlinkSync(_logFilename); 11 | } 12 | } 13 | 14 | public logVerbose(message: string) { 15 | this._log(message, LogLevel.verbose); 16 | } 17 | 18 | public logInfo(message: string) { 19 | this._log(message, LogLevel.information); 20 | } 21 | 22 | public logWarning(message: string) { 23 | this._log(message, LogLevel.warning); 24 | } 25 | 26 | public logError(message: string) { 27 | this._log(message, LogLevel.error); 28 | } 29 | 30 | public logException(error: Error) { 31 | if (error instanceof Error) { 32 | this._log(`Exception message:${error.message}\r\nCall stack:${error.stack}`, LogLevel.verbose); 33 | } 34 | else { 35 | this._log(`Unknown exception: ${JSON.stringify(error)}`, LogLevel.verbose); 36 | } 37 | } 38 | 39 | private _log(message: string, logLevel: LogLevel) { 40 | const outputMessage: string = `[${LogLevel[logLevel].toUpperCase()}] [${(new Date(Date.now())).toISOString()}] ${message}`; 41 | if (logLevel <= this._maxLogLevel) { 42 | console.log(outputMessage); 43 | } 44 | 45 | appendFileSync(this._logFilename, `${outputMessage}\r\n`); 46 | } 47 | } 48 | 49 | export function InitializeFileLogger(logFilename: string, maxLogLevel: LogLevel) { 50 | const folder = dirname(logFilename); 51 | if (!existsSync(folder)) { 52 | mkdirpSync(folder); 53 | } 54 | SetLogger(new FileLogger(logFilename, maxLogLevel)); 55 | } -------------------------------------------------------------------------------- /src/nodejs/Main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { existsSync, readFileSync } from "fs"; 3 | import { resolve, normalize } from "path"; 4 | import { ProcesCommandLine, ProcessConfigurationFile } from "./ConfigurationProcessor"; 5 | import { defaultEncoding, defaultProcessFilename } from "../common/Constants"; 6 | import { ImportError, KnownError } from "../common/Errors"; 7 | import { IConfigurationOptions, IProcessPayload, LogLevel, Modes } from "../common/Interfaces"; 8 | import { logger } from "../common/Logger"; 9 | import { InitializeFileLogger } from "./FileLogger"; 10 | import { ProcessExporter } from "../common/ProcessExporter"; 11 | import { ProcessImporter } from "../common/ProcessImporter"; 12 | import { Engine } from "../common/Engine"; 13 | import { NodeJsUtility } from "./NodeJsUtilities"; 14 | 15 | async function main() { 16 | const startTime = Date.now(); 17 | 18 | // Parse command line 19 | const commandLineOptions = ProcesCommandLine(); 20 | 21 | // Read configuration file 22 | const configuration = await ProcessConfigurationFile(commandLineOptions) 23 | 24 | // Overwrite token if specified on command line 25 | if (commandLineOptions.sourceToken) { 26 | configuration.sourceAccountToken = commandLineOptions.sourceToken; 27 | } 28 | 29 | if (commandLineOptions.targetToken) { 30 | configuration.targetAccountToken = commandLineOptions.targetToken; 31 | } 32 | 33 | // Initialize logger 34 | const maxLogLevel = configuration.options.logLevel ? LogLevel[configuration.options.logLevel] : LogLevel.information; 35 | const logFile = NodeJsUtility.getLogFilePath(configuration.options); 36 | InitializeFileLogger(logFile, maxLogLevel); 37 | logger.logInfo(`Full log is sent to '${resolve(logFile)}' `) 38 | 39 | // Enable user cancellation 40 | NodeJsUtility.startCancellationListener(); 41 | 42 | const mode = commandLineOptions.mode; 43 | const userOptions = configuration.options as IConfigurationOptions; 44 | try { 45 | // Export 46 | let processPayload: IProcessPayload; 47 | if (mode === Modes.export || mode === Modes.migrate) { 48 | const sourceRestClients = await Engine.Task(() => NodeJsUtility.getRestClients(configuration.sourceAccountUrl, configuration.sourceAccountToken), `Get rest client on source account '${configuration.sourceAccountUrl}'`); 49 | const exporter: ProcessExporter = new ProcessExporter(sourceRestClients, configuration); 50 | processPayload = await exporter.exportProcess(); 51 | 52 | const exportFilename = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename); 53 | await Engine.Task(() => NodeJsUtility.writeJsonToFile(exportFilename, processPayload), "Write process payload to file") 54 | logger.logInfo(`Export process completed successfully to '${resolve(exportFilename)}'.`); 55 | } 56 | 57 | // Import 58 | if (mode === Modes.import || mode == Modes.migrate) { 59 | if (mode === Modes.import) { // Read payload from file instead 60 | const processFileName = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename); 61 | if (!existsSync(processFileName)) { 62 | throw new ImportError(`Process payload file '${processFileName}' does not exist.`) 63 | } 64 | logger.logVerbose(`Start read process payload from '${processFileName}'.`); 65 | processPayload = JSON.parse(readFileSync(processFileName, defaultEncoding)); 66 | logger.logVerbose(`Complete read process payload.`); 67 | } 68 | 69 | const targetRestClients = await Engine.Task(() => NodeJsUtility.getRestClients(configuration.targetAccountUrl, configuration.targetAccountToken), `Get rest client on target account '${configuration.targetAccountUrl}'`); 70 | const importer: ProcessImporter = new ProcessImporter(targetRestClients, configuration, commandLineOptions); 71 | await importer.importProcess(processPayload); 72 | } 73 | } 74 | catch (error) { 75 | if (error instanceof KnownError) { 76 | // Known errors, just log error message 77 | logger.logError(error.message); 78 | } 79 | else { 80 | logger.logException(error); 81 | logger.logError(`Encountered unkonwn error, check log file for details.`) 82 | } 83 | process.exit(1); 84 | } 85 | 86 | const endTime = Date.now(); 87 | logger.logInfo(`Total elapsed time: '${(endTime - startTime) / 1000}' seconds.`); 88 | process.exit(0); 89 | } 90 | 91 | main(); -------------------------------------------------------------------------------- /src/nodejs/NodeJsUtilities.ts: -------------------------------------------------------------------------------- 1 | import * as vsts from "azure-devops-node-api/WebApi"; 2 | import { existsSync, writeFileSync } from "fs"; 3 | import { dirname, normalize } from "path"; 4 | import { sync as mkdirpSync } from "mkdirp"; 5 | import * as readline from "readline"; 6 | import { defaultLogFileName } from "../common/Constants"; 7 | import { IConfigurationOptions, IRestClients } from "../common/Interfaces"; 8 | import { logger } from "../common/Logger"; 9 | import { Utility } from "../common/Utilities"; 10 | import { KnownError } from "../common/Errors"; 11 | 12 | export class NodeJsUtility extends Utility { 13 | 14 | public static async writeJsonToFile(exportFilename: string, payload: Object) { 15 | const folder = dirname(exportFilename); 16 | if (!existsSync(folder)) { 17 | mkdirpSync(folder); 18 | } 19 | await writeFileSync(exportFilename, JSON.stringify(payload, null, 2), { flag: "w" }); 20 | } 21 | 22 | public static startCancellationListener() { 23 | const stdin = process.stdin; 24 | if (typeof stdin.setRawMode !== "function") { 25 | logger.logInfo(`We are running inside a TTY does not support RAW mode, you must cancel operation with CTRL+C`); 26 | return; 27 | } 28 | stdin.setRawMode(true); 29 | readline.emitKeypressEvents(stdin); 30 | stdin.addListener("keypress", this._listener); 31 | logger.logVerbose("Keyboard listener added"); 32 | } 33 | 34 | public static getLogFilePath(options: IConfigurationOptions): string { 35 | return options.logFilename ? options.logFilename : normalize(defaultLogFileName); 36 | } 37 | 38 | public static async getRestClients(accountUrl: string, PAT: string): Promise { 39 | const authHandler = vsts.getPersonalAccessTokenHandler(PAT); 40 | const vstsWebApi = new vsts.WebApi(accountUrl, authHandler); 41 | try { 42 | return { 43 | "witApi": await vstsWebApi.getWorkItemTrackingApi(), 44 | "witProcessApi": await vstsWebApi.getWorkItemTrackingProcessApi(), 45 | "witProcessDefinitionApi": await vstsWebApi.getWorkItemTrackingProcessDefinitionApi(), 46 | } 47 | } 48 | catch (error) { 49 | throw new KnownError(`Failed to connect to account '${accountUrl}' using personal access token '' provided, check url and token.`); 50 | } 51 | } 52 | 53 | private static _listener = (str: string, key: readline.Key) => { 54 | if (key.name.toLocaleLowerCase() === "q") { 55 | logger.logVerbose("Setting isCancelled to true."); 56 | Utility.isCancelled = true; 57 | } 58 | }; 59 | } -------------------------------------------------------------------------------- /src/nodejs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "noImplicitThis": true, 5 | "lib": ["es6", "dom", "es2015.iterable"], 6 | "outDir": "../../build/nodejs", 7 | "preserveConstEnums": true, 8 | "removeComments": true, 9 | "sourceMap": true, 10 | "target": "es2017", // to not transpile async/await, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "skipLibCheck": true, // to allow requirejs live together with node 14 | }, 15 | "include": [ 16 | "**/*.ts", 17 | "../common/**/*.ts", 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } --------------------------------------------------------------------------------