From ed311526029dcee98857bae30e52551d154d049e Mon Sep 17 00:00:00 2001 From: Liang Zhu Date: Mon, 30 Apr 2018 14:55:36 -0700 Subject: [PATCH] Bug fixes, refactor for future support of web usage and clean up. --- .gitignore | 4 - .npmignore | 5 + .npmrc | 2 - .vscode/launch.json | 8 +- .vscode/tasks.json | 20 +- README.md | 66 +- configuration.json | 14 +- package-lock.json | 141 +++- package.json | 49 +- src/Browser/placeholder.ts | 12 + src/Browser/tsconfig.json | 24 + src/Common/Constants.ts | 26 + src/Common/Engine.ts | 15 + src/Common/Errors.ts | 35 + src/{ => Common}/Interfaces.ts | 65 +- src/Common/Logger.ts | 43 ++ src/Common/ProcessExporter.ts | 166 ++++ src/Common/ProcessImporter.ts | 780 +++++++++++++++++++ src/Common/Utilities.ts | 234 ++++++ src/Constants.ts | 15 - src/Errors.ts | 24 - src/ImportExportProcess.ts | 1068 -------------------------- src/NodeJs/ConfigurationProcessor.ts | 69 ++ src/NodeJs/FileLogger.ts | 55 ++ src/NodeJs/Main.ts | 83 ++ src/NodeJs/NodeJsUtilities.ts | 60 ++ src/NodeJs/tsconfig.json | 21 + tsconfig.json | 21 - 28 files changed, 1906 insertions(+), 1219 deletions(-) create mode 100644 .npmignore delete mode 100644 .npmrc create mode 100644 src/Browser/placeholder.ts create mode 100644 src/Browser/tsconfig.json create mode 100644 src/Common/Constants.ts create mode 100644 src/Common/Engine.ts create mode 100644 src/Common/Errors.ts rename src/{ => Common}/Interfaces.ts (61%) create mode 100644 src/Common/Logger.ts create mode 100644 src/Common/ProcessExporter.ts create mode 100644 src/Common/ProcessImporter.ts create mode 100644 src/Common/Utilities.ts delete mode 100644 src/Constants.ts delete mode 100644 src/Errors.ts delete mode 100644 src/ImportExportProcess.ts create mode 100644 src/NodeJs/ConfigurationProcessor.ts create mode 100644 src/NodeJs/FileLogger.ts create mode 100644 src/NodeJs/Main.ts create mode 100644 src/NodeJs/NodeJsUtilities.ts create mode 100644 src/NodeJs/tsconfig.json delete mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 56c81ed..7806321 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ node_modules/* output/* -processPayload.json -process/*.json -src/*.js -src/*.js.map build/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..b0f3756 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +src/ +output/ +.vscode/ +build/browser/ +build/tests/ \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index cead277..0000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -registry=https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/ -always-auth=true \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index dbcb9c5..17abee1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,9 +4,11 @@ { "type": "node", "request": "launch", - "name": "Launch process import ", - "program": "${workspaceFolder}/build/ImportExportProcess.js", - "sourceMaps": true + "name": "Launch process migrate", + "program": "${workspaceFolder}/build/nodejs/nodejs/main.js", + "sourceMaps": true, + "args": [ + ] } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 250affa..5058398 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,18 +1,18 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", "tasks": [ { "type": "typescript", - "tsconfig": "tsconfig.json", + "tsconfig": "src\\NodeJs\\tsconfig.json", "problemMatcher": [ "$tsc" - ], - "group": { - "kind": "build", - "isDefault": true - } - } + ] + }, + { + "type": "typescript", + "tsconfig": "src\\Browser\\tsconfig.json", + "problemMatcher": [ + "$tsc" + ] + }, ] } \ No newline at end of file diff --git a/README.md b/README.md index 5d50889..11f7ea9 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,56 @@ -# Introduction +# VSTS Process Migrator for Node.js -The Process Import/Export (PIE) feature provides users with a way to automate the [Process](https://docs.microsoft.com/en-us/vsts/work/customize/process/manage-process?view=vsts) replication across accounts through a Node.js command line interface. +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. -The tool gives user option to export a Process from an account, and save it locally, and/or to do an online re-import into another account. - - +NOTE: This only works with 'Inherited Process', for 'XML process' you may upload/download process as ZIP. + # Getting Started +## Run -**1. Prerequisite** +- Install npm if not yet - [link](https://www.npmjs.com/get-npm) +- Install this package through `npm install process-migrator -g` +- Create and fill required information in config file *configuration.json*. See [doc section](#documentation) for details -- Install [npm](https://www.npmjs.com/get-npm) -- From repository root, run `npm install` + Just run ```processMigrator``` will create the file if not exist. + + ##### ![](https://imgplaceholder.com/100x17/cccccc/fe2904?text=WARNING&font-size=15) CONFIGURATION FILE HAS PAT, RIGHT PROTECT IT ! +- Run `processMigrator [--mode= [--config=]` -**2. Build** +## Contribute -- In root directory of repository, run `npm run build` +- From the root of source, run `npm install` +- Build by `npm run build` +- Execute through `node build\nodejs\nodejs\main.js ` -**3. Run** +## Documentation +##### Command line parameters +- --mode: Optional, defaulted to 'migrate'. Mode of the execution, can be 'migrate' (export and then import), 'export' (export only) or 'import' (import only). +- --config: Optional, default to './configuration.json'. Specify the configuration file. +##### Configuration file strcuture +- 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. +``` json +{ + "sourceAccountUrl": "Required in 'export'/'migrate' mode, source account url.", + "sourceAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'export'/'migrate' mode, personal access token for source account.", + "targetAccountUrl": "Required in 'import'/'migrate' mode, target account url.", + "targetAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'import'/'migrate' mode, personal access token for target account.", + "sourceProcessName": "Required in 'export'/'migrate' mode, source process name.", + "targetProcessName": "Optional, set to override process name in 'import'/'migrate' mode.", + "options": { + "processFilename": "Required in 'import' mode, optional in 'export'/'migrate' mode to override default value './output/process.json'.", + "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'.", + "logFilename":"Optional, default as 'output/processMigrator.log' - Set to override default log file name.", + "overwritePicklist": "Optional, default is 'false'. Set true to overwrite picklist if exists on target. Import will fail if picklist exists but different from source.", + "continueOnRuleImportFailure": "Optional, default is 'false', set true to continue import on failure importing rules, warning will be provided.", + "skipImportFormContributions": "Optional, default is 'false', set true to skip import control contributions on work item form.", + } +} +``` -- Set up `configuration.json` - - Configure account url and credentials. Source account is required; target account credentials required only if doing online re-import. - - `"sourceProcessName"` name of the Process on the source account to export. - - `"targetProcessName"` optional new name to give to Process in the target account. - - `"writeToFile"` serialize exported Process to file (not mutually exclusive with onlineReImport) - - `"onlineReImport"` whether exported Process should be imported into specified target account. - - `"overwritePicklist"` property that specifies which to keep if there is a conflict (by refName) between the picklists on source and target. - -- Launch application `node ./build/ImportExportProcess.js` on root directory of repository. \ No newline at end of file +##### Notes +- If extensions used by source account are not available in target account, import MAY fail + 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. +- If identities used in field default value or rules are not available in target account, import WILL fail + 1) For rules you may use 'continueOnRuleImportFailure' option to proceed with rest of import when such error is occurred. + 2) For identity field default value, you may use 'continueOnFieldDefaultValueFailure' option to proceed with rest of import when such error is occurred. diff --git a/configuration.json b/configuration.json index 76fa6bf..5f5c6ad 100644 --- a/configuration.json +++ b/configuration.json @@ -3,12 +3,12 @@ "sourceAccountToken": "", "targetAccountUrl": "", "targetAccountToken": "", - "options" : { - "sourceProcessName": "", - "targetProcessName": "", - "writeToFile": true, - "onlineReImport": true, - "overwritePicklist": true, - "__cleanupTargetAccount": true + "sourceProcessName": "Process name for export, optional in import only mode, required in export/both mode", + "targetProcessName": "Set to override process name on import, remove from param name", + "options": { + "processFilename": "Set to override default export file name, remove from param name", + "logLevel":"Set to override default log level (Information), remove from param name", + "logFilename":"Set to override default log file name, remove from param name", + "overwritePicklist": false } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8f3b4bd..9c9b872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,107 @@ { - "name": "dev", - "version": "1.0.0", + "name": "process-import-export", + "version": "0.9.3", "lockfileVersion": 1, "requires": true, "dependencies": { - "@types/node": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-9.6.2.tgz", - "integrity": "sha512-UWkRY9X7RQHp5OhhRIIka58/gVVycL1zHZu0OTsT5LI86ABaMOSbUjAl+b0FeDhQcxclrkyft3kW5QWdMRs8wQ==", - "dev": true + "@types/jquery": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.2.tgz", + "integrity": "sha512-ByZwKSEqteAta4VrIalqGJZmMq9lWPD3H3f5Xs6RR8B7zQRDPGUtjoKBYNtKTz/7LgBEQMdlxVbbjQfUaEIItA==" }, - "node": { - "version": "10.0.0", - "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/node/-/node-10.0.0.tgz", - "integrity": "sha512-Ps0lFNyTDEzH8yqCZ3hBXARs+H28dn1cmJt2oxWgqIdMvt0+SByZuiA12mEV+akVjXxkSuuaBH8I92C0SrnFkQ==", + "@types/jqueryui": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.2.tgz", + "integrity": "sha512-OZ3HZFxpyaoCgFO4qBliDS5QzeN+/X9Mr76VUD4L1TTOW0OYtnJl3bG4AfPI8Of7i0xgUA79Oo4KgteMnjllOQ==", "requires": { - "node-bin-setup": "1.0.6" + "@types/jquery": "3.3.2" } }, - "node-bin-setup": { - "version": "1.0.6", - "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/node-bin-setup/-/node-bin-setup-1.0.6.tgz", - "integrity": "sha512-uPIxXNis1CRbv1DwqAxkgBk5NFV3s7cMN/Gf556jSw6jBvV7ca4F9lRL/8cALcZecRibeqU+5dFYqFFmzv5a0Q==" + "@types/knockout": { + "version": "3.4.54", + "resolved": "https://registry.npmjs.org/@types/knockout/-/knockout-3.4.54.tgz", + "integrity": "sha512-X09cnOg6i0gDkaR54BZr3rbToDUousmG9Q7fpo0GKvaMkTbXACErVcc1JHWQIjdKCQGPB+E+qv1kHfD+xanWFQ==" + }, + "@types/minimist": { + "version": "1.2.0", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/@types/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=", + "dev": true + }, + "@types/mkdirp": { + "version": "0.5.2", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/@types/mkdirp/-/mkdirp-0.5.2.tgz", + "integrity": "sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg==", + "dev": true, + "requires": { + "@types/node": "8.10.15" + } + }, + "@types/mousetrap": { + "version": "1.5.34", + "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.5.34.tgz", + "integrity": "sha512-a2yhRIADupQfOFM75v7GfcQQLUxU705+i/xcZ3N/3PK3Xdo31SUfuCUByWPGOHB1e38m7MxTx/D8FPVsJXZKJw==" + }, + "@types/node": { + "version": "8.10.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.15.tgz", + "integrity": "sha512-qNb+m5Cuj6YUMK7YFcvuSgcHCKfVg1uXAUOP91SWvAakZlZTzbGmJaBi99CgDWEAyfZo51NlUhXkuP5WtXsgjg==", + "dev": true + }, + "@types/q": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", + "integrity": "sha1-vShOV8hPEyXacCur/IKlMoGQwMU=" + }, + "@types/react": { + "version": "15.6.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-15.6.15.tgz", + "integrity": "sha512-LOHbyeKRNYLEotniN3DlRGrpXorXupvFSbKrNzc9dZ87uL+IJDbGYVerxKaG1jbnhuc7RhEWxlNmUVtYm3mtNg==" + }, + "@types/requirejs": { + "version": "2.1.31", + "resolved": "https://registry.npmjs.org/@types/requirejs/-/requirejs-2.1.31.tgz", + "integrity": "sha512-b2soeyuU76rMbcRJ4e0hEl0tbMhFwZeTC0VZnfuWlfGlk6BwWNsev6kFu/twKABPX29wkX84wU2o+cEJoXsiTw==" + }, + "guid-typescript": { + "version": "1.0.7", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/guid-typescript/-/guid-typescript-1.0.7.tgz", + "integrity": "sha512-j1XPiaDUuNa0PO8EyGAikyKMpAnGbSDzr81UAzXz3H2xAoQW3c2hH8RS7Omo+DEgtD9emYdgJaxnfKZPYtDNRQ==" + }, + "jsonc-parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.0.0.tgz", + "integrity": "sha512-gYk8VcFDwky0AjrKeJSWgCm/lYGteP9hszGWtgg67Elz4owvhJF9qATjuIRAk5jgBMGM65MPAc+I4RTeoqoykA==" + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" }, "tunnel": { "version": "0.0.4", @@ -48,15 +128,38 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, "vso-node-api": { - "version": "99.99.99", - "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/vso-node-api/-/vso-node-api-99.99.99.tgz", - "integrity": "sha1-6lOYjiSfQZ00K1MdlxR1X8w9LmI=", + "version": "6.5.0", + "resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/vso-node-api/-/vso-node-api-6.5.0.tgz", + "integrity": "sha512-hFjPLMJkq02zF8U+LhZ4airH0ivaiKzGdlNAQlYFB3lWuGH/UANUrl63DVPUQOyGw+7ZNQ+ufM44T6mWN92xyg==", "requires": { "tunnel": "0.0.4", "typed-rest-client": "0.12.0", "underscore": "1.8.3" } + }, + "vss-web-extension-sdk": { + "version": "5.131.0", + "resolved": "https://registry.npmjs.org/vss-web-extension-sdk/-/vss-web-extension-sdk-5.131.0.tgz", + "integrity": "sha512-iWJ3O4tzpRiPojYMmjqCh/IH3GptFTs1+Ahgbb/yXrueIggU85P0XpumtphIhUWsPan7mDlAYJHOMTHBgojc0Q==", + "requires": { + "@types/jquery": "3.3.2", + "@types/jqueryui": "1.12.2", + "@types/knockout": "3.4.54", + "@types/mousetrap": "1.5.34", + "@types/q": "0.0.32", + "@types/react": "15.6.15", + "@types/requirejs": "2.1.31" + } } } } diff --git a/package.json b/package.json index 6cc7470..88eef7d 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,47 @@ { - "name": "dev", - "version": "1.0.0", - "description": "Proces import export node.js application", + "name": "process-migrator", + "version": "0.9.4", + "description": "Proces import/export Node.js application", "main": "", - "scripts": { - "build": "tsc -p tsconfig.json" + "bin": { + "process-migrator": "build/nodejs/nodejs/main.js" }, - "keywords": [], - "author": "", - "license": "", + "engines": { + "node": ">=8.11.2" + }, + "scripts": { + "build": "tsc -p src/nodejs/tsconfig.json && tsc -p src/browser/tsconfig.json", + "bn": "tsc -p src/nodejs/tsconfig.json", + "bb": "tsc -p src/browser/tsconfig.json" + }, + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/process-migrator" + }, + "keywords": [ + "vsts", + "process", + "import", + "export", + "migrate", + "migrator", + "inherited" + ], + "author": "Microsoft", + "license": "MIT", "devDependencies": { - "@types/node": "^9.6.2", + "@types/minimist": "^1.2.0", + "@types/mkdirp": "^0.5.2", + "@types/node": "^8.10.15", "typescript": "^2.8.1" }, "dependencies": { - "node": "^10.0.0", - "vso-node-api": "^99.99.99" + "guid-typescript": "^1.0.7", + "jsonc-parser": "^2.0.0", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "url": "^0.11.0", + "vso-node-api": "^6.5.0", + "vss-web-extension-sdk": "^5.131.0" } } diff --git a/src/Browser/placeholder.ts b/src/Browser/placeholder.ts new file mode 100644 index 0000000..2544a51 --- /dev/null +++ b/src/Browser/placeholder.ts @@ -0,0 +1,12 @@ +/// +import * as WITInterfaces from "vso-node-api/interfaces/WorkItemTrackingInterfaces"; +import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; +import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; +import { WorkItemTrackingApi } from "vso-node-api/WorkItemTrackingApi"; +import { getCollectionClient } from "VSS/Service"; +import { WorkItemTrackingHttpClient } from "TFS/WorkItemTracking/RestClient"; + +// Placeholder file for proof of concept +const witClient = getCollectionClient(WorkItemTrackingHttpClient); +const booleanType = WITInterfaces.FieldType.Boolean; +const customPageType = WITProcessDefinitionsInterfaces.PageType.Custom; \ No newline at end of file diff --git a/src/Browser/tsconfig.json b/src/Browser/tsconfig.json new file mode 100644 index 0000000..aa74f1b --- /dev/null +++ b/src/Browser/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "noImplicitAny": false, + "noImplicitThis": true, + "outDir": "../../build/browser", + "preserveConstEnums": true, + "removeComments": true, + "sourceMap": true, + "target": "es2017", // to not transpile async/await + "module": "amd", + "moduleResolution": "node", + "skipLibCheck": true, // to allow requirejs live together with node + "types": [ + "vss-web-extension-sdk" + ] + }, + "include": [ + "**/*.ts", + "../common/**/*.ts", + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts new file mode 100644 index 0000000..d1cbba0 --- /dev/null +++ b/src/Common/Constants.ts @@ -0,0 +1,26 @@ +export const PICKLIST_NO_ACTION = "PICKLIST_NO_ACTION"; +export const defaultEncoding = "utf-8"; +export const defaultConfigurationFilename = "configuration.json"; +export const defaultLogFileName = "output\\processMigrator.log"; +export const defaultProcessFilename = "output\\process.json"; +export const paramMode = "mode"; +export const paramConfig = "config"; +export const paramOverwriteProcessOnTarget = "overwriteProcessOnTarget"; +export const defaultConfiguration = + `{ + "sourceAccountUrl": "Required in 'export'/'migrate' mode, source account url.", + "sourceAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'export'/'migrate' mode, personal access token for source account.", + "targetAccountUrl": "Required in 'import'/'migrate' mode, target account url.", + "targetAccountToken": "!!TREAT THIS AS PASSWORD!! Required in 'import'/'migrate' mode, personal access token for target account.", + "sourceProcessName": "Required in 'export'/'migrate' mode, source process name.", + // "targetProcessName": "Optional, set to override process name in 'import'/'migrate' mode.", + "options": { + // "processFilename": "Required in 'import' mode, optional in 'export'/'migrate' mode to override default value './output/process.json'.", + // "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'.", + // "logFilename":"Optional, default as 'output/processMigrator.log' - Set to override default log file name.", + // "overwritePicklist": "Optional, default is 'false'. Set true to overwrite picklist if exists on target. Import will fail if picklist exists but different from source.", + // "continueOnRuleImportFailure": "Optional, default is 'false', set true to continue import on failure importing rules, warning will be provided.", + // "skipImportFormContributions": "Optional, default is 'false', set true to skip import control/group/form contributions on work item form.", + } + }`; +export const regexRemoveHypen = new RegExp("-", "g"); \ No newline at end of file diff --git a/src/Common/Engine.ts b/src/Common/Engine.ts new file mode 100644 index 0000000..655b51f --- /dev/null +++ b/src/Common/Engine.ts @@ -0,0 +1,15 @@ +import { CancellationError } from "./Errors"; +import { logger } from "./Logger"; +import { Utility } from "./Utilities"; + +export class Engine { + public static async Task(step: () => Promise, stepName?: string): Promise { + if (Utility.didUserCancel()) { + throw new CancellationError(); + } + logger.logVerbose(`Begin step '${stepName}'.`); + const ret: T = await step(); + logger.logVerbose(`Finished step '${stepName}'.`); + return ret; + } +} diff --git a/src/Common/Errors.ts b/src/Common/Errors.ts new file mode 100644 index 0000000..aa87b9f --- /dev/null +++ b/src/Common/Errors.ts @@ -0,0 +1,35 @@ +// NOTE: We need this intermediate class to use 'instanceof' +export class KnownError extends Error { + __proto__: Error; + constructor(message?: string) { + const trueProto = new.target.prototype; + super(message); + + // Alternatively use Object.setPrototypeOf if you have an ES6 environment. + this.__proto__ = trueProto; + } +} + +export class CancellationError extends KnownError { + constructor() { + super("Process import/export cancelled by user input."); + } +} + +export class ValidationError extends KnownError { + constructor(message: string) { + super(`Process import validation failed. ${message}`); + } +} + +export class ImportError extends KnownError { + constructor(message: string) { + super(`Import failed, see log file for details. ${message}`); + } +} + +export class ExportError extends KnownError { + constructor(message: string) { + super(`Export failed, see log file for details. ${message}`); + } +} \ No newline at end of file diff --git a/src/Interfaces.ts b/src/Common/Interfaces.ts similarity index 61% rename from src/Interfaces.ts rename to src/Common/Interfaces.ts index f8f0a7c..15339b6 100644 --- a/src/Interfaces.ts +++ b/src/Common/Interfaces.ts @@ -1,30 +1,51 @@ import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; import * as WITInterfaces from "vso-node-api/interfaces/WorkItemTrackingInterfaces"; +import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi } from "vso-node-api/WorkItemTrackingProcessDefinitionsApi"; +import { IWorkItemTrackingProcessApi as WITProcessApi } from "vso-node-api/WorkItemTrackingProcessApi"; +import { IWorkItemTrackingApi as WITApi } from "vso-node-api/WorkItemTrackingApi"; export enum LogLevel { - Error, - Warning, - Information, - Verbose + error, + warning, + information, + verbose +} + +export enum Modes { + import, + export, + migrate } export interface IExportOptions { processID: string; - writeToFile: boolean; } -export interface IUserConfigurationOptions { - sourceProcessName: string; +export interface ICommandLineOptions { + mode: Modes; + overwriteProcessOnTarget: boolean; + config: string; +} + +export interface IConfigurationFile { + sourceProcessName?: string; targetProcessName?: string; - outputPath?: string; - logfileName?: string; - writeToFile?: boolean; - onlineReImport?: boolean; + sourceAccountUrl?: string; + targetAccountUrl?: string; + sourceAccountToken?: string; + targetAccountToken?: string; + options?: IConfigurationOptions; +} + +export interface IConfigurationOptions { + logLevel?: string; + logFilename?: string; + processFilename?: string; overwritePicklist?: boolean; - logLevel?: LogLevel; - __cleanupTargetAccount?: boolean; // TODO: For dev purpose - __cleanupTargetEverything?: boolean; // TODO: For dev purpose + continueOnRuleImportFailure?: boolean; + continueOnIdentityDefaultValueFailure?: boolean; + skipImportFormContributions?: boolean; } export interface IProcessPayload { @@ -34,7 +55,7 @@ export interface IProcessPayload { workItemTypeFields: IWITypeFields[]; witFieldPicklists: IWITFieldPicklist[]; layouts: IWITLayout[]; - behaviors: WITProcessDefinitionsInterfaces.BehaviorModel[]; + behaviors: WITProcessInterfaces.WorkItemBehavior[]; workItemTypeBehaviors: IWITBehaviors[]; states: IWITStates[]; rules: IWITRules[]; @@ -94,4 +115,18 @@ export interface IWITFieldPicklist { export interface IDictionaryStringTo { [key: string]: T; +} + +export interface ILogger { + logVerbose(message: string); + logInfo(message: string); + logWarning(message: string); + logError(message: string); + logException(error: Error); +} + +export interface IRestClients { + witApi: WITApi; + witProcessApi: WITProcessApi; + witProcessDefinitionApi: WITProcessDefinitionApi; } \ No newline at end of file diff --git a/src/Common/Logger.ts b/src/Common/Logger.ts new file mode 100644 index 0000000..d1d83a9 --- /dev/null +++ b/src/Common/Logger.ts @@ -0,0 +1,43 @@ +import { LogLevel, ILogger } from "./Interfaces"; + +class ConsoleLogger implements ILogger { + public logVerbose(message: string) { + this._log(message, LogLevel.verbose); + } + + public logInfo(message: string) { + this._log(message, LogLevel.information); + } + + public logWarning(message: string) { + this._log(message, LogLevel.warning); + } + + public logError(message: string) { + this._log(message, LogLevel.error); + } + + public logException(error: Error) { + if (error instanceof Error) { + this._log(`Exception message:${error.message}\r\nCall stack:${error.stack}`, LogLevel.verbose); + } + else { + this._log(`Unknown exception: ${JSON.stringify(error)}`, LogLevel.verbose); + } + } + + private _log(message: string, logLevel: LogLevel) { + const outputMessage: string = `[${LogLevel[logLevel].toUpperCase()}] [${(new Date(Date.now())).toISOString()}] ${message}`; + console.log(outputMessage); + } +} + +export var logger: ILogger = new ConsoleLogger(); + +/** + * DO NOT CALL - This is exposed for other logger implementation + * @param newLogger + */ +export function SetLogger(newLogger: ILogger) { + logger = newLogger; +} \ No newline at end of file diff --git a/src/Common/ProcessExporter.ts b/src/Common/ProcessExporter.ts new file mode 100644 index 0000000..f45917c --- /dev/null +++ b/src/Common/ProcessExporter.ts @@ -0,0 +1,166 @@ +import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; +import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; + +import * as vsts_NOREQUIRE from "vso-node-api/WebApi"; +import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingProcessDefinitionsApi"; +import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingProcessApi"; +import { IWorkItemTrackingApi as WITApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingApi"; + +import { IConfigurationFile, IDictionaryStringTo, IProcessPayload, IWITBehaviors, IWITBehaviorsInfo, IWITFieldPicklist, IWITLayout, IWITRules, IWITStates, IWITypeFields, IRestClients } from "./Interfaces"; +import { ExportError } from "./Errors"; +import { logger } from "./Logger"; +import { Engine } from "./Engine"; +import { Utility } from "./Utilities"; + +export class ProcessExporter { + private _vstsWebApi: vsts_NOREQUIRE.WebApi; + private _witProcessApi: WITProcessApi_NOREQUIRE; + private _witProcessDefinitionApi: WITProcessDefinitionApi_NOREQUIRE; + private _witApi: WITApi_NOREQUIRE; + + constructor(restClients: IRestClients, private _config: IConfigurationFile) { + this._witApi = restClients.witApi; + this._witProcessApi = restClients.witProcessApi; + this._witProcessDefinitionApi = restClients.witProcessDefinitionApi; + } + + private async _getSourceProcessId(): Promise { + const processes = await Utility.tryCatchWithKnownError(() => this._witProcessApi.getProcesses(), + () => new ExportError(`Error getting processes on source account '${this._config.sourceAccountUrl}, check account url, token and token permissions.`)); + + if (!processes) { // most likely 404 + throw new ExportError(`Failed to get processes on source account '${this._config.sourceAccountUrl}', check account url.`); + } + + const lowerCaseSourceProcessName = this._config.sourceProcessName.toLocaleLowerCase(); + const matchProcesses = processes.filter(p => p.name.toLocaleLowerCase() === lowerCaseSourceProcessName); + if (matchProcesses.length === 0) { + throw new ExportError(`Process '${this._config.sourceProcessName}' is not found on source account.`); + } + + const process = matchProcesses[0]; + if (process.properties.class !== WITProcessInterfaces.ProcessClass.Derived) { + throw new ExportError(`Proces '${this._config.sourceProcessName}' is not a derived process, not supported.`); + } + return process.typeId; + } + + private async _getComponents(processId: string): Promise { + let _process: WITProcessInterfaces.ProcessModel; + let _behaviorsCollectionScope: WITProcessInterfaces.WorkItemBehavior[]; + let _fieldsCollectionScope: WITProcessInterfaces.FieldModel[]; + const _fieldsWorkitemtypeScope: IWITypeFields[] = []; + const _layouts: IWITLayout[] = []; + const _states: IWITStates[] = []; + const _rules: IWITRules[] = []; + const _behaviorsWITypeScope: IWITBehaviors[] = []; + const _picklists: IWITFieldPicklist[] = []; + const knownPicklists: IDictionaryStringTo = {}; + const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = []; + const processPromises: Promise[] = []; + + processPromises.push(this._witProcessApi.getProcessById(processId).then(process => _process = process)); + processPromises.push(this._witProcessApi.getFields(processId).then(fields => _fieldsCollectionScope = fields)); + processPromises.push(this._witProcessApi.getBehaviors(processId).then(behaviors => _behaviorsCollectionScope = behaviors)); + processPromises.push(this._witProcessApi.getWorkItemTypes(processId).then(workitemtypes => { + const perWitPromises: Promise[] = []; + + for (const workitemtype of workitemtypes) { + const currentWitPromises: Promise[] = []; + + currentWitPromises.push(this._witProcessDefinitionApi.getBehaviorsForWorkItemType(processId, workitemtype.id).then(behaviors => { + const witBehaviorsInfo: IWITBehaviorsInfo = { refName: workitemtype.id, workItemTypeClass: workitemtype.class }; + const witBehaviors: IWITBehaviors = { + workItemType: witBehaviorsInfo, + behaviors: behaviors + } + _behaviorsWITypeScope.push(witBehaviors); + })); + + if (workitemtype.class !== WITProcessInterfaces.WorkItemTypeClass.System) { + _nonSystemWorkItemTypes.push(workitemtype); + + currentWitPromises.push(this._witProcessDefinitionApi.getWorkItemTypeFields(processId, workitemtype.id).then(fields => { + const witFields: IWITypeFields = { + workItemTypeRefName: workitemtype.id, + fields: fields + }; + _fieldsWorkitemtypeScope.push(witFields); + + const picklistPromises: Promise[] = []; + for (const field of fields) { + 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) + knownPicklists[field.pickList.id] = true; + picklistPromises.push(this._witProcessDefinitionApi.getList(field.pickList.id).then(picklist => _picklists.push( + { + workitemtypeRefName: workitemtype.id, + fieldRefName: field.referenceName, + picklist: picklist + }))); + } + } + return Promise.all(picklistPromises) + })); + + let layoutForm: WITProcessDefinitionsInterfaces.FormLayout; + currentWitPromises.push(this._witProcessDefinitionApi.getFormLayout(processId, workitemtype.id).then(layout => { + const witLayout: IWITLayout = { + workItemTypeRefName: workitemtype.id, + layout: layout + } + _layouts.push(witLayout); + })); + + currentWitPromises.push(this._witProcessDefinitionApi.getStateDefinitions(processId, workitemtype.id).then(states => { + const witStates: IWITStates = { + workItemTypeRefName: workitemtype.id, + states: states + } + _states.push(witStates); + })); + + currentWitPromises.push(this._witProcessApi.getWorkItemTypeRules(processId, workitemtype.id).then(rules => { + const witRules: IWITRules = { + workItemTypeRefName: workitemtype.id, + rules: rules + } + _rules.push(witRules); + })); + } + perWitPromises.push(Promise.all(currentWitPromises)); + } + + return Promise.all(perWitPromises); + })); + + //NOTE: it maybe out of order for per-workitemtype artifacts for different work item types + // for example, you may have Bug and then Feature for 'States' but Feature comes before Bug for 'Rules' + // the order does not matter since we stamp the work item type information + await Promise.all(processPromises); + + const processPayload: IProcessPayload = { + process: _process, + fields: _fieldsCollectionScope, + workItemTypeFields: _fieldsWorkitemtypeScope, + workItemTypes: _nonSystemWorkItemTypes, + layouts: _layouts, + states: _states, + rules: _rules, + behaviors: _behaviorsCollectionScope, + workItemTypeBehaviors: _behaviorsWITypeScope, + witFieldPicklists: _picklists + }; + + return processPayload; + } + + public async exportProcess(): Promise { + logger.logInfo("Export process started."); + + const processId = await Engine.Task(() => this._getSourceProcessId(), "Get source process Id from name"); + const payload = await Engine.Task(() => this._getComponents(processId), "Get artifacts from source process"); + + logger.logInfo("Export process completed."); + return payload; + } +} \ No newline at end of file diff --git a/src/Common/ProcessImporter.ts b/src/Common/ProcessImporter.ts new file mode 100644 index 0000000..cbeffdf --- /dev/null +++ b/src/Common/ProcessImporter.ts @@ -0,0 +1,780 @@ +import * as assert from "assert"; +import { Guid } from "guid-typescript"; + +import * as WITInterfaces from "vso-node-api/interfaces/WorkItemTrackingInterfaces"; +import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; +import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; +import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingProcessDefinitionsApi"; +import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingProcessApi"; +import { IWorkItemTrackingApi as WITApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingApi"; + +import { PICKLIST_NO_ACTION } from "./Constants"; +import { Engine } from "./Engine"; +import { ImportError, ValidationError } from "./Errors"; +import { ICommandLineOptions, IConfigurationFile, IDictionaryStringTo, IProcessPayload, IWITLayout, IWITRules, IWITStates, IRestClients } from "./Interfaces"; +import { logger } from "./Logger"; +import { Utility } from "./Utilities"; + +export class ProcessImporter { + private _witProcessApi: WITProcessApi_NOREQUIRE; + private _witProcessDefinitionApi: WITProcessDefinitionApi_NOREQUIRE; + private _witApi: WITApi_NOREQUIRE; + + constructor(restClients: IRestClients, private _config?: IConfigurationFile, private _commandLineOptions?: ICommandLineOptions) { + this._witApi = restClients.witApi; + this._witProcessApi = restClients.witProcessApi; + this._witProcessDefinitionApi = restClients.witProcessDefinitionApi; + } + + private async _importWorkItemTypes(payload: IProcessPayload): Promise { + for (const wit of payload.workItemTypes) { + if (wit.class === WITProcessInterfaces.WorkItemTypeClass.System) { + //The exported payload should not have exported System WITypes, so fail on import. + throw new ImportError(`Work item type '${wit.name}' is a system work item type with no modifications, cannot import.`); + } + else { + const createdWorkItemType = await Utility.tryCatchWithKnownError(() => this._witProcessDefinitionApi.createWorkItemType(wit, payload.process.typeId), + () => new ImportError(`Failed to create work item type '${wit.id}, see logs for details.`)); + if (!createdWorkItemType || createdWorkItemType.id !== wit.id) { + throw new ImportError(`Failed to create work item type '${wit.id}', server returned empty or reference name does not match.`); + } + } + } + } + + /** + * This process payload from export and return fields that need create also fix Identity field type and picklist id + */ + private async _getFieldsToCreate(payload: IProcessPayload): Promise { + assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); + + let fieldsOnTarget: WITInterfaces.WorkItemField[]; + try { + fieldsOnTarget = await this._witApi.getFields(); + if (!fieldsOnTarget || fieldsOnTarget.length <= 0) { // most likely 404 + throw new ImportError("Failed to get fields from target account, server returned empty result"); + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError("Failed to get fields from target account, see logs for details.") + } + + // Build a lookup to know if a field is picklist field. + const isPicklistField: IDictionaryStringTo = {}; + for (const e of payload.witFieldPicklists) { + isPicklistField[e.fieldRefName] = true; + } + + const outputFields: WITProcessDefinitionsInterfaces.FieldModel[] = []; + for (const sourceField of payload.fields) { + const fieldExist = fieldsOnTarget.some(targetField => targetField.referenceName === sourceField.id); + if (!fieldExist) { + const createField: WITProcessDefinitionsInterfaces.FieldModel = Utility.WITProcessToWITProcessDefinitionsFieldModel(sourceField); + if (sourceField.isIdentity) { + createField.type = WITProcessDefinitionsInterfaces.FieldType.Identity; + } + if (isPicklistField[sourceField.id]) { + const picklistId = payload.targetAccountInformation.fieldRefNameToPicklistId[sourceField.id]; + assert(picklistId !== PICKLIST_NO_ACTION, "[Unexpected] We are creating the field which we found the matching field earlier on collection") + createField.pickList = { + id: picklistId, + isSuggested: null, + name: null, + type: null, + url: null + }; + } + outputFields.push(createField); + } + } + return outputFields; + } + + /**Create fields at a collection scope*/ + private async _importFields(payload: IProcessPayload): Promise { + const fieldsToCreate: WITProcessDefinitionsInterfaces.FieldModel[] = await Engine.Task(() => this._getFieldsToCreate(payload), "Get fields to be created on target process"); + + if (fieldsToCreate.length > 0) { + for (const field of fieldsToCreate) { + try { + const fieldCreated = await Engine.Task(() => this._witProcessDefinitionApi.createField(field, payload.process.typeId), `Create field '${field.id}'`); + if (!fieldCreated) { + throw new ImportError(`Create field '${field.name}' failed, server returned empty object`); + } + if (fieldCreated.id !== field.id) { + throw new ImportError(`Create field '${field.name}' actually returned referenace name '${fieldCreated.id}' instead of anticipated '${field.id}', are you on latest VSTS?`); + } + + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Create field '${field.name}' failed, see log for details.`); + } + }; + } + } + + /**Add fields at a Work Item Type scope*/ + private async _addFieldsToWorkItemTypes(payload: IProcessPayload): Promise { + for (const entry of payload.workItemTypeFields) { + for (const field of entry.fields) { + try { + // Make separate call to set default value on identity field allow failover + const defaultValue = field.defaultValue; + field.defaultValue = field.type === WITProcessDefinitionsInterfaces.FieldType.Identity ? null : defaultValue; + + const fieldAdded = await Engine.Task( + () => this._witProcessDefinitionApi.addFieldToWorkItemType(field, payload.process.typeId, entry.workItemTypeRefName), + `Add field '${field.referenceName}' to work item type '${entry.workItemTypeRefName}'`); + + if (!fieldAdded || fieldAdded.referenceName !== field.referenceName) { + 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.`); + } + + if (defaultValue) { + field.defaultValue = defaultValue; + try { + const fieldAddedWithDefaultValue = await Engine.Task( + () => this._witProcessDefinitionApi.addFieldToWorkItemType(field, payload.process.typeId, entry.workItemTypeRefName), + `Updated field '${field.referenceName}' with default value to work item type '${entry.workItemTypeRefName}'`); + } + catch (error) { + if (this._config.options && this._config.options.continueOnIdentityDefaultValueFailure === true) { + 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`); + } + else { + logger.logException(error); + 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.`); + } + } + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Failed to add field '${field.referenceName}' to work item type '${entry.workItemTypeRefName}', see logs for details.`); + } + } + } + } + + private async _createGroup(createGroup: WITProcessDefinitionsInterfaces.Group, + page: WITProcessDefinitionsInterfaces.Page, + section: WITProcessDefinitionsInterfaces.Section, + witLayout: IWITLayout, + payload: IProcessPayload + ) { + let newGroup: WITProcessDefinitionsInterfaces.Group; + try { + newGroup = await Engine.Task( + () => this._witProcessDefinitionApi.addGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id), + `Create group '${createGroup.id}' in page '${page.id}'`); + } + catch (error) { + logger.logException(error); + throw new ImportError(`Failed to create group '${createGroup.id}' in page '${page.id}', see logs for details.`) + } + + if (!newGroup || !newGroup.id) { + throw new ImportError(`Failed to create group '${createGroup.id}' in page '${page.id}', server returned empty result or non-matching id.`) + } + + return newGroup; + } + + private async _editGroup(createGroup: WITProcessDefinitionsInterfaces.Group, + page: WITProcessDefinitionsInterfaces.Page, + section: WITProcessDefinitionsInterfaces.Section, + group: WITProcessDefinitionsInterfaces.Group, + witLayout: IWITLayout, + payload: IProcessPayload + ) { + let newGroup: WITProcessDefinitionsInterfaces.Group; + try { + newGroup = await Engine.Task( + () => this._witProcessDefinitionApi.editGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id, group.id), + `edit group '${group.id}' in page '${page.id}'`); + } + catch (error) { + logger.logException(error); + throw new ImportError(`Failed to edit group '${group.id}' in page '${page.id}', see logs for details.`) + } + + if (!newGroup || newGroup.id !== group.id) { + throw new ImportError(`Failed to create group '${group.id}' in page '${page.id}', server returned empty result or id.`) + } + return newGroup; + } + + private async _importPage(targetLayout: WITProcessDefinitionsInterfaces.FormLayout, witLayout: IWITLayout, page: WITProcessDefinitionsInterfaces.Page, payload: IProcessPayload) { + if (!page) { + throw new ImportError(`Encountered null page in work item type '${witLayout.workItemTypeRefName}'`); + } + + if (page.isContribution && this._config.options.skipImportFormContributions === true) { + // skip import page contriubtion unless user explicitly asks so + return; + } + + let newPage: WITProcessDefinitionsInterfaces.Page; //The newly created page, contains the pageId required to create groups. + const createPage: WITProcessDefinitionsInterfaces.Page = Utility.toCreatePage(page); + const sourcePagesOnTarget: WITProcessDefinitionsInterfaces.Page[] = targetLayout.pages.filter(p => p.id === page.id); + try { + newPage = sourcePagesOnTarget.length === 0 + ? await Engine.Task(() => this._witProcessDefinitionApi.addPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName), + `Create '${page.id}' page in ${witLayout.workItemTypeRefName}`) + : await Engine.Task(() => this._witProcessDefinitionApi.editPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName), + `Edit '${page.id}' page in ${witLayout.workItemTypeRefName}`); + } + catch (error) { + logger.logException(error); + throw new ImportError(`Failed to create or edit '${page.id}' page in ${witLayout.workItemTypeRefName}, see logs for details.`); + } + if (!newPage || !newPage.id) { + throw new ImportError(`Failed to create or edit '${page.id}' page in ${witLayout.workItemTypeRefName}, server returned empty result.`); + } + + page.id = newPage.id; + for (const section of page.sections) { + for (const group of section.groups) { + let newGroup: WITProcessDefinitionsInterfaces.Group; + + if (group.isContribution === true && this._config.options.skipImportFormContributions === true) { + // skip import group contriubtion unless user explicitly asks so + continue; + } + + if (group.controls.length !== 0 && group.controls[0].controlType === "HtmlFieldControl") { + //Handle groups with HTML Controls + const createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); + + if (group.inherited) { + if (group.overridden) { + newGroup = await this._editGroup(createGroup, page, section, group, witLayout, payload); + + const htmlControl = group.controls[0]; + if (htmlControl.overridden) { + // If the HTML control is overriden, we must update that as well + let updatedHtmlControl: WITProcessDefinitionsInterfaces.Control; + try { + updatedHtmlControl = await Engine.Task( + () => this._witProcessDefinitionApi.editControl(htmlControl, payload.process.typeId, witLayout.workItemTypeRefName, newGroup.id, htmlControl.id), + `Edit HTML control '${htmlControl.id} in group'${group.id}' in page '${page.id}'`); + } + catch (error) { + logger.logException(error); + throw new ImportError(`Failed to edit HTML control '${htmlControl.id} in group'${group.id}' in page '${page.id}', see logs for details.`) + } + + if (!updatedHtmlControl || updatedHtmlControl.id !== htmlControl.id) { + throw new ImportError(`Failed to edit group '${group.id}' in page '${page.id}', server returned empty result or non-matching id.`) + } + } + } + else { + // no-op since the group is not overriden + } + } + else { + // special handling for HTML control - we must create a group containing the HTML control at same time. + createGroup.controls = group.controls; + await this._createGroup(createGroup, page, section, witLayout, payload); + } + } + else { + //Groups with no HTML Controls + let createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); + + if (group.inherited) { + if (group.overridden) { + //edit + await this._editGroup(createGroup, page, section, group, witLayout, payload); + } + } + else { + //create + newGroup = await this._createGroup(createGroup, page, section, witLayout, payload); + group.id = newGroup.id; + } + + for (const control of group.controls) { + if (!control.inherited || control.overridden) { + try { + let createControl: WITProcessDefinitionsInterfaces.Control = Utility.toCreateControl(control); + + if (control.controlType === "WebpageControl" || (control.isContribution === true && this._config.options.skipImportFormContributions === true)) { + // Skip web page control for now since not supported in inherited process. + continue; + } + + if (control.inherited) { + if (control.overridden) { + //edit + await Engine.Task(() => this._witProcessDefinitionApi.editControl(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, control.id), + `Edit control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`); + } + } + else { + //create + await Engine.Task(() => this._witProcessDefinitionApi.addControlToGroup(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id), + `Create control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`); + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Unable to add '${control.id}' control to group '${group.id}' in page '${page.id}' in '${witLayout.workItemTypeRefName}'. ${error}`); + } + } + } + } + } + } + } + + private async _importLayouts(payload: IProcessPayload): Promise { + /** Notes: + * HTML controls need to be created at the same tme as the group they are in. + * Non HTML controls need to be added 1 by 1 after the group they are in has been created. + */ + for (const witLayoutEntry of payload.layouts) { + const targetLayout: WITProcessDefinitionsInterfaces.FormLayout = await Engine.Task( + () => this._witProcessDefinitionApi.getFormLayout(payload.process.typeId, witLayoutEntry.workItemTypeRefName), + `Get layout on target process for work item type '${witLayoutEntry.workItemTypeRefName}'`); + for (const page of witLayoutEntry.layout.pages) { + if (page.pageType === WITProcessDefinitionsInterfaces.PageType.Custom) { + await this._importPage(targetLayout, witLayoutEntry, page, payload); + } + } + } + } + + private async _importWITStates(witStateEntry: IWITStates, payload: IProcessPayload) { + let targetWITStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[]; + try { + targetWITStates = await Engine.Task( + () => this._witProcessApi.getStateDefinitions(payload.process.typeId, witStateEntry.workItemTypeRefName), + `Get states on target process for work item type '${witStateEntry.workItemTypeRefName}'`); + if (!targetWITStates || targetWITStates.length <= 0) { + throw new ImportError(`Failed to get states definitions from work item type '${witStateEntry.workItemTypeRefName}' on target account, server returned empty result.`) + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Failed to get states definitions from work item type '${witStateEntry.workItemTypeRefName}' on target account, see logs for details.`) + } + + for (const sourceState of witStateEntry.states) { + try { + const existingStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = targetWITStates.filter(targetState => sourceState.name === targetState.name); + if (existingStates.length === 0) { // does not exist on target + const createdState = await Engine.Task( + () => this._witProcessDefinitionApi.createStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName), + `Create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); + if (!createdState || !createdState.id) { + throw new ImportError(`Unable to create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result or id.`); + } + } + else { + if (sourceState.hidden) { // if state exists on target, only update if hidden + const hiddenState = await Engine.Task( + () => this._witProcessDefinitionApi.hideStateDefinition({ hidden: true }, payload.process.typeId, witStateEntry.workItemTypeRefName, existingStates[0].id), + `Hide state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); + if (!hiddenState || hiddenState.name !== sourceState.name || !hiddenState.hidden) { + 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.`); + } + } + + const existingState = existingStates[0]; + if (sourceState.color !== existingState.color || sourceState.stateCategory !== existingState.stateCategory || sourceState.name !== existingState.name) { + // Inherited state can be edited in custom work item types. + const updatedState = await Engine.Task( + () => this._witProcessDefinitionApi.updateStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName, existingState.id), + `Update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); + if (!updatedState || updatedState.name !== sourceState.name) { + 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.`); + } + } + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Unable to create/hide/update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, see logs for details`); + } + } + + for (const targetState of targetWITStates) { + const sourceStateMatchingTarget: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = witStateEntry.states.filter(sourceState => sourceState.name === targetState.name); + if (sourceStateMatchingTarget.length === 0) { + try { + await Engine.Task(() => this._witProcessDefinitionApi.deleteStateDefinition(payload.process.typeId, witStateEntry.workItemTypeRefName, targetState.id), + `Delete state '${targetState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`); + } + catch (error) { + throw new ImportError(`Unable to delete state '${targetState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, see logs for details`); + } + } + } + } + + private async _importStates(payload: IProcessPayload): Promise { + for (const witStateEntry of payload.states) { + await this._importWITStates(witStateEntry, payload); + } + } + + private async _importWITRule(rule: WITProcessInterfaces.FieldRuleModel, witRulesEntry: IWITRules, payload: IProcessPayload) { + try { + const createdRule = await Engine.Task( + () => this._witProcessApi.addWorkItemTypeRule(rule, payload.process.typeId, witRulesEntry.workItemTypeRefName), + `Create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}'`); + + if (!createdRule || !createdRule.id) { + throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', server returned empty result or id.`); + } + } + catch (error) { + if (this._config.options.continueOnRuleImportFailure === true) { + logger.logWarning(`Failed to import rule below, continue importing rest of process.\r\n:Error:${error}\r\n${JSON.stringify(rule, null, 2)}`); + } + else { + Utility.handleKnownError(error); + throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', see logs for details.`); + } + } + } + + private async _importRules(payload: IProcessPayload): Promise { + for (const witRulesEntry of payload.rules) { + for (const rule of witRulesEntry.rules) { + if (!rule.isSystem) { + await this._importWITRule(rule, witRulesEntry, payload); + } + } + } + } + + private async _importBehaviors(payload: IProcessPayload): Promise { + const behaviorsOnTarget = await Utility.tryCatchWithKnownError( + async () => { + return await Engine.Task( + () => this._witProcessApi.getBehaviors(payload.process.typeId), + `Get behaviors on target account`); + }, () => new ImportError(`Failed to get behaviors on target account.`)); + + const behaviorIdToRealNameBehavior: { [id: string]: WITProcessDefinitionsInterfaces.BehaviorReplaceModel } = {}; + + for (const behavior of payload.behaviors) { + try { + const existing = behaviorsOnTarget.some(b => b.id === behavior.id); + if (!existing) { + const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = Utility.toCreateBehavior(behavior); + // Use a random name to avoid conflict on scenarios involving a name swap + behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior); + createBehavior.name = Utility.createGuidWithoutHyphen(); + const createdBehavior = await Engine.Task( + () => this._witProcessDefinitionApi.createBehavior(createBehavior, payload.process.typeId), + `Create behavior '${behavior.id}' with fake name '${behavior.name}'`); + if (!createdBehavior || createdBehavior.id !== behavior.id) { + throw new ImportError(`Failed to create behavior '${behavior.name}', server returned empty result or id does not match.`) + } + } + else { + const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = Utility.toReplaceBehavior(behavior); + behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior); + replaceBehavior.name = Utility.createGuidWithoutHyphen(); + const replacedBehavior = await Engine.Task( + () => this._witProcessDefinitionApi.replaceBehavior(replaceBehavior, payload.process.typeId, behavior.id), + `Replace behavior '${behavior.id}' with fake name '${behavior.name}'`); + if (!replacedBehavior) { + throw new ImportError(`Failed to replace behavior '${behavior.name}', server returned empty result.`) + } + } + } + catch (error) { + logger.logException(error); + throw new ImportError(`Failed to import behavior ${behavior.name}, see logs for details.`); + } + } + + // Recover the behavior names to what they should be + for (const id in behaviorIdToRealNameBehavior) { + const behaviorWithRealName = behaviorIdToRealNameBehavior[id]; + const replacedBehavior = await Engine.Task( + () => this._witProcessDefinitionApi.replaceBehavior(behaviorWithRealName, payload.process.typeId, id), + `Replace behavior '${id}' to it's real name '${behaviorWithRealName.name}'`); + if (!replacedBehavior) { + throw new ImportError(`Failed to replace behavior id '${id}' to its real name, server returned empty result.`) + } + } + } + + private async _addBehaviorsToWorkItemTypes(payload: IProcessPayload): Promise { + for (const witBehaviorsEntry of payload.workItemTypeBehaviors) { + for (const behavior of witBehaviorsEntry.behaviors) { + try { + if (witBehaviorsEntry.workItemType.workItemTypeClass === WITProcessDefinitionsInterfaces.WorkItemTypeClass.Custom) { + const addedBehavior = await Engine.Task( + () => this._witProcessDefinitionApi.addBehaviorToWorkItemType(behavior, payload.process.typeId, witBehaviorsEntry.workItemType.refName), + `Add behavior '${behavior.behavior.id}' to work item type '${witBehaviorsEntry.workItemType.refName}'`); + + if (!addedBehavior || addedBehavior.behavior.id !== behavior.behavior.id) { + 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`); + } + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Failed to add behavior '${behavior.behavior.id}' to work item type '${witBehaviorsEntry.workItemType.refName}', check logs for details.`); + } + } + } + } + + private async _importPicklists(payload: IProcessPayload): Promise { + assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); + + const targetFieldToPicklistId = payload.targetAccountInformation.fieldRefNameToPicklistId; + const processedFieldRefNames: IDictionaryStringTo = {}; + for (const picklistEntry of payload.witFieldPicklists) { + if (processedFieldRefNames[picklistEntry.fieldRefName] === true) { + continue; // Skip since we already processed the field, it might be referenced by different work item type + } + + const targetPicklistId = targetFieldToPicklistId[picklistEntry.fieldRefName]; + if (targetPicklistId && targetPicklistId !== PICKLIST_NO_ACTION) { + // Picklist exists but items not match, update items + let newpicklist: WITProcessDefinitionsInterfaces.PickListModel = {}; + Object.assign(newpicklist, picklistEntry.picklist); + newpicklist.id = targetPicklistId; + try { + const updatedPicklist = await Engine.Task( + () => this._witProcessDefinitionApi.updateList(newpicklist, targetPicklistId), + `Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}'`); + + // validate the updated list matches expectation + if (!updatedPicklist || !updatedPicklist.id) { + 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`); + } + + if (updatedPicklist.items.length !== picklistEntry.picklist.items.length) { + throw new ImportError(`Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, items number does not match.`); + } + + for (const item of updatedPicklist.items) { + if (!picklistEntry.picklist.items.some(i => i.value === item.value)) { + throw new ImportError(`Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, item '${item.value}' does not match expected`); + } + } + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Failed to update picklist '${targetPicklistId} for field '${picklistEntry.fieldRefName}', check logs for details.`); + } + } + else if (!targetPicklistId) { + // Target field does not exist we need create picklist to be used when create field. + picklistEntry.picklist.name = `picklist_${Guid.create()}`; // Avoid conflict on target + try { + const createdPicklist = await Engine.Task( + () => this._witProcessDefinitionApi.createList(picklistEntry.picklist), + `Create picklist for field ${picklistEntry.fieldRefName}`); + + if (!createdPicklist || !createdPicklist.id) { + throw new ImportError(`Failed to create picklist for field ${picklistEntry.fieldRefName}, server returned empty result or id.`); + } + + targetFieldToPicklistId[picklistEntry.fieldRefName] = createdPicklist.id; + } + catch (error) { + Utility.handleKnownError(error); + throw new ImportError(`Failed to create picklist for field ${picklistEntry.fieldRefName}, see logs for details.`); + } + } + + processedFieldRefNames[picklistEntry.fieldRefName] = true; + } + } + + private async _createComponents(payload: IProcessPayload): Promise { + await Engine.Task(() => this._importPicklists(payload), "Import picklists on target account"); // This must be before field import + await Engine.Task(() => this._importFields(payload), "Import fields on target account"); + await Engine.Task(() => this._importWorkItemTypes(payload), "Import work item types on target process"); + await Engine.Task(() => this._addFieldsToWorkItemTypes(payload), "Add field to work item types on target process"); + await Engine.Task(() => this._importLayouts(payload), "Import work item form layouts on target process"); + await Engine.Task(() => this._importStates(payload), "Import states on target process"); + await Engine.Task(() => this._importRules(payload), "Import rules on target process"); + await Engine.Task(() => this._importBehaviors(payload), "Import behaviors on target process"); + await Engine.Task(() => this._addBehaviorsToWorkItemTypes(payload), "Add behavior to work item types on target process"); + } + + private async _validateProcess(payload: IProcessPayload): Promise { + if (payload.process.properties.class != WITProcessInterfaces.ProcessClass.Derived) { + throw new ValidationError("Only inherited process is supported to be imported."); + } + + const targetProcesses: WITProcessInterfaces.ProcessModel[] = + await Utility.tryCatchWithKnownError(async () => { + return await Engine.Task(() => this._witProcessApi.getProcesses(), `Get processes on target account`); + }, () => new ValidationError("Failed to get processes on target acccount, check account url, token and token permission.")); + + if (!targetProcesses) { // most likely 404 + throw new ValidationError("Failed to get processes on target acccount, check account url."); + } + + for (const process of targetProcesses) { + if (payload.process.name.toLowerCase() === process.name.toLowerCase()) { + throw new ValidationError("Process with same name already exists on target account."); + } + } + } + + private async _validateFields(payload: IProcessPayload): Promise { + const currentFieldsOnTarget: WITInterfaces.WorkItemField[] = + await Utility.tryCatchWithKnownError(async () => { + return await Engine.Task( + () => this._witApi.getFields(), + `Get fields on target account`); + }, () => new ValidationError("Failed to get fields on target account.")); + + if (!currentFieldsOnTarget) { // most likely 404 + throw new ValidationError("Failed to get fields on target account.") + } + + payload.targetAccountInformation.collectionFields = currentFieldsOnTarget; + for (const sourceField of payload.fields) { + const convertedSrcFieldType: number = Utility.WITProcessToWITFieldType(sourceField.type, sourceField.isIdentity); + const conflictingFields: WITInterfaces.WorkItemField[] = currentFieldsOnTarget.filter(targetField => + ((targetField.referenceName === sourceField.id) || (targetField.name === sourceField.name)) // match by name or reference name + && convertedSrcFieldType !== targetField.type // but with a different type + && (!sourceField.isIdentity || !targetField.isIdentity)); // with exception if both are identity - known issue we export identity field type = string + + if (conflictingFields.length > 0) { + throw new ValidationError(`Field in target Collection conflicts with '${sourceField.name}' field with a diffrent refrence name or type.`); + } + } + } + + private async _populatePicklistDictionary(fields: WITInterfaces.WorkItemField[]): Promise> { + const ret: IDictionaryStringTo = {}; + const promises: Promise[] = []; + for (const field of fields) { + const anyField = field; // TODO: When vso-node-api updates, remove this hack + assert(field.isPicklist || !anyField.picklistId, "Non picklist field should not have picklist") + if (field.isPicklist && anyField.picklistId) { + promises.push(this._witProcessDefinitionApi.getList(anyField.picklistId).then(list => ret[field.referenceName] = list)); + } + } + await Promise.all(promises); + return ret; + } + + /** + * Validate picklist and output to payload.targetAccountInformation.fieldRefNameToPicklistId for directions under different case + * 1) Picklist field does not exist -> importPicklists will create picklist and importFields will use the picklist created + * 2) Picklist field exist and items match -> no-op for importPicklists/importFields + * 3) Picklist field exists but items does not match -> if 'overwritePicklist' enabled, importPicklists will update items and importFields will skip + * @param payload + */ + private async _validatePicklists(payload: IProcessPayload): Promise { + assert(payload.targetAccountInformation && payload.targetAccountInformation.collectionFields, "[Unexpected] - targetInformation not properly populated"); + + const fieldToPicklistIdMapping = payload.targetAccountInformation.fieldRefNameToPicklistId; // This is output for import picklist/field + const currentTargetFieldToPicklist = await this._populatePicklistDictionary(payload.targetAccountInformation.collectionFields); + + for (const picklistEntry of payload.witFieldPicklists) { + const fieldRefName = picklistEntry.fieldRefName; + const currentTargetPicklist = currentTargetFieldToPicklist[fieldRefName]; + if (currentTargetPicklist) { + // Compare the pick list items + let conflict: boolean; + if (currentTargetPicklist.items.length === picklistEntry.picklist.items.length && !currentTargetPicklist.isSuggested === !picklistEntry.picklist.isSuggested) { + for (const sourceItem of picklistEntry.picklist.items) { + if (currentTargetPicklist.items.filter(targetItem => targetItem.value === sourceItem.value).length !== 1) { + conflict = true; + break; + } + } + } + else { + conflict = true; + } + + if (conflict) { + if (!(this._config.options && this._config.options.overwritePicklist === true)) { + throw new ValidationError(`Picklist field ${fieldRefName} exist on target account but have different items than source, set 'overwritePicklist' option to overwrite`); + } + else { + fieldToPicklistIdMapping[fieldRefName] = currentTargetPicklist.id; // We will need to update the picklist later when import picklists + } + } + else { + fieldToPicklistIdMapping[fieldRefName] = PICKLIST_NO_ACTION; // No action needed since picklist values match. + } + } + else { + // No-op, leave payload.targetAccountInformation.fieldRefNameToPicklistId[picklistEntry.fieldRefName] = undefined, which indicates creating new picklist. + } + } + } + + private async _preImportValidation(payload: IProcessPayload): Promise { + payload.targetAccountInformation = { + fieldRefNameToPicklistId: {} + }; // set initial value for target account information + + if (!this._commandLineOptions.overwriteProcessOnTarget) { // only validate if we are not cleaning up target + await Engine.Task(() => this._validateProcess(payload), "Validate process existence on target account"); + } + await Engine.Task(() => this._validateFields(payload), "Validate fields on target account"); + await Engine.Task(() => this._validatePicklists(payload), "Validate picklists on target account"); + } + + private async _deleteProcessOnTarget(targetProcessName: string) { + const processes = await this._witProcessApi.getProcesses(); + for (const process of processes.filter(p => p.name.toLocaleLowerCase() === targetProcessName.toLocaleLowerCase())) { + await Utility.tryCatchWithKnownError( + async () => await Engine.Task( + () => this._witProcessApi.deleteProcess(process.typeId), + `Delete process '${process.name}' on target account`), + () => new ImportError(`Failed to delete process on target, do you have projects created using that project?`)); + } + } + + private async _createProcess(payload: IProcessPayload) { + const createProcessModel: WITProcessInterfaces.CreateProcessModel = Utility.ProcessModelToCreateProcessModel(payload.process); + const createdProcess = await Engine.Task( + () => this._witProcessApi.createProcess(createProcessModel), + `Create process '${createProcessModel.name}'`); + if (!createdProcess) { + throw new ImportError(`Failed to create process '${createProcessModel.name}' on target account.`); + } + payload.process.typeId = createdProcess.typeId; + } + + public async importProcess(payload: IProcessPayload): Promise { + logger.logInfo("Process import started."); + + try { + if (this._config.targetProcessName) { + payload.process.name = this._config.targetProcessName; + } + + await Engine.Task(() => this._preImportValidation(payload), "Pre-import validation on target account"); + + if (this._commandLineOptions.overwriteProcessOnTarget) { + await Engine.Task(() => this._deleteProcessOnTarget(payload.process.name), "Delete process (if exist) on target account"); + } + + await Engine.Task(() => this._createProcess(payload), "Create process on target account"); + await Engine.Task(() => this._createComponents(payload), "Create artifacts on target process"); + } + catch (error) { + if (error instanceof ValidationError) { + logger.logError("Pre-Import validation failed. No artifacts were created on target process") + } + throw error; + } + + logger.logInfo("Process import completed successfully."); + } +} \ No newline at end of file diff --git a/src/Common/Utilities.ts b/src/Common/Utilities.ts new file mode 100644 index 0000000..16fbf71 --- /dev/null +++ b/src/Common/Utilities.ts @@ -0,0 +1,234 @@ +import * as WITInterfaces from "vso-node-api/interfaces/WorkItemTrackingInterfaces"; +import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; +import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; +import { KnownError } from "./Errors"; +import { logger } from "./Logger"; +import { Modes, IConfigurationFile, LogLevel } from "./Interfaces"; +import * as url from "url"; +import { Guid } from "guid-typescript"; +import { regexRemoveHypen } from "./Constants"; + +export class Utility { + /** Convert from WITProcess FieldModel to WITProcessDefinitions FieldModel + * @param fieldModel + */ + public static WITProcessToWITProcessDefinitionsFieldModel(fieldModel: WITProcessInterfaces.FieldModel): WITProcessDefinitionsInterfaces.FieldModel { + + let outField: WITProcessDefinitionsInterfaces.FieldModel = { + description: fieldModel.description, + id: fieldModel.id, + name: fieldModel.name, + type: fieldModel.isIdentity ? WITProcessDefinitionsInterfaces.FieldType.Identity : fieldModel.type, + url: fieldModel.url, + pickList: null + } + return outField; + } + + /** Convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType + * @param witProcessFieldType + */ + public static WITProcessToWITFieldType(witProcessFieldType: number, fieldIsIdentity: boolean): number { + if (fieldIsIdentity) { return WITInterfaces.FieldType.Identity; } + + switch (witProcessFieldType) { + case WITProcessInterfaces.FieldType.String: { return WITInterfaces.FieldType.String; } + case WITProcessInterfaces.FieldType.Integer: { return WITInterfaces.FieldType.Integer; } + case WITProcessInterfaces.FieldType.DateTime: { return WITInterfaces.FieldType.DateTime; } + case WITProcessInterfaces.FieldType.PlainText: { return WITInterfaces.FieldType.PlainText; } + case WITProcessInterfaces.FieldType.Html: { return WITInterfaces.FieldType.Html; } + case WITProcessInterfaces.FieldType.TreePath: { return WITInterfaces.FieldType.TreePath; } + case WITProcessInterfaces.FieldType.History: { return WITInterfaces.FieldType.History; } + case WITProcessInterfaces.FieldType.Double: { return WITInterfaces.FieldType.Double; } + case WITProcessInterfaces.FieldType.Guid: { return WITInterfaces.FieldType.Guid; } + case WITProcessInterfaces.FieldType.Boolean: { return WITInterfaces.FieldType.Boolean; } + case WITProcessInterfaces.FieldType.Identity: { return WITInterfaces.FieldType.Identity; } + case WITProcessInterfaces.FieldType.PicklistInteger: { return WITInterfaces.FieldType.PicklistInteger; } + case WITProcessInterfaces.FieldType.PicklistString: { return WITInterfaces.FieldType.PicklistString; } + case WITProcessInterfaces.FieldType.PicklistDouble: { return WITInterfaces.FieldType.PicklistDouble; } + default: { throw new Error(`Failed to convert from WorkItemTrackingProcess.FieldType to WorkItemTracking.FieldType, unrecognized enum value '${witProcessFieldType}'`) } + } + } + + /**Convert process from ProcessModel to CreateProcessModel + * @param processModel + */ + public static ProcessModelToCreateProcessModel(processModel: WITProcessInterfaces.ProcessModel): WITProcessInterfaces.CreateProcessModel { + const createModel: WITProcessInterfaces.CreateProcessModel = { + description: processModel.description, + name: processModel.name, + parentProcessTypeId: processModel.properties.parentProcessTypeId, + referenceName: Utility.createGuidWithoutHyphen() // Reference name does not really matter since we already have typeId + }; + return createModel; + } + + /**Convert group from getLayout group interface to WITProcessDefinitionsInterfaces.Group + * @param group + */ + public static toCreateGroup(group: WITProcessDefinitionsInterfaces.Group): WITProcessDefinitionsInterfaces.Group { + let createGroup: WITProcessDefinitionsInterfaces.Group = { + id: group.id, + inherited: group.inherited, + label: group.label, + isContribution: group.isContribution, + visible: group.visible, + controls: null, + contribution: group.contribution, + height: group.height, + order: null, + overridden: null + } + return createGroup; + } + + /**Convert control from getLayout control interface to WITProcessDefinitionsInterfaces.Control + * @param control + */ + public static toCreateControl(control: WITProcessDefinitionsInterfaces.Control): WITProcessDefinitionsInterfaces.Control { + let createControl: WITProcessDefinitionsInterfaces.Control = { + id: control.id, + inherited: control.inherited, + label: control.label, + controlType: control.controlType, + readOnly: control.readOnly, + watermark: control.watermark, + metadata: control.metadata, + visible: control.visible, + isContribution: control.isContribution, + contribution: control.contribution, + height: control.height, + order: null, + overridden: null + } + return createControl; + } + + /**Convert page from getLayout page interface to WITProcessDefinitionsInterfaces.Page + * @param control + */ + public static toCreatePage(page: WITProcessDefinitionsInterfaces.Page): WITProcessDefinitionsInterfaces.Page { + let createPage: WITProcessDefinitionsInterfaces.Page = { + id: page.id, + inherited: page.inherited, + label: page.label, + pageType: page.pageType, + locked: page.locked, + visible: page.visible, + isContribution: page.isContribution, + sections: null, + contribution: page.contribution, + order: null, + overridden: null + } + return createPage; + } + + /**Convert a state result to state input + * @param group + */ + public static toCreateOrUpdateStateDefinition(state: WITProcessInterfaces.WorkItemStateResultModel): WITProcessDefinitionsInterfaces.WorkItemStateInputModel { + const updateState: WITProcessDefinitionsInterfaces.WorkItemStateInputModel = { + color: state.color, + name: state.name, + stateCategory: state.stateCategory, + order: null + } + return updateState; + } + + public static toCreateBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorCreateModel { + const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = { + color: behavior.color, + inherits: behavior.inherits.id, + name: behavior.name + }; + // TODO: Move post S135 when generated model has id. + (createBehavior).id = behavior.id; + return createBehavior; + } + + public static toReplaceBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorReplaceModel { + const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = { + color: behavior.color, + name: behavior.name + } + return replaceBehavior; + } + + public static handleKnownError(error: any) { + if (error instanceof KnownError) { throw error; } + logger.logException(error); + } + + public static async tryCatchWithKnownError(action: () => Promise | T, thrower: () => Error): Promise { + try { + return await action(); + } + catch (error) { + Utility.handleKnownError(error); + throw thrower(); + } + } + + public static validateConfiguration(configuration: IConfigurationFile, mode: Modes): boolean { + if (mode === Modes.export || mode === Modes.migrate) { + if (!configuration.sourceAccountUrl || !url.parse(configuration.sourceAccountUrl).host) { + logger.logError(`[Configuration validation] Missing or invalid source account url: '${configuration.sourceAccountUrl}'.`); + return false; + } + if (!configuration.sourceAccountToken) { + logger.logError(`[Configuration validation] Missing personal access token for source account.`); + return false; + } + if (!configuration.sourceProcessName) { + logger.logError(`[Configuration validation] Missing source process name.`); + return false; + } + } + + if (mode === Modes.import || mode === Modes.migrate) { + if (!configuration.targetAccountUrl || !url.parse(configuration.targetAccountUrl).host) { + logger.logError(`[Configuration validation] Missing or invalid target account url: '${configuration.targetAccountUrl}'.`); + return false; + } + if (!configuration.targetAccountToken) { + logger.logError(`[Configuration validation] Personal access token for target account is empty.`); + return false; + } + if (configuration.options && configuration.options.overwritePicklist && (configuration.options.overwritePicklist !== true && configuration.options.overwritePicklist !== false)) { + logger.logError(`[Configuration validation] Option 'overwritePicklist' is not a valid boolean.`); + return false; + } + if (configuration.options && configuration.options.continueOnRuleImportFailure && (configuration.options.continueOnRuleImportFailure !== true && configuration.options.continueOnRuleImportFailure !== false)) { + logger.logError(`[Configuration validation] Option 'continueOnRuleImportFailure' is not a valid boolean.`); + return false; + } + if (configuration.options && configuration.options.continueOnIdentityDefaultValueFailure && (configuration.options.continueOnIdentityDefaultValueFailure !== true && configuration.options.continueOnIdentityDefaultValueFailure !== false)) { + logger.logError(`[Configuration validation] Option 'continueOnFieldImportDefaultValueFailure' is not a valid boolean.`); + return false; + } + if (configuration.options && configuration.options.skipImportFormContributions && (configuration.options.skipImportFormContributions !== true && configuration.options.skipImportFormContributions !== false)) { + logger.logError(`[Configuration validation] Option 'skipImportFormContributions' is not a valid boolean.`); + return false; + } + } + + if (configuration.options && configuration.options.logLevel && LogLevel[configuration.options.logLevel] === undefined) { + logger.logError(`[Configuration validation] Option 'logLevel' is not a valid log level.`); + return false; + } + + return true; + } + + public static didUserCancel(): boolean { + return Utility.isCancelled; + } + + public static createGuidWithoutHyphen(): string { + return Guid.create().toString().replace(regexRemoveHypen, ""); + } + + protected static isCancelled = false; +} diff --git a/src/Constants.ts b/src/Constants.ts deleted file mode 100644 index c8e4c85..0000000 --- a/src/Constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const PICKLIST_NO_ACTION = "PICKLIST_NO_ACTION"; -export const configurationFilename = "configuration.json"; -export const defaultEncoding = "utf-8"; -export const defaultLogFileName = "process_import_export.log"; -export const defaultConfiguration = { - "sourceAccountUrl": "", - "sourceAccountToken": "", - "targetAccountUrl": "", - "targetAccountToken": "", - "options" : { - "sourceProcessName": "", - "writeToFile": true, - "onlineReImport": true - } -}; \ No newline at end of file diff --git a/src/Errors.ts b/src/Errors.ts deleted file mode 100644 index 261a962..0000000 --- a/src/Errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -export class CancellationError extends Error { - constructor() { - super("Process Import/Export cancelled. See log file for details"); - } -} - -export class ValidationError extends Error { - constructor(message: string) { - super(`Process Import/Export does not meet the requiements for import. ${message}`); - } -} - -export class ImportError extends Error { - constructor(message: string) { - super(`Import failed. See log file for details. ${message}`); - //TODO implement log file of all the artifacts that have been created in the target acccount. - } -} - -export class ExportError extends Error { - constructor(message: string) { - super(`Export failed. ${message}`); - } -} \ No newline at end of file diff --git a/src/ImportExportProcess.ts b/src/ImportExportProcess.ts deleted file mode 100644 index ced6272..0000000 --- a/src/ImportExportProcess.ts +++ /dev/null @@ -1,1068 +0,0 @@ -import * as assert from "assert"; -import { format } from "path"; -import { writeFileSync, readFileSync, appendFileSync, existsSync, unlinkSync } from "fs"; -import { isFunction } from "util"; -import * as vsts from "vso-node-api/WebApi"; -import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; -import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces"; -import * as WITInterfaces from "vso-node-api/interfaces/WorkItemTrackingInterfaces"; -import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi } from "vso-node-api/WorkItemTrackingProcessDefinitionsApi"; -import { IWorkItemTrackingProcessApi as WITProcessApi } from "vso-node-api/WorkItemTrackingProcessApi"; -import { IWorkItemTrackingApi as WITApi } from "vso-node-api/WorkItemTrackingApi"; -import { ImportError, ExportError, ValidationError, CancellationError } from "./Errors"; -import { PICKLIST_NO_ACTION, defaultConfiguration, configurationFilename, defaultEncoding, defaultLogFileName } from "./Constants"; -import { IExportOptions, IUserConfigurationOptions, IProcessPayload, IDictionaryStringTo, IWITLayout, IWITRules, IWITBehaviors, IWITFieldPicklist, IWITStates, IWITypeFields, IWITBehaviorsInfo, LogLevel } from "./Interfaces"; -import * as url from "url"; -import * as readline from "readline"; - -export class Logger { - constructor(private _logFilename: string, private _logLevel: LogLevel) { - if (existsSync(_logFilename)) { - unlinkSync(_logFilename); - } - } - - public logVerbose(message: string) { - this._log(message, LogLevel.Verbose); - } - - public logInfo(message: string) { - this._log(message, LogLevel.Information); - } - - public logWarning(message: string) { - this._log(message, LogLevel.Warning); - } - - public logError(message: string) { - //TODO: make stack trace more readable (map back to .ts functions?) - const stack = new Error().stack; - if (stack) - message += stack; - - this._log(message, LogLevel.Error); - } - - public logException(error: Error) { - if (error instanceof Error) { - this._log(`Exception message:${error.message}\r\nCall stack:${error.stack}`, LogLevel.Verbose); - } - else { - this._log(`Unknown exception: ${JSON.stringify(error)}`, LogLevel.Verbose); - } - } - - private _log(message: string, logLevel: LogLevel) { - const outputMessage: string = `[${LogLevel[logLevel]}] [${(new Date(Date.now())).toISOString()}] ${message}`; - if (logLevel <= this._logLevel) { - console.log(outputMessage); - } - - //TODO: revisit the perf here - this isn't very nice but should work at the size of the application - appendFileSync(this._logFilename, outputMessage); - } -} -let logger: Logger; - -export class Engine { - constructor(private options: IUserConfigurationOptions) { - } - - private async writeToLog(message: string) { - //TODO: This is super tricky - let's replace with a meaningful name from caller. - let logString: string = 'Executing ' + message.replace("function () { return _this.", "").replace("; }", ""); - logger.logInfo(logString); - } - - public async Task(step: () => Promise): Promise { - if (Utility.didUserCancel()) { - throw new CancellationError(); - } - this.writeToLog(step.toString()); - return step(); - } -} - -export class Utility { - /** Convert from WITProcess FieldModel to WITProcessDefinitions FieldModel - * @param fieldModel - */ - public static WITProcessToWITProcessDefinitionsFieldModel(fieldModel: WITProcessInterfaces.FieldModel): WITProcessDefinitionsInterfaces.FieldModel { - - let outField: WITProcessDefinitionsInterfaces.FieldModel = { - description: fieldModel.description, - id: fieldModel.id, - name: fieldModel.name, - type: fieldModel.isIdentity ? WITProcessDefinitionsInterfaces.FieldType.Identity : fieldModel.type, - url: fieldModel.url, - pickList: null - } - return outField; - } - - /** Convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType - * @param witProcessFieldType - */ - public static WITProcessToWITFieldType(witProcessFieldType: number, fieldIsIdentity: boolean): number { - if (fieldIsIdentity) { return WITInterfaces.FieldType.Identity; } - - switch (witProcessFieldType) { - case WITProcessInterfaces.FieldType.String: { return WITInterfaces.FieldType.String; } - case WITProcessInterfaces.FieldType.Integer: { return WITInterfaces.FieldType.Integer; } - case WITProcessInterfaces.FieldType.DateTime: { return WITInterfaces.FieldType.DateTime; } - case WITProcessInterfaces.FieldType.PlainText: { return WITInterfaces.FieldType.PlainText; } - case WITProcessInterfaces.FieldType.Html: { return WITInterfaces.FieldType.Html; } - case WITProcessInterfaces.FieldType.TreePath: { return WITInterfaces.FieldType.TreePath; } - case WITProcessInterfaces.FieldType.History: { return WITInterfaces.FieldType.History; } - case WITProcessInterfaces.FieldType.Double: { return WITInterfaces.FieldType.Double; } - case WITProcessInterfaces.FieldType.Guid: { return WITInterfaces.FieldType.Guid; } - case WITProcessInterfaces.FieldType.Boolean: { return WITInterfaces.FieldType.Boolean; } - case WITProcessInterfaces.FieldType.Identity: { return WITInterfaces.FieldType.Identity; } - case WITProcessInterfaces.FieldType.PicklistInteger: { return WITInterfaces.FieldType.PicklistInteger; } - case WITProcessInterfaces.FieldType.PicklistString: { return WITInterfaces.FieldType.PicklistString; } - case WITProcessInterfaces.FieldType.PicklistDouble: { return WITInterfaces.FieldType.PicklistDouble; } - default: { throw new Error("Failed to convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType. Input WorkItemTrackingProcess FieldType not declared as enum.") } - } - } - - /**Convert process from ProcessModel to CreateProcessModel - * @param processModel - */ - public static ProcessModelToCreateProcessModel(processModel: WITProcessInterfaces.ProcessModel): WITProcessInterfaces.CreateProcessModel { - const createModel: WITProcessInterfaces.CreateProcessModel = { - description: processModel.description, - name: processModel.name, - parentProcessTypeId: processModel.properties.parentProcessTypeId, - referenceName: processModel.referenceName - }; - return createModel; - } - - /**Convert group from getLayout group interface to WITProcessDefinitionsInterfaces.Group - * @param group - */ - public static toCreateGroup(group: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Group { - let createGroup: WITProcessDefinitionsInterfaces.Group = { - id: group.id, - inherited: group.inherited, - label: group.label, - isContribution: group.isContribution, - visible: group.visible, - controls: null, - contribution: null, - height: null, - order: null, - overridden: null - } - return createGroup; - } - - /**Convert control from getLayout control interface to WITProcessDefinitionsInterfaces.Control - * @param control - */ - public static toCreateControl(control: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Control { - let createControl: WITProcessDefinitionsInterfaces.Control = { - id: control.id, - inherited: control.inherited, - label: control.label, - controlType: control.controlType, - readOnly: control.readOnly, - watermark: control.watermark, - metadata: control.metadata, - visible: control.visible, - isContribution: control.isContribution, - contribution: null, - height: null, - order: null, - overridden: null - } - return createControl; - } - - /**Convert page from getLayout page interface to WITProcessDefinitionsInterfaces.Page - * @param control - */ - public static toCreatePage(page: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Page { - let createPage: WITProcessDefinitionsInterfaces.Page = { - id: page.id, - inherited: page.inherited, - label: page.label, - pageType: page.pageType, - locked: page.loacked, - visible: page.visible, - isContribution: page.isContribution, - sections: null,//yeah?? - contribution: null, - order: null, - overridden: null - } - return createPage; - } - - public static toCreateBehavior(behavior: WITProcessDefinitionsInterfaces.BehaviorModel): WITProcessDefinitionsInterfaces.BehaviorCreateModel { - let createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = { - color: behavior.color, - inherits: behavior.inherits.id, - name: behavior.name - } - return createBehavior; - } - - public static toReplaceBehavior(behavior: WITProcessDefinitionsInterfaces.BehaviorModel): WITProcessDefinitionsInterfaces.BehaviorReplaceModel { - let replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = { - color: behavior.color, - name: behavior.name - } - return replaceBehavior; - } - - public static startCanellationListener() { - const stdin = process.stdin; - if (!isFunction(stdin.setRawMode)) { - logger.logInfo(`We are running inside a TTY does not support RAW mode, you must cancel operation with CTRL+C`); - return; - } - stdin.setRawMode(true); - readline.emitKeypressEvents(stdin); - stdin.addListener("keypress", this._listener); - logger.logVerbose("Keyboard listener added"); - } - - public static didUserCancel(): boolean { - return Utility.isCancelled; - } - - public static getLogFilePath(options: IUserConfigurationOptions): string { - const logFilename = format({ - root: options.outputPath ? options.outputPath : ".", - base: options.logfileName ? options.logfileName : defaultLogFileName - }) - return logFilename; - } - - public static getWebApi(accountUrl: string, PAT: string): vsts.WebApi { - const authHandlerSRC = vsts.getPersonalAccessTokenHandler(PAT); - return new vsts.WebApi(accountUrl, authHandlerSRC); - } - - public static validateConfiguration(configuration: any): boolean { - if (!configuration.sourceAccountUrl || !url.parse(configuration.sourceAccountUrl).host) { - console.log(`[Configuration validation] Missing or invalid source account url: '${configuration.sourceAccountUrl}'.`); - return false; - } - if (!configuration.targetAccountUrl || !url.parse(configuration.targetAccountUrl).host) { - console.log(`vMissing or invalid target account url: '${configuration.targetAccountUrl}'.`); - return false; - } - if (!configuration.sourceAccountToken) { - console.log(`[Configuration validation] Personal access token for source account is empty`); - return false; - } - if (!configuration.targetAccountToken) { - console.log(`[Configuration validation] Personal access token for target account is empty`); - return false; - } - if (!configuration.options || !configuration.options.sourceProcessName) { - console.log(`[Configuration validation] Missing source process name`); - return false; - } - if (configuration.options && configuration.options.writeToFile && (configuration.options.writeToFile !== true && configuration.options.writeToFile !== false)) { - console.log(`[Configuration validation] Option 'writeToFile' is not a valid boolean`); - return false; - } - if (configuration.options && configuration.options.onlineReImport && (configuration.options.onlineReImport !== true && configuration.options.onlineReImport !== false)) { - console.log(`[Configuration validation] Option 'onlineReImport' is not a valid boolean`); - return false; - } - return true; - } - - - private static _listener = (str: string, key: readline.Key) => { - if (key.name.toLocaleLowerCase() === "q") { - logger.logVerbose("Setting isCancelled to true."); - Utility.isCancelled = true; - } - }; - - private static isCancelled = false; -} - -export class ProcessImporter { - private vstsWebApi: vsts.WebApi; - private witProcessApi: WITProcessApi; - private witProcessDefinitionApi: WITProcessDefinitionApi; - private witApi: WITApi; - private engine: Engine; - - constructor(vstsWebApi: vsts.WebApi, private configurationOptions?: IUserConfigurationOptions) { - this.vstsWebApi = vstsWebApi; - this.engine = new Engine(configurationOptions); - } - - public async getApis() { - this.witApi = await this.vstsWebApi.getWorkItemTrackingApi(); - this.witProcessApi = await this.vstsWebApi.getWorkItemTrackingProcessApi(); - this.witProcessDefinitionApi = await this.vstsWebApi.getWorkItemTrackingProcessDefinitionApi(); - } - - /**Reads-in a previously stored JSON containing an IProcessPayload*/ - public async uploadProcessPayload(pathToFile: string): Promise { - const processPayload = JSON.parse(await readFileSync(pathToFile, defaultEncoding)); - return processPayload; - } - - private async importWorkItemTypes(payload: IProcessPayload): Promise { - try { - for (const wit of payload.workItemTypes) { - if (wit.class === WITProcessInterfaces.WorkItemTypeClass.System) { - //The exported payload should not have exported System WITypes, so fail on import. - throw new ImportError(`Work Item Type '${wit.name}' is a System work item type with no modifications, cannot import.`); - } - else { - await this.witProcessDefinitionApi.createWorkItemType(wit, payload.process.typeId); - } - } - } - catch (error) { - if (!(error instanceof ImportError)) { - logger.logError(`Error creating the Work Item Type on target account. ${error}`); - } - throw error; - } - } - - /** - * This process payload from export and return fields that need create also fix Identity field type and picklist id - */ - private async getFieldsToCreate(payload: IProcessPayload): Promise { - assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); - - let fieldsOnTarget: WITInterfaces.WorkItemField[]; - try { - fieldsOnTarget = await this.witApi.getFields(); - } - catch (error) { - logger.logError("Error with getting fields from target. Possible auth. issue on target account."); - throw error; - } - - // Build a lookup to know if a field is picklist field. - const isPicklistField: IDictionaryStringTo = {}; - for (const e of payload.witFieldPicklists) { - isPicklistField[e.fieldRefName] = true; - } - - const outputFields: WITProcessDefinitionsInterfaces.FieldModel[] = []; - for (const sourceField of payload.fields) { - const fieldExist = fieldsOnTarget.some(targetField => targetField.referenceName === sourceField.id); - if (!fieldExist) { - const createField: WITProcessDefinitionsInterfaces.FieldModel = Utility.WITProcessToWITProcessDefinitionsFieldModel(sourceField); - if (sourceField.isIdentity) { - createField.type = WITProcessDefinitionsInterfaces.FieldType.Identity; - } - if (isPicklistField[sourceField.id]) { - const picklistId = payload.targetAccountInformation.fieldRefNameToPicklistId[sourceField.id]; - assert(picklistId !== PICKLIST_NO_ACTION, "[Unexpected] We are creating the field which we found the matching field earlier on collection") - createField.pickList = { - id: picklistId, - isSuggested: null, - name: null, - type: null, - url: null - }; - //createField.type = Utility.ConvertToPicklistType(createField.type); - } - outputFields.push(createField); - } - } - return outputFields; - } - - /**Create fields at a collection scope*/ - private async importFields(payload: IProcessPayload): Promise { - try { - const fieldsToCreate: WITProcessDefinitionsInterfaces.FieldModel[] = await this.engine.Task(() => this.getFieldsToCreate(payload)); - if (fieldsToCreate.length > 0) { - const createFieldPromises: Promise[] = []; - for (let field of fieldsToCreate) { - field && createFieldPromises.push(this.witProcessDefinitionApi.createField(field, payload.process.typeId).then(fieldCreated => { - if (!fieldCreated) { - throw new ImportError(`Create field '${field.name}' failed, server returned null object`); - } - if (fieldCreated.id !== field.id) { - throw new ImportError(`Create field '${field.name}' actually returned referenace name '${fieldCreated.id}' instead of anticipated '${field.id}', are you on latest VSTS?`); - } - }, (err) => { - throw new ImportError(`Create field '${field.name}' failed, server error message:'${err.message}'`); - })); - } - logger.logInfo(`Attempting creating fields: ${fieldsToCreate.map(f => f.name).join(",")}`); - await Promise.all(createFieldPromises); - } - } - catch (error) { - throw new ImportError(`[Unexpected] Field import failure. ${error}`); - } - } - - /**Create fields at a Work Item Type scope*/ - private async addFieldsToWorkItemTypes(payload: IProcessPayload): Promise { - - let addFieldPromises: Promise[] = []; - for (let IWorkItemTypeFields of payload.workItemTypeFields) { - for (let field of IWorkItemTypeFields.fields) { - try { - await this.witProcessDefinitionApi.addFieldToWorkItemType(field, payload.process.typeId, IWorkItemTypeFields.workItemTypeRefName); - } - catch (error) { - console.log(`Unable to add ${field.name} field to ${IWorkItemTypeFields.workItemTypeRefName} WIT: ${error}`); - throw new ImportError(`Unable to add ${field.name} field to ${IWorkItemTypeFields.workItemTypeRefName} WIT: ${error}`); - } - } - } - } - - private async importLayouts(payload: IProcessPayload): Promise { - /** Notes: - * HTML controls need to be created at the same tme as the group they are in. - * Non HTML controls need to be added 1 by 1 after the group they are in has been created. - */ - for (const witLayout of payload.layouts) { - for (const page of witLayout.layout.pages) { - let newPage: WITProcessDefinitionsInterfaces.Page;//The newly created page, contains the pageId required to create groups. - const targetLayout: WITProcessDefinitionsInterfaces.FormLayout = await this.witProcessDefinitionApi.getFormLayout(payload.process.typeId, witLayout.workItemTypeRefName); - const sourcePagesOnTarget: WITProcessDefinitionsInterfaces.Page[] = targetLayout.pages.filter(p => p.id === page.id); - if (!page) { - throw new ImportError("NULL page."); - } - const createPage: WITProcessDefinitionsInterfaces.Page = Utility.toCreatePage(page); - if (sourcePagesOnTarget.length === 0) {//Page is new - try { - newPage = await this.witProcessDefinitionApi.addPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName); - page.id = newPage.id; - } - catch (error) { - throw new ImportError(`Unable to add '${page}' page to ${witLayout.workItemTypeRefName}. ${error}`); - } - } - else {//Update page, it already exists on target - try { - newPage = await this.witProcessDefinitionApi.editPage(createPage, payload.process.typeId, witLayout.workItemTypeRefName); - page.id = newPage.id; - } - catch (error) { - throw new ImportError(`Unable to add '${page}' page to ${witLayout.workItemTypeRefName}. ${error}`); - } - } - for (const section of page.sections) { - for (const group of section.groups) { - let newGroup: WITProcessDefinitionsInterfaces.Group; - - if (group.controls.length !== 0 && group.controls[0].controlType === "HtmlFieldControl") { - //Handle groups with HTML Controls - try { - let createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); - - if (group.inherited) { - if (group.overridden) { - //edit - newGroup = await this.witProcessDefinitionApi.editGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id, group.id); - - const htmlControl = group.controls[0]; - if (htmlControl.overridden) { - // If the HTML control is overriden, we must update that as well - await this.witProcessDefinitionApi.editControl(htmlControl, payload.process.typeId, witLayout.workItemTypeRefName, newGroup.id, htmlControl.id); - } - } - else { - // no-op since the group is not overriden - } - } - else { - // special handling for HTML control - we must create a group containing the HTML control at same time. - createGroup.controls = group.controls; - newGroup = await this.witProcessDefinitionApi.addGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id); - } - } - catch (error) { - throw new ImportError(`Unable to add ${group} HTML group to ${witLayout.workItemTypeRefName}. ${error}`); - } - } - else { - //Groups with no HTML Controls - try { - let createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); - - if (group.inherited) { - if (group.overridden) { - //edit - newGroup = await this.witProcessDefinitionApi.editGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id, group.id); - group.id = newGroup.id; - } - } - else { - //create - newGroup = await this.witProcessDefinitionApi.addGroup(createGroup, payload.process.typeId, witLayout.workItemTypeRefName, page.id, section.id); - group.id = newGroup.id; - } - } - catch (error) { - throw new ImportError(`Unable to add ${group} group to ${witLayout.workItemTypeRefName}. ${error}`); - } - - for (let control of group.controls) { - if (!control.inherited || control.overridden) { - try { - let createControl: WITProcessDefinitionsInterfaces.Control = Utility.toCreateControl(control); - - if (control.inherited) { - if (control.overridden) { - //edit - await this.witProcessDefinitionApi.editControl(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, control.id); - } - } - else { - //create - await this.witProcessDefinitionApi.addControlToGroup(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id); - } - } - catch (error) { - throw new ImportError(`Unable to add '${control}' control to page '${page}' in '${witLayout.workItemTypeRefName}'. ${error}`); - } - } - } - } - } - } - } - } - } - - private async importStates(payload: IProcessPayload): Promise { - for (let sourceWITState of payload.states) { - let targetWITStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = await this.witProcessApi.getStateDefinitions(payload.process.typeId, sourceWITState.workItemTypeRefName); - for (let sourceState of sourceWITState.states) { - try { - const existingStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = targetWITStates.filter(targetState => sourceState.name === targetState.name); - if (existingStates.length === 0) { //does not exist on target - await this.witProcessDefinitionApi.createStateDefinition(sourceState, payload.process.typeId, sourceWITState.workItemTypeRefName); - } - else { - if (sourceState.hidden) { // if state exists on target, only update if hidden - await this.witProcessDefinitionApi.hideStateDefinition({ hidden: true }, payload.process.typeId, sourceWITState.workItemTypeRefName, existingStates[0].id); - } - } - } - catch (error) { - throw new ImportError(`Unable to create '${sourceState}' state in '${sourceWITState.workItemTypeRefName}' WIT: ${error}`); - } - } - } - } - - private async importRules(payload: IProcessPayload): Promise { - for (const WITRule of payload.rules) { - for (const rule of WITRule.rules) { - try { - if (!rule.isSystem) { - await this.witProcessApi.addWorkItemTypeRule(rule, payload.process.typeId, WITRule.workItemTypeRefName); - } - } - catch (error) { - throw new ImportError(`Unable to create '${rule}' rule in '${WITRule}' work item type: ${error}`); - } - } - } - } - - private async importBehaviors(payload: IProcessPayload): Promise { - for (const behavior of payload.behaviors) { - try { - if (!behavior.overridden) { - const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = Utility.toCreateBehavior(behavior); - this.witProcessDefinitionApi.createBehavior(createBehavior, payload.process.typeId); - } - else { - const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = Utility.toReplaceBehavior(behavior); - this.witProcessDefinitionApi.replaceBehavior(replaceBehavior, payload.process.typeId, behavior.id); - } - } - catch (error) { - throw new ImportError(`Unable to import behavior ${behavior.name}`); - } - } - } - - private async addBehaviorsToWorkItemTypes(payload: IProcessPayload): Promise { - for (let IWorkItemTypeBehaviors of payload.workItemTypeBehaviors) { - for (let behavior of IWorkItemTypeBehaviors.behaviors) { - try { - if (IWorkItemTypeBehaviors.workItemType.workItemTypeClass === WITProcessDefinitionsInterfaces.WorkItemTypeClass.Custom) { - await this.witProcessDefinitionApi.addBehaviorToWorkItemType(behavior, payload.process.typeId, IWorkItemTypeBehaviors.workItemType.refName); - } - } - catch (error) { - throw new ImportError(`Unable to add ${behavior.behavior.id} field to ${IWorkItemTypeBehaviors.workItemType.refName} WIT: ${error}`); - } - } - } - } - - private async importPicklists(payload: IProcessPayload): Promise { - assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); - - const targetFieldToPicklistId = payload.targetAccountInformation.fieldRefNameToPicklistId; - for (const picklistEntry of payload.witFieldPicklists) { - const targetPicklistId = targetFieldToPicklistId[picklistEntry.fieldRefName]; - if (targetPicklistId && targetPicklistId !== PICKLIST_NO_ACTION) { - // Picklist exists but items not match, update items - let newpicklist: WITProcessDefinitionsInterfaces.PickListModel = {}; - Object.assign(newpicklist, picklistEntry.picklist); - newpicklist.id = targetPicklistId; - try { - const updatedPicklist = await this.witProcessDefinitionApi.updateList(newpicklist, targetPicklistId); - - // validate the updated list matches expectation - if (!updatedPicklist || !updatedPicklist.id) { - throw new ImportError(`[Unexpected] Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, result is emtpy, possibly the picklist does not exist on target collection`); - } - - if (updatedPicklist.items.length !== picklistEntry.picklist.items.length) { - throw new ImportError(`[Unexpected] Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, items number does not match.`); - } - - for (const item of updatedPicklist.items) { - if (!picklistEntry.picklist.items.some(i => i.value === item.value)) { - throw new ImportError(`[Unexpected] Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}' was not successful, item '${item.value}' does not match expected`); - } - } - } - catch (err) { - throw new ImportError(`Error when update picklist '${targetPicklistId} for field '${picklistEntry.fieldRefName}', server error message: '${err.message}'`); - } - } - else if (!targetPicklistId) { - // Target field does not exist we need create picklist to be used when create field. - const createdPicklist = await this.witProcessDefinitionApi.createList(picklistEntry.picklist); - if (!createdPicklist || !createdPicklist.id) { - throw new ImportError(`Create picklist for field ${picklistEntry.fieldRefName} was not successful`); - } - targetFieldToPicklistId[picklistEntry.fieldRefName] = createdPicklist.id; - } - } - } - - private async createComponents(payload: IProcessPayload): Promise { - await this.engine.Task(() => this.importPicklists(payload)); // This must be before field import - await this.engine.Task(() => this.importFields(payload)); - await this.engine.Task(() => this.importWorkItemTypes(payload)); - await this.engine.Task(() => this.addFieldsToWorkItemTypes(payload)); - await this.engine.Task(() => this.importLayouts(payload)); - await this.engine.Task(() => this.importStates(payload)); - await this.engine.Task(() => this.importRules(payload)); - await this.engine.Task(() => this.importBehaviors(payload)); - await this.engine.Task(() => this.addBehaviorsToWorkItemTypes(payload)); - } - - private async validateProcess(payload: IProcessPayload): Promise { - let targetProcesses: WITProcessInterfaces.ProcessModel[]; - try { - targetProcesses = await this.witProcessApi.getProcesses(); - } - catch (error) { - throw new ValidationError("Failed to get processes on target acccount, check account url, token and token permission"); - } - - if (!targetProcesses) { // most likely 404 - throw new ValidationError("Failed to get processes on target acccount, check account url"); - } - - for (const process of targetProcesses) { - if (payload.process.name.toLowerCase() === process.name.toLowerCase()) { - throw new ValidationError("Process with same name or reference name already exists on target account."); - } - } - } - - private async validateFields(payload: IProcessPayload): Promise { - const currentFieldsOnTarget: WITInterfaces.WorkItemField[] = await this.witApi.getFields(); - if (!currentFieldsOnTarget) { // most likely 404 - throw new ImportError("Failed to get fields on target account.") - } - payload.targetAccountInformation.collectionFields = currentFieldsOnTarget; - - for (const sourceField of payload.fields) { - const convertedSrcFieldType: number = Utility.WITProcessToWITFieldType(sourceField.type, sourceField.isIdentity); - const conflictingFields: WITInterfaces.WorkItemField[] = currentFieldsOnTarget.filter(targetField => - ((targetField.referenceName === sourceField.id) || (targetField.name === sourceField.name)) // match by name or reference name - && convertedSrcFieldType !== targetField.type // but with a different type - && (!sourceField.isIdentity || !targetField.isIdentity)); // with exception if both are identity - known issue we export identity field type = string - - if (conflictingFields.length > 0) { - throw new ValidationError(`Field in target Collection conflicts with '${sourceField.name}' field with a diffrent refrence name or type.`); - } - } - } - - private async populatePicklistDictionary(fields: WITInterfaces.WorkItemField[]): Promise> { - const ret: IDictionaryStringTo = {}; - for (const field of fields) { - assert(field.isPicklist || !field.picklistId, "Non picklist field should not have picklist") - if (field.isPicklist && field.picklistId) { - ret[field.referenceName] = await this.witProcessDefinitionApi.getList(field.picklistId); - } - } - return ret; - } - - /** - * Validate picklist and output to payload.targetAccountInformation.fieldRefNameToPicklistId for directions under different case - * 1) Picklist field does not exist -> importPicklists will create picklist and importFields will use the picklist created - * 2) Picklist field exist and items match -> no-op for importPicklists/importFields - * 3) Picklist field exists but items does not match -> if 'overwritePicklist' enabled, importPicklists will update items and importFields will skip - * @param payload - */ - private async validatePicklists(payload: IProcessPayload): Promise { - assert(payload.targetAccountInformation && payload.targetAccountInformation.collectionFields, "[Unexpected] - targetInformation not properly populated"); - - const fieldToPicklistIdMapping = payload.targetAccountInformation.fieldRefNameToPicklistId; // This is output for import picklist/field - const currentTargetFieldToPicklist = await this.populatePicklistDictionary(payload.targetAccountInformation.collectionFields); - - for (const picklistEntry of payload.witFieldPicklists) { - const fieldRefName = picklistEntry.fieldRefName; - const currentTargetPicklist = currentTargetFieldToPicklist[fieldRefName]; - if (currentTargetPicklist) { - // Compare the pick list items - let conflict: boolean; - if (currentTargetPicklist.items.length === picklistEntry.picklist.items.length && !currentTargetPicklist.isSuggested === !picklistEntry.picklist.isSuggested) { - for (const sourceItem of picklistEntry.picklist.items) { - if (currentTargetPicklist.items.filter(targetItem => targetItem.value === sourceItem.value).length !== 1) { - conflict = true; - break; - } - } - } - else { - conflict = true; - } - - if (conflict) { - if (!this.configurationOptions.overwritePicklist) { - throw new ValidationError(`Picklist field ${fieldRefName} exist on target account but have different items than source, set 'overwritePicklist' option to overwrite`); - } - else { - fieldToPicklistIdMapping[fieldRefName] = currentTargetPicklist.id; // We will need to update the picklist later when import picklists - } - } - else { - fieldToPicklistIdMapping[fieldRefName] = PICKLIST_NO_ACTION; // No action needed since picklist values match. - } - } - else { - // No-op, leave payload.targetAccountInformation.fieldRefNameToPicklistId[picklistEntry.fieldRefName] = undefined, which indicates creating new picklist. - } - } - } - - private async validateLayouts(payload: IProcessPayload): Promise { - //TODO: Add validation in future - } - - private async validateBehaviors(payload: IProcessPayload): Promise { - // No validation for behaviors for now - } - - private async preImportValidation(payload: IProcessPayload): Promise { - payload.targetAccountInformation = { - fieldRefNameToPicklistId: {} - }; // set initial value for target account information - - const promises: Promise[] = []; - try { - await this.engine.Task(() => this.validateProcess(payload)); - await this.engine.Task(() => this.validateFields(payload)); - await this.engine.Task(() => this.validatePicklists(payload)); - // Validation above must execute sequentially before the others - promises.push(this.engine.Task(() => this.validateLayouts(payload))); - promises.push(this.engine.Task(() => this.validateBehaviors(payload))); - await Promise.all(promises); - } - catch (error) { - if (error instanceof ValidationError) throw error; - throw (`Pre-import validation has failed: ${error}`); - } - } - - //MAIN IMPORT - public async importProcess(processPayload: IProcessPayload): Promise { - /* NOTE: for offline re-import, must upload process payload: - let processPayload: IProcessPayload = await importer.uploadProcessPayload("./process/processPayload.json"); - await importer.importProcess(processPayload); - */ - await this.getApis(); - try { - if (this.configurationOptions.targetProcessName) { - //TODO: validate process name here right away - processPayload.process.name = this.configurationOptions.targetProcessName; - } - await this.engine.Task(() => this.preImportValidation(processPayload)); - - const createProcessModel: WITProcessInterfaces.CreateProcessModel = Utility.ProcessModelToCreateProcessModel(processPayload.process); - const pm = await this.witProcessApi.createProcess(createProcessModel); - processPayload.process.typeId = pm.typeId; - await this.engine.Task(() => this.createComponents(processPayload)); - } - catch (error) { - if (error instanceof ValidationError) { - console.log("Pre-Import validation failed. No artifacts were copied to the target account.") - } - //TODO: Handle other errors - throw (`Import process error: ${error}`); - } - } -} - -export class ProcessExporter { - private vstsWebApi: vsts.WebApi; - private witProcessApi: WITProcessApi; - private witProcessDefinitionApi: WITProcessDefinitionApi; - private witApi: WITApi; - private engine: Engine; - - constructor(vstsWebApi: vsts.WebApi, private configurationOptions: IUserConfigurationOptions) { - this.vstsWebApi = vstsWebApi; - this.engine = new Engine(configurationOptions); - } - - public async getApis() { - this.witApi = await this.vstsWebApi.getWorkItemTrackingApi(); - this.witProcessApi = await this.vstsWebApi.getWorkItemTrackingProcessApi(); - this.witProcessDefinitionApi = await this.vstsWebApi.getWorkItemTrackingProcessDefinitionApi(); - } - - private async getOptions(): Promise { - let processes: WITProcessInterfaces.ProcessModel[]; - try { - processes = await this.witProcessApi.getProcesses(); - } - catch (error) { - logger.logException(error); - throw new ExportError("Error getting processes on source account - check account url, token and token permission"); //TODO: we need have scope for wit process/processdefinitinos API then - } - if (!processes) { // most likely 404 - throw new ExportError("Failed to get processes on source account, check account url"); - } - - const lowerCaseSourceProcessName = this.configurationOptions.sourceProcessName.toLocaleLowerCase(); - const matchProcesses = processes.filter(p => p.name.toLocaleLowerCase() === lowerCaseSourceProcessName); - if (matchProcesses.length === 0) { - throw new ExportError(`Process '${this.configurationOptions.sourceProcessName}' is not found on source account`); - } - const options: IExportOptions = { processID: matchProcesses[0].typeId, writeToFile: this.configurationOptions.writeToFile } - return options; - } - - private async getComponents(options: IExportOptions): Promise { - let _process: WITProcessInterfaces.ProcessModel; - let _behaviorsCollectionScope: WITProcessDefinitionsInterfaces.BehaviorModel[]; - let _fieldsCollectionScope: WITProcessInterfaces.FieldModel[]; - const _fieldsWorkitemtypeScope: IWITypeFields[] = []; - const _layouts: IWITLayout[] = []; - const _states: IWITStates[] = []; - const _rules: IWITRules[] = []; - const _behaviorsWITypeScope: IWITBehaviors[] = []; - const _picklists: IWITFieldPicklist[] = []; - const knownPicklists: IDictionaryStringTo = {}; - const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = []; - const processPromises: Promise[] = []; - - processPromises.push(this.witProcessApi.getProcessById(options.processID).then(process => _process = process)); - processPromises.push(this.witProcessApi.getFields(options.processID).then(fields => _fieldsCollectionScope = fields)); - processPromises.push(this.witProcessDefinitionApi.getBehaviors(options.processID).then(behaviors => _behaviorsCollectionScope = behaviors)); - processPromises.push(this.witProcessApi.getWorkItemTypes(options.processID).then(workitemtypes => { - const perWitPromises: Promise[] = []; - - for (const workitemtype of workitemtypes) { - const currentWitPromises: Promise[] = []; - - currentWitPromises.push(this.witProcessDefinitionApi.getBehaviorsForWorkItemType(options.processID, workitemtype.id).then(behaviors => { - const witBehaviorsInfo: IWITBehaviorsInfo = { refName: workitemtype.id, workItemTypeClass: workitemtype.class }; - const witBehaviors: IWITBehaviors = { - workItemType: witBehaviorsInfo, - behaviors: behaviors - } - _behaviorsWITypeScope.push(witBehaviors); - })); - perWitPromises.push(Promise.all(currentWitPromises)); - - if (workitemtype.class !== WITProcessInterfaces.WorkItemTypeClass.System) { - _nonSystemWorkItemTypes.push(workitemtype); - - currentWitPromises.push(this.witProcessDefinitionApi.getWorkItemTypeFields(options.processID, workitemtype.id).then(fields => { - const witFields: IWITypeFields = { - workItemTypeRefName: workitemtype.id, - fields: fields - }; - _fieldsWorkitemtypeScope.push(witFields); - - const picklistPromises: Promise[] = []; - for (const field of fields) { - 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) - knownPicklists[field.pickList.id] = true; - picklistPromises.push(this.witProcessDefinitionApi.getList(field.pickList.id).then(picklist => _picklists.push( - { - workitemtypeRefName: workitemtype.id, - fieldRefName: field.referenceName, - picklist: picklist - }))); - } - } - return Promise.all(picklistPromises) - })); - - let layoutForm: WITProcessDefinitionsInterfaces.FormLayout; - currentWitPromises.push(this.witProcessDefinitionApi.getFormLayout(options.processID, workitemtype.id).then(layout => { - const witLayout: IWITLayout = { - workItemTypeRefName: workitemtype.id, - layout: layout - } - _layouts.push(witLayout); - })); - - currentWitPromises.push(this.witProcessDefinitionApi.getStateDefinitions(options.processID, workitemtype.id).then(states => { - const witStates: IWITStates = { - workItemTypeRefName: workitemtype.id, - states: states - } - _states.push(witStates); - })); - - currentWitPromises.push(this.witProcessApi.getWorkItemTypeRules(options.processID, workitemtype.id).then(rules => { - const witRules: IWITRules = { - workItemTypeRefName: workitemtype.id, - rules: rules - } - _rules.push(witRules); - })); - } - } - - return Promise.all(perWitPromises); - })); - - //NOTE: it maybe out of order for per-workitemtype artifacts for different work item types - // for example, you may have Bug and then Feature for 'States' but Feature comes before Bug for 'Rules' - // the order does not matter since we stamp the work item type information - await Promise.all(processPromises); - - const processPayload: IProcessPayload = { - process: _process, - fields: _fieldsCollectionScope, - workItemTypeFields: _fieldsWorkitemtypeScope, - workItemTypes: _nonSystemWorkItemTypes, - layouts: _layouts, - states: _states, - rules: _rules, - behaviors: _behaviorsCollectionScope, - workItemTypeBehaviors: _behaviorsWITypeScope, - witFieldPicklists: _picklists - }; - - return processPayload; - } - - public async exportProcess(): Promise { - logger.logInfo("Export process started"); - try { - await this.getApis(); - } - catch (error) { - logger.logException(error); - throw new ExportError("Failed to connect to source account - check url and token"); - } - - let processPayload: IProcessPayload; - const options: IExportOptions = await this.engine.Task(() => this.getOptions()); - processPayload = await this.engine.Task(() => this.getComponents(options)); - - if (options.writeToFile) { - logger.logVerbose("Writing file: started"); - //TODO: flexible output file name - await writeFileSync("./processPayload.json", JSON.stringify(processPayload, null, 2), { flag: "w" }); - logger.logVerbose("Writing file: finished"); - - } - logger.logInfo("Export Process done."); - return processPayload; - } -} - -async function main() { - - //Load configuration file - if (!existsSync(configurationFilename)) { - console.log(`Cannot find configuration file '${configurationFilename}', we have generated the default configuration and please fill in required information before retry.`); - writeFileSync(configurationFilename, JSON.stringify(defaultConfiguration, null, 2)); - process.exit(1); - } - - //TODO: Native node.js does not support encoding auto detection, there is 3rd party library to do so, wait for feedback - const configuration = JSON.parse(await readFileSync(configurationFilename, defaultEncoding)); - if (!Utility.validateConfiguration(configuration)) { - process.exit(1); - } - - // Initialize logger - logger = new Logger(Utility.getLogFilePath(configuration.options), configuration.options.logLevel ? configuration.options.logLevel : LogLevel.Information); - - // Read configuration and get webApis - const sourceWebApi = Utility.getWebApi(configuration.sourceAccountUrl, configuration.sourceAccountToken); - const targetWebApi = Utility.getWebApi(configuration.targetAccountUrl, configuration.targetAccountToken); - const userOptions = configuration.options as IUserConfigurationOptions; - try { - //TODO: Remove or formalize this - dev only for now - if (userOptions.__cleanupTargetAccount) { - await deleteProcessOnTarget(targetWebApi.getWorkItemTrackingProcessApi(), configuration.options.sourceProcessName); - } - - const exporter: ProcessExporter = new ProcessExporter(sourceWebApi, configuration.options); - const processPayload: IProcessPayload = await exporter.exportProcess(); - - if (userOptions.onlineReImport) { - const importer: ProcessImporter = new ProcessImporter(targetWebApi, configuration.options); - await importer.importProcess(processPayload); - logger.logInfo("Import process has successfully completed."); - } - } - catch (error) { - logger.logException(error); - if (error instanceof ExportError) { - logger.logError(`Export process failed: ${error}`); - } else if (error instanceof ImportError) { - logger.logError(`Import process failed: ${error}`); - } else if (error instanceof ValidationError) { - logger.logError(`Pre-Import validation failed. ${error}`); - } else if (error instanceof CancellationError) { - logger.logError(`User cancelled the operation.`); - } else { - logger.logError(`Hit unknown error, check log for details`) - } - process.exit(1); - } - process.exit(0); -} - -main(); - -//TODO: Clean up -async function deleteProcessOnTarget(wpTgtPromise: Promise, processName: string) { - const wpTgt = await wpTgtPromise; - const processes = await wpTgt.getProcesses(); - processes - .filter(p => p.name.toLocaleLowerCase() === processName.toLocaleLowerCase()) - .map(async p => { - await wpTgt.deleteProcess(p.typeId); - logger.logInfo(`${processName} has been deleted`); - }); -} \ No newline at end of file diff --git a/src/NodeJs/ConfigurationProcessor.ts b/src/NodeJs/ConfigurationProcessor.ts new file mode 100644 index 0000000..145b4a4 --- /dev/null +++ b/src/NodeJs/ConfigurationProcessor.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { normalize } from "path"; +import * as minimist from "minimist"; +import * as url from "url"; +import { defaultConfiguration, defaultConfigurationFilename, defaultEncoding, paramConfig, paramMode, paramOverwriteProcessOnTarget } from "../common/Constants"; +import { IConfigurationFile, LogLevel, Modes, ICommandLineOptions } from "../common/Interfaces"; +import { logger } from "../common/Logger"; +import { Utility } from "../common/Utilities"; +import { parse as jsoncParse } from "jsonc-parser"; + +export function ProcesCommandLine(): ICommandLineOptions { + const parseOptions: minimist.Opts = { + boolean: true, + alias: { + "help": "h", + "mode": "m", + "config": "c" + } + } + const parsedArgs = minimist(process.argv, parseOptions); + + if (parsedArgs["h"]) { + logger.logInfo(`Usage: processMigrator [--mode= [--config=]`); + process.exit(0); + } + + const configFileName = parsedArgs[paramConfig] || normalize(defaultConfigurationFilename); + + const userSpecifiedMode = parsedArgs[paramMode] as string; + let mode; + if (userSpecifiedMode) { + switch (userSpecifiedMode.toLocaleLowerCase()) { + case Modes[Modes.export]: mode = Modes.export; break; + case Modes[Modes.import]: mode = Modes.import; break; + case Modes[Modes.migrate]: mode = Modes.migrate; break; + default: logger.logError(`Invalid mode argument, allowed values are 'import','export' and 'migrate'.`); process.exit(1); + } + } else { + mode = Modes.migrate; + } + + const ret = {}; + ret[paramMode] = mode; + ret[paramConfig] = configFileName; + ret[paramOverwriteProcessOnTarget] = !!parsedArgs[paramOverwriteProcessOnTarget]; + + return ret; +} + +export async function ProcessConfigurationFile(configFilename: string, mode: Modes): Promise { + // Load configuration file + if (!existsSync(configFilename)) { + logger.logError(`Cannot find configuration file '${configFilename}'`); + const normalizedConfiguraitonFilename = normalize(defaultConfigurationFilename); + if (!existsSync(normalizedConfiguraitonFilename)) { + writeFileSync(normalizedConfiguraitonFilename, defaultConfiguration); + logger.logInfo(`Generated configuration file as '${defaultConfigurationFilename}', please fill in required information and retry.`); + } + process.exit(1); + } + + const configuration = jsoncParse(readFileSync(configFilename, defaultEncoding)) as IConfigurationFile; + if (!Utility.validateConfiguration(configuration, mode)) { + process.exit(1); + } + + return configuration; +} + diff --git a/src/NodeJs/FileLogger.ts b/src/NodeJs/FileLogger.ts new file mode 100644 index 0000000..e4b614f --- /dev/null +++ b/src/NodeJs/FileLogger.ts @@ -0,0 +1,55 @@ +import { LogLevel, ILogger } from "../common/Interfaces"; +import { SetLogger } from "../common/Logger"; +import { appendFileSync, existsSync, unlinkSync, mkdirSync } from "fs"; +import { dirname } from "path"; +import { sync as mkdirpSync } from "mkdirp"; + +export class FileLogger implements ILogger { + constructor(private _logFilename: string, private _maxLogLevel: LogLevel) { + if (existsSync(_logFilename)) { + unlinkSync(_logFilename); + } + } + + public logVerbose(message: string) { + this._log(message, LogLevel.verbose); + } + + public logInfo(message: string) { + this._log(message, LogLevel.information); + } + + public logWarning(message: string) { + this._log(message, LogLevel.warning); + } + + public logError(message: string) { + this._log(message, LogLevel.error); + } + + public logException(error: Error) { + if (error instanceof Error) { + this._log(`Exception message:${error.message}\r\nCall stack:${error.stack}`, LogLevel.verbose); + } + else { + this._log(`Unknown exception: ${JSON.stringify(error)}`, LogLevel.verbose); + } + } + + private _log(message: string, logLevel: LogLevel) { + const outputMessage: string = `[${LogLevel[logLevel].toUpperCase()}] [${(new Date(Date.now())).toISOString()}] ${message}`; + if (logLevel <= this._maxLogLevel) { + console.log(outputMessage); + } + + appendFileSync(this._logFilename, `${outputMessage}\r\n`); + } +} + +export function InitializeFileLogger(logFilename: string, maxLogLevel: LogLevel) { + const folder = dirname(logFilename); + if (!existsSync(folder)) { + mkdirpSync(folder); + } + SetLogger(new FileLogger(logFilename, maxLogLevel)); +} \ No newline at end of file diff --git a/src/NodeJs/Main.ts b/src/NodeJs/Main.ts new file mode 100644 index 0000000..a803d5d --- /dev/null +++ b/src/NodeJs/Main.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "fs"; +import { resolve, normalize } from "path"; +import { ProcesCommandLine, ProcessConfigurationFile } from "./ConfigurationProcessor"; +import { defaultEncoding, defaultProcessFilename } from "../common/Constants"; +import { ImportError, KnownError } from "../common/Errors"; +import { IConfigurationOptions, IProcessPayload, LogLevel, Modes } from "../common/Interfaces"; +import { logger } from "../common/Logger"; +import { InitializeFileLogger } from "./FileLogger"; +import { ProcessExporter } from "../common/ProcessExporter"; +import { ProcessImporter } from "../common/ProcessImporter"; +import { Utility } from "../common/Utilities"; +import { Engine } from "../common/Engine"; +import { NodeJsUtility } from "./NodeJsUtilities"; + +async function main() { + const startTime = Date.now(); + + // Parse command line + const commandLineOptions = ProcesCommandLine(); + + // Read configuration file + const configuration = await ProcessConfigurationFile(commandLineOptions.config, commandLineOptions.mode) + + // Initialize logger + const maxLogLevel = configuration.options.logLevel ? LogLevel[configuration.options.logLevel] : LogLevel.information; + const logFile = NodeJsUtility.getLogFilePath(configuration.options); + InitializeFileLogger(logFile, maxLogLevel); + logger.logInfo(`Full log is sent to '${resolve(logFile)}' `) + + // Enable user cancellation + NodeJsUtility.startCancellationListener(); + + const mode = commandLineOptions.mode; + const userOptions = configuration.options as IConfigurationOptions; + try { + // Export + let processPayload: IProcessPayload; + if (mode === Modes.export || mode === Modes.migrate) { + const sourceRestClients = await Engine.Task(() => NodeJsUtility.getRestClients(configuration.sourceAccountUrl, configuration.sourceAccountToken), `Get rest client on source account '${configuration.sourceAccountUrl}'`); + const exporter: ProcessExporter = new ProcessExporter(sourceRestClients, configuration); + processPayload = await exporter.exportProcess(); + + const exportFilename = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename); + await Engine.Task(() => NodeJsUtility.writeJsonToFile(exportFilename, processPayload), "Write process payload to file") + logger.logInfo(`Export process completed successfully to '${resolve(exportFilename)}'.`); + } + + // Import + if (mode === Modes.import || mode == Modes.migrate) { + if (mode === Modes.import) { // Read payload from file instead + const processFileName = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename); + if (!existsSync(processFileName)) { + throw new ImportError(`Process payload file '${processFileName}' does not exist.`) + } + logger.logVerbose(`Start read process payload from '${processFileName}'.`); + processPayload = JSON.parse(readFileSync(processFileName, defaultEncoding)); + logger.logVerbose(`Complete read process payload.`); + } + + const targetRestClients = await Engine.Task(() => NodeJsUtility.getRestClients(configuration.targetAccountUrl, configuration.targetAccountToken), `Get rest client on target account '${configuration.targetAccountUrl}'`); + const importer: ProcessImporter = new ProcessImporter(targetRestClients, configuration, commandLineOptions); + await importer.importProcess(processPayload); + } + } + catch (error) { + if (error instanceof KnownError) { + // Known errors, just log error message + logger.logError(error.message); + } + else { + logger.logException(error); + logger.logError(`Encourntered unkonwn error, check log file for details.`) + } + process.exit(1); + } + + const endTime = Date.now(); + logger.logInfo(`Total elapsed time: '${(endTime - startTime) / 1000}' seconds.`); + process.exit(0); +} + +main(); \ No newline at end of file diff --git a/src/NodeJs/NodeJsUtilities.ts b/src/NodeJs/NodeJsUtilities.ts new file mode 100644 index 0000000..eb8d3d1 --- /dev/null +++ b/src/NodeJs/NodeJsUtilities.ts @@ -0,0 +1,60 @@ +import * as vsts from "vso-node-api/WebApi"; +import { existsSync, writeFileSync } from "fs"; +import { dirname, normalize } from "path"; +import { sync as mkdirpSync } from "mkdirp"; +import * as readline from "readline"; +import { isFunction } from "util"; +import { defaultLogFileName } from "../common/Constants"; +import { IConfigurationOptions, IRestClients } from "../common/Interfaces"; +import { logger } from "../common/Logger"; +import { Utility } from "../common/Utilities"; +import { KnownError } from "../common/Errors"; + +export class NodeJsUtility extends Utility { + + public static async writeJsonToFile(exportFilename: string, payload: Object) { + const folder = dirname(exportFilename); + if (!existsSync(folder)) { + mkdirpSync(folder); + } + await writeFileSync(exportFilename, JSON.stringify(payload, null, 2), { flag: "w" }); + } + + public static startCancellationListener() { + const stdin = process.stdin; + if (!isFunction(stdin.setRawMode)) { + logger.logInfo(`We are running inside a TTY does not support RAW mode, you must cancel operation with CTRL+C`); + return; + } + stdin.setRawMode(true); + readline.emitKeypressEvents(stdin); + stdin.addListener("keypress", this._listener); + logger.logVerbose("Keyboard listener added"); + } + + public static getLogFilePath(options: IConfigurationOptions): string { + return options.logFilename ? options.logFilename : normalize(defaultLogFileName); + } + + public static async getRestClients(accountUrl: string, PAT: string): Promise { + const authHandler = vsts.getPersonalAccessTokenHandler(PAT); + const vstsWebApi = new vsts.WebApi(accountUrl, authHandler); + try { + return { + "witApi": await vstsWebApi.getWorkItemTrackingApi(), + "witProcessApi": await vstsWebApi.getWorkItemTrackingProcessApi(), + "witProcessDefinitionApi": await vstsWebApi.getWorkItemTrackingProcessDefinitionApi(), + } + } + catch (error) { + throw new KnownError(`Failed to connect to account '${accountUrl}' using personal access token '' provided, check url and token.`); + } + } + + private static _listener = (str: string, key: readline.Key) => { + if (key.name.toLocaleLowerCase() === "q") { + logger.logVerbose("Setting isCancelled to true."); + Utility.isCancelled = true; + } + }; +} \ No newline at end of file diff --git a/src/NodeJs/tsconfig.json b/src/NodeJs/tsconfig.json new file mode 100644 index 0000000..6c9c9fe --- /dev/null +++ b/src/NodeJs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "noImplicitAny": false, + "noImplicitThis": true, + "outDir": "../../build/NodeJs", + "preserveConstEnums": true, + "removeComments": true, + "sourceMap": true, + "target": "es2017", // to not transpile async/await, + "module": "commonjs", + "moduleResolution": "node", + "skipLibCheck": true, // to allow requirejs live together with node + }, + "include": [ + "**/*.ts", + "../common/**/*.ts", + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 17826f9..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "noImplicitAny": true, - "outDir": "./build/", - "preserveConstEnums": true, - "removeComments": true, - "sourceMap": true, - "target": "ES5", - "lib": [ - "es2015" - ] - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "node_modules", - "typings/browser.d.ts", - "typings/browser" - ] -} \ No newline at end of file