Bug fixes, refactor for future support of web usage and clean up.
This commit is contained in:
Родитель
352a2ee941
Коммит
ed31152602
|
@ -1,7 +1,3 @@
|
|||
node_modules/*
|
||||
output/*
|
||||
processPayload.json
|
||||
process/*.json
|
||||
src/*.js
|
||||
src/*.js.map
|
||||
build/*
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
src/
|
||||
output/
|
||||
.vscode/
|
||||
build/browser/
|
||||
build/tests/
|
2
.npmrc
2
.npmrc
|
@ -1,2 +0,0 @@
|
|||
registry=https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/
|
||||
always-auth=true
|
|
@ -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": [
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
66
README.md
66
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=<migrate(default)import/export> [--config=<your-configuration-file-path>]`
|
||||
|
||||
**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 <args>`
|
||||
|
||||
**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.
|
||||
##### 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.
|
||||
|
|
|
@ -3,12 +3,12 @@
|
|||
"sourceAccountToken": "<Source account PAT>",
|
||||
"targetAccountUrl": "<Target account url>",
|
||||
"targetAccountToken": "<Target account PAT>",
|
||||
"options" : {
|
||||
"sourceProcessName": "<SourceProcessName>",
|
||||
"targetProcessName": "<Optional, set as empty string to use source process name>",
|
||||
"writeToFile": true,
|
||||
"onlineReImport": true,
|
||||
"overwritePicklist": true,
|
||||
"__cleanupTargetAccount": true
|
||||
"sourceProcessName": "Process name for export, optional in import only mode, required in export/both mode",
|
||||
"targetProcessName<Optional>": "Set to override process name on import, remove <Optional> from param name",
|
||||
"options": {
|
||||
"processFilename<Optional>": "Set to override default export file name, remove <Optional> from param name",
|
||||
"logLevel<Optional>":"Set to override default log level (Information), remove <Optional> from param name",
|
||||
"logFilename<Optional>":"Set to override default log file name, remove <Optional> from param name",
|
||||
"overwritePicklist": false
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
49
package.json
49
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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/// <reference types="vss-web-extension-sdk" />
|
||||
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>(WorkItemTrackingHttpClient);
|
||||
const booleanType = WITInterfaces.FieldType.Boolean;
|
||||
const customPageType = WITProcessDefinitionsInterfaces.PageType.Custom;
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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");
|
|
@ -0,0 +1,15 @@
|
|||
import { CancellationError } from "./Errors";
|
||||
import { logger } from "./Logger";
|
||||
import { Utility } from "./Utilities";
|
||||
|
||||
export class Engine {
|
||||
public static async Task<T>(step: () => Promise<T>, stepName?: string): Promise<T> {
|
||||
if (Utility.didUserCancel()) {
|
||||
throw new CancellationError();
|
||||
}
|
||||
logger.logVerbose(`Begin step '${stepName}'.`);
|
||||
const ret: T = await step();
|
||||
logger.logVerbose(`Finished step '${stepName}'.`);
|
||||
return ret;
|
||||
}
|
||||
}
|
|
@ -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}`);
|
||||
}
|
||||
}
|
|
@ -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<T> {
|
||||
[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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<string> {
|
||||
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<IProcessPayload> {
|
||||
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<boolean> = {};
|
||||
const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = [];
|
||||
const processPromises: Promise<any>[] = [];
|
||||
|
||||
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<any>[] = [];
|
||||
|
||||
for (const workitemtype of workitemtypes) {
|
||||
const currentWitPromises: Promise<any>[] = [];
|
||||
|
||||
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<any>[] = [];
|
||||
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<IProcessPayload> {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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<void> {
|
||||
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<WITProcessDefinitionsInterfaces.FieldModel[]> {
|
||||
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<boolean> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated");
|
||||
|
||||
const targetFieldToPicklistId = payload.targetAccountInformation.fieldRefNameToPicklistId;
|
||||
const processedFieldRefNames: IDictionaryStringTo<boolean> = {};
|
||||
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 = <any>{};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<IDictionaryStringTo<WITProcessDefinitionsInterfaces.PickListModel>> {
|
||||
const ret: IDictionaryStringTo<WITProcessDefinitionsInterfaces.PickListModel> = {};
|
||||
const promises: Promise<any>[] = [];
|
||||
for (const field of fields) {
|
||||
const anyField = <any>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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.");
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
(<any>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<T>(action: () => Promise<T> | T, thrower: () => Error): Promise<T> {
|
||||
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;
|
||||
}
|
|
@ -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": "<Source account url - eg: https://fabrikamSource.visualstudio.com>",
|
||||
"sourceAccountToken": "<Personal access token for source account>",
|
||||
"targetAccountUrl": "<Target account url - eg: https://fabrikamTarget.visualstudio.com>",
|
||||
"targetAccountToken": "<Personal access token for target account, may be same as source>",
|
||||
"options" : {
|
||||
"sourceProcessName": "<Process to import/export - eg: MyAgile>",
|
||||
"writeToFile": true,
|
||||
"onlineReImport": true
|
||||
}
|
||||
};
|
|
@ -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}`);
|
||||
}
|
||||
}
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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=<migrate(default)import/export> [--config=<your-configuration-file-path>]`);
|
||||
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 <ICommandLineOptions>ret;
|
||||
}
|
||||
|
||||
export async function ProcessConfigurationFile(configFilename: string, mode: Modes): Promise<IConfigurationFile> {
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
|
@ -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();
|
|
@ -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<IRestClients> {
|
||||
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 '<omitted>' 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;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
Загрузка…
Ссылка в новой задаче