From ec9938a47536d7142b9382f70f8bf6237dd73eee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:07:57 +0000 Subject: [PATCH 1/4] Initial plan From bda90875f5682053548c9a070ef289078fb6ef5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:26:30 +0000 Subject: [PATCH 2/4] Upgrade dependencies to latest versions with Azure DevOps API breaking change fixes Co-authored-by: MOlausson <7904771+MOlausson@users.noreply.github.com> --- package-lock.json | 449 +++++++++++++++++++++++++++++----- package.json | 18 +- src/common/Interfaces.ts | 4 +- src/common/ProcessExporter.ts | 17 +- src/common/ProcessImporter.ts | 34 +-- src/common/Utilities.ts | 41 ++++ 6 files changed, 459 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab4744d..2f99251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,22 +9,22 @@ "version": "0.9.8", "license": "MIT", "dependencies": { - "azure-devops-node-api": "^6.5.0", - "guid-typescript": "~1.0.8", - "jsonc-parser": "~3.1.0", + "azure-devops-node-api": "^15.1.1", + "guid-typescript": "^1.0.9", + "jsonc-parser": "^3.3.1", "minimist": "~1.2.7", - "mkdirp": "^1.0.4", - "url": "^0.11.0", + "mkdirp": "^3.0.1", + "url": "^0.11.4", "vss-web-extension-sdk": "5.141.0" }, "bin": { "process-migrator": "build/nodejs/nodejs/Main.js" }, "devDependencies": { - "@types/minimist": "^1.2.2", - "@types/mkdirp": "^0.5.2", - "@types/node": "8.10.0", - "typescript": "^2.8.1" + "@types/minimist": "^1.2.5", + "@types/mkdirp": "^1.0.2", + "@types/node": "^20.19.24", + "typescript": "^5.9.3" }, "engines": { "node": ">=8.11.2" @@ -52,16 +52,18 @@ "integrity": "sha512-L/dGG0DdadKj0nsumdvkNonEcHMRe4RflgHEoHFzj1RZ+xuUMayF7+4Jj5pALOD462M/x4cGa9GuadBDiU6nRw==" }, "node_modules/@types/minimist": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", - "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", - "dev": true + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" }, "node_modules/@types/mkdirp": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-0.5.2.tgz", - "integrity": "sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.2.tgz", + "integrity": "sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -72,10 +74,14 @@ "integrity": "sha512-a2yhRIADupQfOFM75v7GfcQQLUxU705+i/xcZ3N/3PK3Xdo31SUfuCUByWPGOHB1e38m7MxTx/D8FPVsJXZKJw==" }, "node_modules/@types/node": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.0.tgz", - "integrity": "sha512-7IGHZQfRfa0bCd7zUBVUGFKFn31SpaLDFfNoCAqkTGQO5JlHC9BwQA/CG9KZlABFxIUtXznyFgechjPQEGrUTg==", - "dev": true + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/q": { "version": "0.0.32", @@ -98,25 +104,221 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" }, "node_modules/azure-devops-node-api": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-6.6.3.tgz", - "integrity": "sha512-94wSu4O6CSSXoqYWg7Rzt2/IqbW2xVNu2qOtx6e7lnXxnDOcAu4eRzi8tgVNHsXTIGOVEsTqgMvGvFThKr9Pig==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-15.1.1.tgz", + "integrity": "sha512-ohL2CY+zRAItKvwkHhefYxjr0Hndu6s8qKwyl0+wL4Ol6c4UrsI3A3G6ZPwwK81c1Ga3dEXjeDg4aKV4hn9loA==", + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "2.1.0" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", "dependencies": { - "os": "0.1.1", - "tunnel": "0.0.4", - "typed-rest-client": "1.0.9", - "underscore": "1.8.3" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", - "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==" + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" }, "node_modules/jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" }, "node_modules/minimist": { "version": "1.2.8", @@ -127,77 +329,188 @@ } }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", "bin": { - "mkdirp": "bin/cmd.js" + "mkdirp": "dist/cjs/src/bin.js" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/os": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/os/-/os-0.1.1.tgz", - "integrity": "sha512-jg06S2xr5De63mLjZVJDf3/k37tpjppr2LR7MUOsxv8XuUCVpCnvbCksXCBcB5gQqQf/K0+87WGTRlAj5q7r1A==" + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">=0.4.x" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tunnel": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz", - "integrity": "sha512-o9QYRJN5WgS8oCtqvwzzcfnzaTnDPr7HpUsQdSXscTyzXbjvl4wSHPTUKOKzEaDeQvOuyRtt3ui+ujM7x7TReQ==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "license": "MIT", "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, "node_modules/typed-rest-client": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.0.9.tgz", - "integrity": "sha512-iOdwgmnP/tF6Qs+oY4iEtCf/3fnCDl7Gy9LGPJ4E3M4Wj3uaSko15FVwbsaBmnBqTJORnXBWVY5306D4HH8oiA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.1.0.tgz", + "integrity": "sha512-Nel9aPbgSzRxfs1+4GoSB4wexCF+4Axlk7OSGVQCMa+4fWcyxIsN/YNmkp0xTT2iQzMD98h8yFLav/cNaULmRA==", + "license": "MIT", "dependencies": { - "tunnel": "0.0.4", - "underscore": "1.8.3" + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "^6.10.3", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + }, + "engines": { + "node": ">= 16.0.0" } }, "node_modules/typescript": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", - "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/underscore": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", - "integrity": "sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg==" + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/url": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", - "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==", + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/vss-web-extension-sdk": { diff --git a/package.json b/package.json index 8cec07e..a729b01 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,18 @@ "author": "Microsoft", "license": "MIT", "devDependencies": { - "@types/minimist": "^1.2.2", - "@types/mkdirp": "^0.5.2", - "@types/node": "8.10.0", - "typescript": "^2.8.1" + "@types/minimist": "^1.2.5", + "@types/mkdirp": "^1.0.2", + "@types/node": "^20.19.24", + "typescript": "^5.9.3" }, "dependencies": { - "azure-devops-node-api": "^6.5.0", - "guid-typescript": "~1.0.8", - "jsonc-parser": "~3.1.0", + "azure-devops-node-api": "^15.1.1", + "guid-typescript": "^1.0.9", + "jsonc-parser": "^3.3.1", "minimist": "~1.2.7", - "mkdirp": "^1.0.4", - "url": "^0.11.0", + "mkdirp": "^3.0.1", + "url": "^0.11.4", "vss-web-extension-sdk": "5.141.0" } } diff --git a/src/common/Interfaces.ts b/src/common/Interfaces.ts index 2a54438..488f537 100644 --- a/src/common/Interfaces.ts +++ b/src/common/Interfaces.ts @@ -53,7 +53,7 @@ export interface IConfigurationOptions { export interface IProcessPayload { process: WITProcessInterfaces.ProcessModel; workItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[]; - fields: WITProcessInterfaces.FieldModel[]; + fields: WITInterfaces.WorkItemField[]; workItemTypeFields: IWITypeFields[]; witFieldPicklists: IWITFieldPicklist[]; layouts: IWITLayout[]; @@ -91,7 +91,7 @@ export interface IWITStates { export interface IWITRules { workItemTypeRefName: string; - rules: WITProcessInterfaces.FieldRuleModel[]; + rules: WITProcessInterfaces.ProcessRule[]; } export interface IWITBehaviors { diff --git a/src/common/ProcessExporter.ts b/src/common/ProcessExporter.ts index 63a5758..96b4c99 100644 --- a/src/common/ProcessExporter.ts +++ b/src/common/ProcessExporter.ts @@ -1,5 +1,6 @@ import * as WITProcessDefinitionsInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces"; import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingProcessInterfaces"; +import * as WITInterfaces from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; import * as vsts_NOREQUIRE from "azure-devops-node-api/WebApi"; import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessDefinitionsApi"; import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "azure-devops-node-api/WorkItemTrackingProcessApi"; @@ -24,7 +25,7 @@ export class ProcessExporter { } private async _getSourceProcessId(): Promise { - const processes = await Utility.tryCatchWithKnownError(() => this._witProcessApi.getProcesses(), + const processes = await Utility.tryCatchWithKnownError(() => this._witProcessApi.getListOfProcesses(), () => new ExportError(`Error getting processes on source account '${this._config.sourceAccountUrl}, check account url, token and token permissions.`)); if (!processes) { // most likely 404 @@ -38,7 +39,7 @@ export class ProcessExporter { } const process = matchProcesses[0]; - if (process.properties.class !== WITProcessInterfaces.ProcessClass.Derived) { + if (process.customizationType === WITProcessInterfaces.CustomizationType.System) { throw new ExportError(`Proces '${this._config.sourceProcessName}' is not a derived process, not supported.`); } return process.typeId; @@ -47,7 +48,7 @@ export class ProcessExporter { private async _getComponents(processId: string): Promise { let _process: WITProcessInterfaces.ProcessModel; let _behaviorsCollectionScope: WITProcessInterfaces.WorkItemBehavior[]; - let _fieldsCollectionScope: WITProcessInterfaces.FieldModel[]; + let _fieldsCollectionScope: WITInterfaces.WorkItemField[]; const _fieldsWorkitemtypeScope: IWITypeFields[] = []; const _layouts: IWITLayout[] = []; const _states: IWITStates[] = []; @@ -58,10 +59,10 @@ export class ProcessExporter { const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = []; const processPromises: Promise[] = []; - processPromises.push(this._witProcessApi.getProcessById(processId).then(process => _process = process)); - processPromises.push(this._witProcessApi.getFields(processId).then(fields => _fieldsCollectionScope = fields)); - processPromises.push(this._witProcessApi.getBehaviors(processId).then(behaviors => _behaviorsCollectionScope = behaviors)); - processPromises.push(this._witProcessApi.getWorkItemTypes(processId).then(workitemtypes => { + processPromises.push(this._witProcessApi.getProcessByItsId(processId).then(process => _process = process)); + processPromises.push(this._witApi.getFields().then(fields => _fieldsCollectionScope = fields)); + processPromises.push(this._witProcessApi.getProcessBehaviors(processId).then(behaviors => _behaviorsCollectionScope = behaviors)); + processPromises.push(this._witProcessDefinitionApi.getWorkItemTypes(processId).then(workitemtypes => { const perWitPromises: Promise[] = []; for (const workitemtype of workitemtypes) { @@ -118,7 +119,7 @@ export class ProcessExporter { _states.push(witStates); })); - currentWitPromises.push(this._witProcessApi.getWorkItemTypeRules(processId, workitemtype.id).then(rules => { + currentWitPromises.push(this._witProcessApi.getProcessWorkItemTypeRules(processId, workitemtype.id).then(rules => { const witRules: IWITRules = { workItemTypeRefName: workitemtype.id, rules: rules diff --git a/src/common/ProcessImporter.ts b/src/common/ProcessImporter.ts index 3d0cc10..6d1ca02 100644 --- a/src/common/ProcessImporter.ts +++ b/src/common/ProcessImporter.ts @@ -68,14 +68,14 @@ export class ProcessImporter { const outputFields: WITProcessDefinitionsInterfaces.FieldModel[] = []; for (const sourceField of payload.fields) { - const fieldExist = fieldsOnTarget.some(targetField => targetField.referenceName === sourceField.id); + const fieldExist = fieldsOnTarget.some(targetField => targetField.referenceName === sourceField.referenceName); if (!fieldExist) { - const createField: WITProcessDefinitionsInterfaces.FieldModel = Utility.WITProcessToWITProcessDefinitionsFieldModel(sourceField); + const createField: WITProcessDefinitionsInterfaces.FieldModel = Utility.WITToWITProcessDefinitionsFieldModel(sourceField); if (sourceField.isIdentity) { createField.type = WITProcessDefinitionsInterfaces.FieldType.Identity; } - if (isPicklistField[sourceField.id]) { - const picklistId = payload.targetAccountInformation.fieldRefNameToPicklistId[sourceField.id]; + if (isPicklistField[sourceField.referenceName]) { + const picklistId = payload.targetAccountInformation.fieldRefNameToPicklistId[sourceField.referenceName]; assert(picklistId !== PICKLIST_NO_ACTION, "[Unexpected] We are creating the field which we found the matching field earlier on collection") createField.pickList = { id: picklistId, @@ -443,13 +443,13 @@ export class ProcessImporter { } } - private async _importWITRule(rule: WITProcessInterfaces.FieldRuleModel, witRulesEntry: IWITRules, payload: IProcessPayload) { + private async _importWITRule(rule: WITProcessInterfaces.ProcessRule, witRulesEntry: IWITRules, payload: IProcessPayload) { try { const createdRule = await Engine.Task( - () => this._witProcessApi.addWorkItemTypeRule(rule, payload.process.typeId, witRulesEntry.workItemTypeRefName), + () => this._witProcessApi.addProcessWorkItemTypeRule(rule, payload.process.typeId, witRulesEntry.workItemTypeRefName), `Create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}'`); - if (!createdRule || !createdRule.id) { + if (!createdRule || !(createdRule as any).id) { throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', server returned empty result or id.`); } } @@ -467,7 +467,7 @@ export class ProcessImporter { private async _importRules(payload: IProcessPayload): Promise { for (const witRulesEntry of payload.rules) { for (const rule of witRulesEntry.rules) { - if (!rule.isSystem) { + if (rule.customizationType !== WITProcessInterfaces.CustomizationType.System) { await this._importWITRule(rule, witRulesEntry, payload); } } @@ -478,7 +478,7 @@ export class ProcessImporter { const behaviorsOnTarget = await Utility.tryCatchWithKnownError( async () => { return await Engine.Task( - () => this._witProcessApi.getBehaviors(payload.process.typeId), + () => this._witProcessApi.getProcessBehaviors(payload.process.typeId), `Get behaviors on target account`); }, () => new ImportError(`Failed to get behaviors on target account.`)); @@ -486,7 +486,7 @@ export class ProcessImporter { for (const behavior of payload.behaviors) { try { - const existing = behaviorsOnTarget.some(b => b.id === behavior.id); + const existing = (behaviorsOnTarget as any[]).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 @@ -635,7 +635,7 @@ export class ProcessImporter { const targetProcesses: WITProcessInterfaces.ProcessModel[] = await Utility.tryCatchWithKnownError(async () => { - return await Engine.Task(() => this._witProcessApi.getProcesses(), `Get processes on target account`); + return await Engine.Task(() => this._witProcessApi.getListOfProcesses(), `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 @@ -663,9 +663,9 @@ export class ProcessImporter { payload.targetAccountInformation.collectionFields = currentFieldsOnTarget; for (const sourceField of payload.fields) { - const convertedSrcFieldType: number = Utility.WITProcessToWITFieldType(sourceField.type, sourceField.isIdentity); + const convertedSrcFieldType: number = sourceField.type; const conflictingFields: WITInterfaces.WorkItemField[] = currentFieldsOnTarget.filter(targetField => - ((targetField.referenceName === sourceField.id) || (targetField.name === sourceField.name)) // match by name or reference name + ((targetField.referenceName === sourceField.referenceName) || (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 @@ -751,11 +751,11 @@ export class ProcessImporter { } private async _deleteProcessOnTarget(targetProcessName: string) { - const processes = await this._witProcessApi.getProcesses(); + const processes = await this._witProcessApi.getListOfProcesses(); for (const process of processes.filter(p => p.name.toLocaleLowerCase() === targetProcessName.toLocaleLowerCase())) { await Utility.tryCatchWithKnownError( async () => await Engine.Task( - () => this._witProcessApi.deleteProcess(process.typeId), + () => this._witProcessApi.deleteProcessById(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?`)); } @@ -764,12 +764,12 @@ export class ProcessImporter { private async _createProcess(payload: IProcessPayload) { const createProcessModel: WITProcessInterfaces.CreateProcessModel = Utility.ProcessModelToCreateProcessModel(payload.process); const createdProcess = await Engine.Task( - () => this._witProcessApi.createProcess(createProcessModel), + () => this._witProcessApi.createNewProcess(createProcessModel), `Create process '${createProcessModel.name}'`); if (!createdProcess) { throw new ImportError(`Failed to create process '${createProcessModel.name}' on target account.`); } - payload.process.typeId = createdProcess.typeId; + payload.process.typeId = (createdProcess as any).typeId; } public async importProcess(payload: IProcessPayload): Promise { diff --git a/src/common/Utilities.ts b/src/common/Utilities.ts index 2b9a110..1c89b27 100644 --- a/src/common/Utilities.ts +++ b/src/common/Utilities.ts @@ -25,6 +25,47 @@ export class Utility { return outField; } + /** Convert from WIT WorkItemField to WITProcessDefinitions FieldModel + * @param workItemField + */ + public static WITToWITProcessDefinitionsFieldModel(workItemField: WITInterfaces.WorkItemField): WITProcessDefinitionsInterfaces.FieldModel { + + let outField: WITProcessDefinitionsInterfaces.FieldModel = { + description: workItemField.description, + id: workItemField.referenceName, + name: workItemField.name, + type: Utility.WITToWITProcessDefinitionsFieldType(workItemField.type, workItemField.isIdentity), + url: workItemField.url, + pickList: null + } + return outField; + } + + /** Convert from WorkItemTracking FieldType to WorkItemTrackingProcessDefinitions FieldType + * @param witFieldType + */ + public static WITToWITProcessDefinitionsFieldType(witFieldType: WITInterfaces.FieldType, fieldIsIdentity: boolean): WITProcessDefinitionsInterfaces.FieldType { + if (fieldIsIdentity) { return WITProcessDefinitionsInterfaces.FieldType.Identity; } + + switch (witFieldType) { + case WITInterfaces.FieldType.String: { return WITProcessDefinitionsInterfaces.FieldType.String; } + case WITInterfaces.FieldType.Integer: { return WITProcessDefinitionsInterfaces.FieldType.Integer; } + case WITInterfaces.FieldType.DateTime: { return WITProcessDefinitionsInterfaces.FieldType.DateTime; } + case WITInterfaces.FieldType.PlainText: { return WITProcessDefinitionsInterfaces.FieldType.PlainText; } + case WITInterfaces.FieldType.Html: { return WITProcessDefinitionsInterfaces.FieldType.Html; } + case WITInterfaces.FieldType.TreePath: { return WITProcessDefinitionsInterfaces.FieldType.TreePath; } + case WITInterfaces.FieldType.History: { return WITProcessDefinitionsInterfaces.FieldType.History; } + case WITInterfaces.FieldType.Double: { return WITProcessDefinitionsInterfaces.FieldType.Double; } + case WITInterfaces.FieldType.Guid: { return WITProcessDefinitionsInterfaces.FieldType.Guid; } + case WITInterfaces.FieldType.Boolean: { return WITProcessDefinitionsInterfaces.FieldType.Boolean; } + case WITInterfaces.FieldType.Identity: { return WITProcessDefinitionsInterfaces.FieldType.Identity; } + case WITInterfaces.FieldType.PicklistInteger: { return WITProcessDefinitionsInterfaces.FieldType.PicklistInteger; } + case WITInterfaces.FieldType.PicklistString: { return WITProcessDefinitionsInterfaces.FieldType.PicklistString; } + case WITInterfaces.FieldType.PicklistDouble: { return WITProcessDefinitionsInterfaces.FieldType.PicklistDouble; } + default: { throw new Error(`Failed to convert from WorkItemTracking.FieldType to WorkItemTrackingProcessDefinitions.FieldType, unrecognized enum value '${witFieldType}'`) } + } + } + /** Convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType * @param witProcessFieldType */ From 08a916fc43e8eb1ee439ea5bfd285384ec1eed11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:32:00 +0000 Subject: [PATCH 3/4] Fix typo and improve type safety by removing type assertions Co-authored-by: MOlausson <7904771+MOlausson@users.noreply.github.com> --- src/common/ProcessExporter.ts | 2 +- src/common/ProcessImporter.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/common/ProcessExporter.ts b/src/common/ProcessExporter.ts index 96b4c99..0ac90fc 100644 --- a/src/common/ProcessExporter.ts +++ b/src/common/ProcessExporter.ts @@ -40,7 +40,7 @@ export class ProcessExporter { const process = matchProcesses[0]; if (process.customizationType === WITProcessInterfaces.CustomizationType.System) { - throw new ExportError(`Proces '${this._config.sourceProcessName}' is not a derived process, not supported.`); + throw new ExportError(`Process '${this._config.sourceProcessName}' is not a derived process, not supported.`); } return process.typeId; } diff --git a/src/common/ProcessImporter.ts b/src/common/ProcessImporter.ts index 6d1ca02..f88c350 100644 --- a/src/common/ProcessImporter.ts +++ b/src/common/ProcessImporter.ts @@ -445,11 +445,11 @@ export class ProcessImporter { private async _importWITRule(rule: WITProcessInterfaces.ProcessRule, witRulesEntry: IWITRules, payload: IProcessPayload) { try { - const createdRule = await Engine.Task( + const createdRule: WITProcessInterfaces.ProcessRule = await Engine.Task( () => this._witProcessApi.addProcessWorkItemTypeRule(rule, payload.process.typeId, witRulesEntry.workItemTypeRefName), `Create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}'`); - if (!createdRule || !(createdRule as any).id) { + 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.`); } } @@ -475,7 +475,7 @@ export class ProcessImporter { } private async _importBehaviors(payload: IProcessPayload): Promise { - const behaviorsOnTarget = await Utility.tryCatchWithKnownError( + const behaviorsOnTarget: WITProcessInterfaces.ProcessBehavior[] = await Utility.tryCatchWithKnownError( async () => { return await Engine.Task( () => this._witProcessApi.getProcessBehaviors(payload.process.typeId), @@ -486,7 +486,7 @@ export class ProcessImporter { for (const behavior of payload.behaviors) { try { - const existing = (behaviorsOnTarget as any[]).some(b => b.id === behavior.id); + const existing = behaviorsOnTarget.some(b => b.referenceName === behavior.id); if (!existing) { const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = Utility.toCreateBehavior(behavior); // Use a random name to avoid conflict on scenarios involving a name swap @@ -763,13 +763,13 @@ export class ProcessImporter { private async _createProcess(payload: IProcessPayload) { const createProcessModel: WITProcessInterfaces.CreateProcessModel = Utility.ProcessModelToCreateProcessModel(payload.process); - const createdProcess = await Engine.Task( + const createdProcess: WITProcessInterfaces.ProcessInfo = await Engine.Task( () => this._witProcessApi.createNewProcess(createProcessModel), `Create process '${createProcessModel.name}'`); if (!createdProcess) { throw new ImportError(`Failed to create process '${createProcessModel.name}' on target account.`); } - payload.process.typeId = (createdProcess as any).typeId; + payload.process.typeId = createdProcess.typeId; } public async importProcess(payload: IProcessPayload): Promise { From 5a1b6d51dff0703467834b9097eb438ef0616df8 Mon Sep 17 00:00:00 2001 From: Mathias Olausson Date: Sun, 7 Dec 2025 18:50:20 +0000 Subject: [PATCH 4/4] More fixes for api compat + copilot updated code comments --- src/common/Constants.ts | 31 ++-- src/common/Engine.ts | 48 ++++++ src/common/Errors.ts | 16 +- src/common/Interfaces.ts | 19 +++ src/common/Logger.ts | 6 +- src/common/ProcessExporter.ts | 19 ++- src/common/ProcessImporter.ts | 162 +++++++++++------- src/common/Utilities.ts | 245 +++++++++++++++++++++++---- src/nodejs/ConfigurationProcessor.ts | 11 +- src/nodejs/FileLogger.ts | 6 + src/nodejs/Main.ts | 24 ++- src/nodejs/NodeJsUtilities.ts | 15 ++ 12 files changed, 481 insertions(+), 121 deletions(-) diff --git a/src/common/Constants.ts b/src/common/Constants.ts index 16ed667..0dbe0d2 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -1,28 +1,35 @@ +// Picklist processing constants export const PICKLIST_NO_ACTION = "PICKLIST_NO_ACTION"; + +// File system constants export const defaultEncoding = "utf-8"; export const defaultConfigurationFilename = "configuration.json"; export const defaultLogFileName = "output\\processMigrator.log"; export const defaultProcessFilename = "output\\process.json"; + +// Command line parameter names export const paramMode = "mode"; export const paramConfig = "config"; export const paramSourceToken = "sourceToken"; export const paramTargetToken = "targetToken"; export const paramOverwriteProcessOnTarget = "overwriteProcessOnTarget"; +// Default configuration template with improved comments 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.", + "sourceAccountUrl": "Required for export/migrate: Azure DevOps organization URL", + "sourceAccountToken": "!!SECURE!! Required for export/migrate: Personal Access Token", + "targetAccountUrl": "Required for import/migrate: Azure DevOps organization URL", + "targetAccountToken": "!!SECURE!! Required for import/migrate: Personal Access Token", + "sourceProcessName": "Required for export/migrate: Name of process to export", + // "targetProcessName": "Optional: Override process name during import/migrate", "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.", + // "processFilename": "Optional: Process definition file path (default: './output/process.json')", + // "logLevel": "Optional: Logging level - Verbose/Information/Warning/Error (default: Information)", + // "logFilename": "Optional: Log file path (default: 'output/processMigrator.log')", + // "overwritePicklist": "Optional: Overwrite existing picklists on target (default: false)", + // "continueOnRuleImportFailure": "Optional: Continue import if rule creation fails (default: false)", + // "skipImportFormContributions": "Optional: Skip importing form contributions (default: false)", } }`; +// Regular expression to remove hyphens from GUIDs export const regexRemoveHypen = new RegExp("-", "g"); \ No newline at end of file diff --git a/src/common/Engine.ts b/src/common/Engine.ts index 655b51f..9bc7a22 100644 --- a/src/common/Engine.ts +++ b/src/common/Engine.ts @@ -2,12 +2,60 @@ import { CancellationError } from "./Errors"; import { logger } from "./Logger"; import { Utility } from "./Utilities"; +/** + * Task execution engine with logging and retry capabilities + */ export class Engine { + private static _config: any = null; + + /** + * Set configuration for retry behavior + */ + public static setConfiguration(config: any) { + Engine._config = config; + } + + /** + * Execute task with optional retry logic and logging + */ public static async Task(step: () => Promise, stepName?: string): Promise { if (Utility.didUserCancel()) { throw new CancellationError(); } logger.logVerbose(`Begin step '${stepName}'.`); + + // Configure retry behavior from options + const options = Engine._config?.options; + const enableRetries = options?.enableRetries !== false; // default: true + const maxRetries = options?.maxRetries || 3; + const retryBaseDelayMs = options?.retryBaseDelayMs || 1000; + + let ret: T; + if (enableRetries) { + // Execute with retry logic for network resilience + ret = await Utility.executeWithRetry( + step, + maxRetries, + retryBaseDelayMs, + stepName || "unknown operation" + ); + } else { + // Execute without retry + ret = await step(); + } + + logger.logVerbose(`Finished step '${stepName}'.`); + return ret; + } + + /** + * Task method without retry logic for operations that should not be retried + */ + public static async TaskNoRetry(step: () => Promise, stepName?: string): Promise { + if (Utility.didUserCancel()) { + throw new CancellationError(); + } + logger.logVerbose(`Begin step '${stepName}'.`); const ret: T = await step(); logger.logVerbose(`Finished step '${stepName}'.`); return ret; diff --git a/src/common/Errors.ts b/src/common/Errors.ts index aa87b9f..49a8429 100644 --- a/src/common/Errors.ts +++ b/src/common/Errors.ts @@ -1,4 +1,6 @@ -// NOTE: We need this intermediate class to use 'instanceof' +/** + * Base class for known/expected errors (required for instanceof checks) + */ export class KnownError extends Error { __proto__: Error; constructor(message?: string) { @@ -10,24 +12,36 @@ export class KnownError extends Error { } } +/** + * Error thrown when user cancels the operation + */ export class CancellationError extends KnownError { constructor() { super("Process import/export cancelled by user input."); } } +/** + * Error thrown during pre-import validation + */ export class ValidationError extends KnownError { constructor(message: string) { super(`Process import validation failed. ${message}`); } } +/** + * Error thrown during import operations + */ export class ImportError extends KnownError { constructor(message: string) { super(`Import failed, see log file for details. ${message}`); } } +/** + * Error thrown during export operations + */ export class ExportError extends KnownError { constructor(message: string) { super(`Export failed, see log file for details. ${message}`); diff --git a/src/common/Interfaces.ts b/src/common/Interfaces.ts index 488f537..bfb5277 100644 --- a/src/common/Interfaces.ts +++ b/src/common/Interfaces.ts @@ -5,6 +5,9 @@ import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi } fro import { IWorkItemTrackingProcessApi as WITProcessApi } from "azure-devops-node-api/WorkItemTrackingProcessApi"; import { IWorkItemTrackingApi as WITApi } from "azure-devops-node-api/WorkItemTrackingApi"; +/** + * Logging levels for console and file output + */ export enum LogLevel { error, warning, @@ -12,6 +15,9 @@ export enum LogLevel { verbose } +/** + * Operation modes for the process migration tool + */ export enum Modes { import, export, @@ -30,6 +36,9 @@ export interface ICommandLineOptions { targetToken?: string; } +/** + * Configuration file structure for process migration + */ export interface IConfigurationFile { sourceProcessName?: string; targetProcessName?: string; @@ -40,6 +49,9 @@ export interface IConfigurationFile { options?: IConfigurationOptions; } +/** + * Optional configuration settings + */ export interface IConfigurationOptions { logLevel?: string; logFilename?: string; @@ -48,8 +60,15 @@ export interface IConfigurationOptions { continueOnRuleImportFailure?: boolean; continueOnIdentityDefaultValueFailure?: boolean; skipImportFormContributions?: boolean; + // Network retry options + maxRetries?: number; + retryBaseDelayMs?: number; + enableRetries?: boolean; } +/** + * Complete process definition including all components and artifacts + */ export interface IProcessPayload { process: WITProcessInterfaces.ProcessModel; workItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[]; diff --git a/src/common/Logger.ts b/src/common/Logger.ts index d1d83a9..de8da2f 100644 --- a/src/common/Logger.ts +++ b/src/common/Logger.ts @@ -1,5 +1,8 @@ import { LogLevel, ILogger } from "./Interfaces"; +/** + * Console-based logger implementation + */ class ConsoleLogger implements ILogger { public logVerbose(message: string) { this._log(message, LogLevel.verbose); @@ -35,8 +38,7 @@ class ConsoleLogger implements ILogger { export var logger: ILogger = new ConsoleLogger(); /** - * DO NOT CALL - This is exposed for other logger implementation - * @param newLogger + * Replace the default logger implementation (internal use only) */ export function SetLogger(newLogger: ILogger) { logger = newLogger; diff --git a/src/common/ProcessExporter.ts b/src/common/ProcessExporter.ts index 0ac90fc..8117aad 100644 --- a/src/common/ProcessExporter.ts +++ b/src/common/ProcessExporter.ts @@ -12,6 +12,9 @@ import { logger } from "./Logger"; import { Engine } from "./Engine"; import { Utility } from "./Utilities"; +/** + * Exports Azure DevOps process definitions and components + */ export class ProcessExporter { private _vstsWebApi: vsts_NOREQUIRE.WebApi; private _witProcessApi: WITProcessApi_NOREQUIRE; @@ -24,6 +27,9 @@ export class ProcessExporter { this._witProcessDefinitionApi = restClients.witProcessDefinitionApi; } + /** + * Get source process ID from configuration + */ private async _getSourceProcessId(): Promise { const processes = await Utility.tryCatchWithKnownError(() => this._witProcessApi.getListOfProcesses(), () => new ExportError(`Error getting processes on source account '${this._config.sourceAccountUrl}, check account url, token and token permissions.`)); @@ -45,6 +51,9 @@ export class ProcessExporter { return process.typeId; } + /** + * Extract all process components and artifacts + */ private async _getComponents(processId: string): Promise { let _process: WITProcessInterfaces.ProcessModel; let _behaviorsCollectionScope: WITProcessInterfaces.WorkItemBehavior[]; @@ -89,7 +98,7 @@ export class ProcessExporter { const picklistPromises: Promise[] = []; for (const field of fields) { - if (field.pickList && !knownPicklists[field.referenceName]) { // Same picklist field may exist in multiple work item types but we only need to export once (At this moment the picklist is still collection-scoped) + if (field.pickList && !knownPicklists[field.referenceName]) { // Export each picklist only once (may be used by multiple work item types) knownPicklists[field.pickList.id] = true; picklistPromises.push(this._witProcessDefinitionApi.getList(field.pickList.id).then(picklist => _picklists.push( { @@ -133,9 +142,8 @@ export class ProcessExporter { 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 + // NOTE: Artifacts may be returned out of order across work item types + // This doesn't affect functionality since each artifact includes work item type information await Promise.all(processPromises); const processPayload: IProcessPayload = { @@ -154,6 +162,9 @@ export class ProcessExporter { return processPayload; } + /** + * Export complete process with all components + */ public async exportProcess(): Promise { logger.logInfo("Export process started."); diff --git a/src/common/ProcessImporter.ts b/src/common/ProcessImporter.ts index f88c350..3f96dcc 100644 --- a/src/common/ProcessImporter.ts +++ b/src/common/ProcessImporter.ts @@ -29,7 +29,7 @@ export class ProcessImporter { private async _importWorkItemTypes(payload: IProcessPayload): Promise { for (const wit of payload.workItemTypes) { if (wit.class === WITProcessInterfaces.WorkItemTypeClass.System) { - //The exported payload should not have exported System WITypes, so fail on import. + // System work item types should not be imported throw new ImportError(`Work item type '${wit.name}' is a system work item type with no modifications, cannot import.`); } else { @@ -43,7 +43,7 @@ export class ProcessImporter { } /** - * This process payload from export and return fields that need create also fix Identity field type and picklist id + * Process export payload and return fields that need to be created, fixing Identity field types and picklist IDs */ private async _getFieldsToCreate(payload: IProcessPayload): Promise { assert(payload.targetAccountInformation && payload.targetAccountInformation.fieldRefNameToPicklistId, "[Unexpected] - targetInformation not properly populated"); @@ -60,7 +60,7 @@ export class ProcessImporter { 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. + // Build lookup to identify picklist fields const isPicklistField: IDictionaryStringTo = {}; for (const e of payload.witFieldPicklists) { isPicklistField[e.fieldRefName] = true; @@ -91,7 +91,9 @@ export class ProcessImporter { return outputFields; } - /**Create fields at a collection scope*/ + /** + * Create fields at collection scope + */ private async _importFields(payload: IProcessPayload): Promise { const fieldsToCreate: WITProcessDefinitionsInterfaces.FieldModel[] = await Engine.Task(() => this._getFieldsToCreate(payload), "Get fields to be created on target process"); @@ -115,12 +117,14 @@ export class ProcessImporter { } } - /**Add fields at a Work Item Type scope*/ + /** + * Add fields at work item type scope + */ private async _addFieldsToWorkItemTypes(payload: IProcessPayload): Promise { for (const entry of payload.workItemTypeFields) { for (const field of entry.fields) { try { - // Make separate call to set default value on identity field allow failover + // Set default value for identity fields separately to allow failover const defaultValue = field.defaultValue; field.defaultValue = field.type === WITProcessDefinitionsInterfaces.FieldType.Identity ? null : defaultValue; @@ -145,7 +149,7 @@ export class ProcessImporter { } 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.`); + 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 configuration file to continue.`); } } } @@ -212,11 +216,11 @@ export class ProcessImporter { } if (page.isContribution && this._config.options.skipImportFormContributions === true) { - // skip import page contriubtion unless user explicitly asks so + // Skip importing page contributions unless explicitly requested return; } - let newPage: WITProcessDefinitionsInterfaces.Page; //The newly created page, contains the pageId required to create groups. + let newPage: WITProcessDefinitionsInterfaces.Page; // Newly created page containing pageId required for group creation const createPage = Utility.toCreatePage(page); const sourcePagesOnTarget = targetLayout.pages.filter(p => p.id === page.id); try { @@ -269,18 +273,18 @@ export class ProcessImporter { let newGroup: WITProcessDefinitionsInterfaces.Group; if (group.isContribution === true && this._config.options.skipImportFormContributions === true) { - // skip import group contriubtion unless user explicitly asks so + // Skip importing group contributions unless explicitly requested continue; } if (group.controls.length !== 0 && group.controls[0].controlType === "HtmlFieldControl") { - //Handle groups with HTML Controls + // Handle groups with HTML controls if (group.inherited) { if (group.overridden) { // No handling on group update since we have done this already in 1st pass const htmlControl = group.controls[0]; if (htmlControl.overridden) { - // If the HTML control is overriden, we must update that as well + // Update overridden HTML control let updatedHtmlControl: WITProcessDefinitionsInterfaces.Control; try { updatedHtmlControl = await Engine.Task( @@ -298,21 +302,21 @@ export class ProcessImporter { } } else { - // no-op since the group is not overriden + // No action needed - group is not overridden } } else { - // special handling for HTML control - we must create a group containing the HTML control at same time. + // HTML controls require creating group and control simultaneously const createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group); createGroup.controls = group.controls; await this._createGroup(createGroup, page, section, witLayout, payload); } } else { - //Groups with no HTML Controls + // Groups without HTML controls if (!group.inherited) { - //create the group if it's not inherited + // Create non-inherited groups const createGroup = Utility.toCreateGroup(group); newGroup = await this._createGroup(createGroup, page, section, witLayout, payload); group.id = newGroup.id; @@ -330,13 +334,13 @@ export class ProcessImporter { if (control.inherited) { if (control.overridden) { - //edit + // Edit overridden inherited control 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 + // Create new control 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}'.`); } @@ -353,9 +357,9 @@ export class ProcessImporter { } private async _importLayouts(payload: IProcessPayload): Promise { - /** Notes: - * HTML controls need to be created at the same tme as the group they are in. - * Non HTML controls need to be added 1 by 1 after the group they are in has been created. + /* + * HTML controls must be created simultaneously with their containing group. + * Non-HTML controls are added individually after their group is created. */ for (const witLayoutEntry of payload.layouts) { const targetLayout: WITProcessDefinitionsInterfaces.FormLayout = await Engine.Task( @@ -396,7 +400,7 @@ export class ProcessImporter { } } else { - if (sourceState.hidden) { // if state exists on target, only update if hidden + if (sourceState.hidden) { // Only update existing states if they need to be 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`); @@ -407,7 +411,7 @@ export class ProcessImporter { 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. + // Update inherited states 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`); @@ -486,28 +490,48 @@ export class ProcessImporter { for (const behavior of payload.behaviors) { try { - const existing = behaviorsOnTarget.some(b => b.referenceName === behavior.id); + // Extract behavior ID from various possible API structures + const behaviorId = behavior.id || (behavior as any).referenceName || (behavior as any).behaviorId || behavior.name; + + // Skip behaviors with invalid IDs + if (!behaviorId || behaviorId === 'undefined' || behaviorId.trim() === '') { + logger.logWarning(`Skipping behavior with invalid ID: ${JSON.stringify({id: behavior.id, referenceName: (behavior as any).referenceName, name: behavior.name})}`); + continue; + } + + // Get the correct behavior reference name for comparison + const behaviorRefName = behaviorId; + const existing = behaviorsOnTarget.some(b => b.referenceName === behaviorRefName || b.referenceName === behaviorId); + 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); + + // Log behavior creation details for debugging + logger.logVerbose(`Creating behavior: id='${behaviorId}', name='${behavior.name}', inherits='${createBehavior.inherits}', color='${createBehavior.color}', referenceName='${(behavior as any).referenceName}'`); + + // Validate parent behavior ID is present + if (!createBehavior.inherits || createBehavior.inherits.trim() === '') { + logger.logWarning(`Behavior '${behavior.name}' has empty or undefined parent behavior ID. Original inherits: ${JSON.stringify(behavior.inherits)}`); + throw new ImportError(`Cannot create behavior '${behavior.name}' because parent behavior ID is missing or empty. This may be due to Azure DevOps API changes.`); + } + + // Store behavior for final name update (use behaviorId instead of behavior.id) + behaviorIdToRealNameBehavior[behaviorId] = 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) { + `Create behavior '${behaviorId}' with temporary name`); + if (!createdBehavior || createdBehavior.id !== behaviorId) { 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.`) + // Skip existing behaviors - Azure DevOps API v15 no longer supports behavior replacement via PUT + logger.logVerbose(`Behavior '${behaviorId}' already exists on target, skipping replacement (API v15 limitation)`); + // Only store for name update if the behavior needs renaming + if (behavior.name && behavior.name !== behaviorId) { + behaviorIdToRealNameBehavior[behaviorId] = Utility.toReplaceBehavior(behavior); } } } @@ -517,14 +541,24 @@ export class ProcessImporter { } } - // Recover the behavior names to what they should be + // Restore behavior names to their correct values 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.`) + try { + const replacedBehavior = await Engine.Task( + () => this._witProcessDefinitionApi.replaceBehavior(behaviorWithRealName, payload.process.typeId, id), + `Replace behavior '${id}' to its real name '${behaviorWithRealName.name}'`); + if (!replacedBehavior) { + logger.logWarning(`Could not restore name for behavior '${id}' - this may be expected for existing behaviors in API v15`); + } + } catch (error: any) { + // Azure DevOps API v15 may not support behavior name updates for existing behaviors + if (error.message && error.message.includes('PUT')) { + logger.logWarning(`Behavior name update not supported for '${id}' (API v15 limitation): ${error.message}`); + } else { + logger.logException(error); + throw new ImportError(`Failed to restore behavior name for '${id}', see logs for details.`); + } } } } @@ -558,12 +592,12 @@ export class ProcessImporter { const processedFieldRefNames: IDictionaryStringTo = {}; for (const picklistEntry of payload.witFieldPicklists) { if (processedFieldRefNames[picklistEntry.fieldRefName] === true) { - continue; // Skip since we already processed the field, it might be referenced by different work item type + continue; // Skip already processed fields (may be referenced by multiple work item types) } const targetPicklistId = targetFieldToPicklistId[picklistEntry.fieldRefName]; if (targetPicklistId && targetPicklistId !== PICKLIST_NO_ACTION) { - // Picklist exists but items not match, update items + // Update existing picklist with mismatched items let newpicklist: WITProcessDefinitionsInterfaces.PickListModel = {}; Object.assign(newpicklist, picklistEntry.picklist); newpicklist.id = targetPicklistId; @@ -572,7 +606,7 @@ export class ProcessImporter { () => this._witProcessDefinitionApi.updateList(newpicklist, targetPicklistId), `Update picklist '${targetPicklistId}' for field '${picklistEntry.fieldRefName}'`); - // validate the updated list matches expectation + // Validate updated list meets expectations 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`); } @@ -593,8 +627,8 @@ export class ProcessImporter { } } 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 + // Create picklist for fields that don't exist on target + picklistEntry.picklist.name = `picklist_${Guid.create()}`; // Avoid naming conflicts try { const createdPicklist = await Engine.Task( () => this._witProcessDefinitionApi.createList(picklistEntry.picklist), @@ -617,7 +651,7 @@ export class ProcessImporter { } private async _createComponents(payload: IProcessPayload): Promise { - await Engine.Task(() => this._importPicklists(payload), "Import picklists on target account"); // This must be before field import + await Engine.Task(() => this._importPicklists(payload), "Import picklists on target account"); // Must execute 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"); @@ -629,17 +663,22 @@ export class ProcessImporter { } private async _validateProcess(payload: IProcessPayload): Promise { - if (payload.process.properties.class != WITProcessInterfaces.ProcessClass.Derived) { + // Verify process supports inheritance (additional safety check) + if (payload.process.properties && payload.process.properties.class === WITProcessInterfaces.ProcessClass.System) { + throw new ValidationError("Only inherited process is supported to be imported."); + } + // Alternative check for different API property structures + if ((payload.process as any).customizationType === WITProcessInterfaces.CustomizationType.System) { 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.getListOfProcesses(), `Get processes on target account`); - }, () => new ValidationError("Failed to get processes on target acccount, check account url, token and token permission.")); + }, () => new ValidationError("Failed to get processes on target account, 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."); + throw new ValidationError("Failed to get processes on target account, check account url."); } for (const process of targetProcesses) { @@ -665,9 +704,9 @@ export class ProcessImporter { for (const sourceField of payload.fields) { const convertedSrcFieldType: number = sourceField.type; const conflictingFields: WITInterfaces.WorkItemField[] = currentFieldsOnTarget.filter(targetField => - ((targetField.referenceName === sourceField.referenceName) || (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 + ((targetField.referenceName === sourceField.referenceName) || (targetField.name === sourceField.name)) // Match by name or reference + && convertedSrcFieldType !== targetField.type // Different field type + && (!sourceField.isIdentity || !targetField.isIdentity)); // Exception for identity fields (known export issue) if (conflictingFields.length > 0) { throw new ValidationError(`Field in target Collection conflicts with '${sourceField.name}' field with a different reference name or type.`); @@ -690,11 +729,10 @@ export class ProcessImporter { } /** - * 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 + * Validate picklists and populate targetAccountInformation.fieldRefNameToPicklistId mapping: + * 1) Field doesn't exist -> create picklist, then field + * 2) Field exists with matching items -> no action needed + * 3) Field exists with different items -> update if 'overwritePicklist' enabled */ private async _validatePicklists(payload: IProcessPayload): Promise { assert(payload.targetAccountInformation && payload.targetAccountInformation.collectionFields, "[Unexpected] - targetInformation not properly populated"); @@ -706,7 +744,7 @@ export class ProcessImporter { const fieldRefName = picklistEntry.fieldRefName; const currentTargetPicklist = currentTargetFieldToPicklist[fieldRefName]; if (currentTargetPicklist) { - // Compare the pick list items + // Compare picklist items for conflicts let conflict: boolean; if (currentTargetPicklist.items.length === picklistEntry.picklist.items.length && !currentTargetPicklist.isSuggested === !picklistEntry.picklist.isSuggested) { for (const sourceItem of picklistEntry.picklist.items) { @@ -741,9 +779,9 @@ export class ProcessImporter { private async _preImportValidation(payload: IProcessPayload): Promise { payload.targetAccountInformation = { fieldRefNameToPicklistId: {} - }; // set initial value for target account information + }; // Initialize target account information - if (!this._commandLineOptions.overwriteProcessOnTarget) { // only validate if we are not cleaning up target + if (!this._commandLineOptions.overwriteProcessOnTarget) { // Skip validation if overwriting target await Engine.Task(() => this._validateProcess(payload), "Validate process existence on target account"); } await Engine.Task(() => this._validateFields(payload), "Validate fields on target account"); diff --git a/src/common/Utilities.ts b/src/common/Utilities.ts index 1c89b27..5514ddd 100644 --- a/src/common/Utilities.ts +++ b/src/common/Utilities.ts @@ -4,13 +4,12 @@ import * as WITProcessInterfaces from "azure-devops-node-api/interfaces/WorkItem import { KnownError } from "./Errors"; import { logger } from "./Logger"; import { Modes, IConfigurationFile, LogLevel, ICommandLineOptions } 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 + /** + * Convert WITProcess FieldModel to WITProcessDefinitions FieldModel */ public static WITProcessToWITProcessDefinitionsFieldModel(fieldModel: WITProcessInterfaces.FieldModel): WITProcessDefinitionsInterfaces.FieldModel { @@ -25,8 +24,8 @@ export class Utility { return outField; } - /** Convert from WIT WorkItemField to WITProcessDefinitions FieldModel - * @param workItemField + /** + * Convert WIT WorkItemField to WITProcessDefinitions FieldModel */ public static WITToWITProcessDefinitionsFieldModel(workItemField: WITInterfaces.WorkItemField): WITProcessDefinitionsInterfaces.FieldModel { @@ -41,8 +40,8 @@ export class Utility { return outField; } - /** Convert from WorkItemTracking FieldType to WorkItemTrackingProcessDefinitions FieldType - * @param witFieldType + /** + * Convert WorkItemTracking FieldType to WorkItemTrackingProcessDefinitions FieldType */ public static WITToWITProcessDefinitionsFieldType(witFieldType: WITInterfaces.FieldType, fieldIsIdentity: boolean): WITProcessDefinitionsInterfaces.FieldType { if (fieldIsIdentity) { return WITProcessDefinitionsInterfaces.FieldType.Identity; } @@ -66,8 +65,8 @@ export class Utility { } } - /** Convert from WorkItemTrackingProcess FieldType to WorkItemTracking FieldType - * @param witProcessFieldType + /** + * Convert WorkItemTrackingProcess FieldType to WorkItemTracking FieldType */ public static WITProcessToWITFieldType(witProcessFieldType: number, fieldIsIdentity: boolean): number { if (fieldIsIdentity) { return WITInterfaces.FieldType.Identity; } @@ -91,22 +90,37 @@ export class Utility { } } - /**Convert process from ProcessModel to CreateProcessModel - * @param processModel - */ + /** + * Convert ProcessModel to CreateProcessModel + */ public static ProcessModelToCreateProcessModel(processModel: WITProcessInterfaces.ProcessModel): WITProcessInterfaces.CreateProcessModel { + // Try to get parentProcessTypeId from different possible locations due to API changes + let parentProcessTypeId: string | undefined; + + if (processModel.properties && processModel.properties.parentProcessTypeId) { + parentProcessTypeId = processModel.properties.parentProcessTypeId; + } else if ((processModel as any).parentProcessTypeId) { + parentProcessTypeId = (processModel as any).parentProcessTypeId; + } else if ((processModel as any).parentTypeId) { + parentProcessTypeId = (processModel as any).parentTypeId; + } + + if (!parentProcessTypeId) { + throw new Error(`Unable to determine parent process type ID for process '${processModel.name}'. This may be due to Azure DevOps API changes.`); + } + const createModel: WITProcessInterfaces.CreateProcessModel = { description: processModel.description, name: processModel.name, - parentProcessTypeId: processModel.properties.parentProcessTypeId, + parentProcessTypeId: 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 - */ + /** + * Convert layout group to WITProcessDefinitions Group + */ public static toCreateGroup(group: WITProcessDefinitionsInterfaces.Group): WITProcessDefinitionsInterfaces.Group { let createGroup: WITProcessDefinitionsInterfaces.Group = { id: group.id, @@ -123,9 +137,9 @@ export class Utility { return createGroup; } - /**Convert control from getLayout control interface to WITProcessDefinitionsInterfaces.Control - * @param control - */ + /** + * Convert layout control to WITProcessDefinitions Control + */ public static toCreateControl(control: WITProcessDefinitionsInterfaces.Control): WITProcessDefinitionsInterfaces.Control { let createControl: WITProcessDefinitionsInterfaces.Control = { id: control.id, @@ -145,8 +159,8 @@ export class Utility { return createControl; } - /**Convert page from getLayout page interface to WITProcessDefinitionsInterfaces.Page - * @param control + /** + * Convert layout page to WITProcessDefinitions Page */ public static toCreatePage(page: WITProcessDefinitionsInterfaces.Page): WITProcessDefinitionsInterfaces.Page { let createPage: WITProcessDefinitionsInterfaces.Page = { @@ -165,9 +179,9 @@ export class Utility { return createPage; } - /**Convert a state result to state input - * @param group - */ + /** + * Convert state result model to state input model + */ public static toCreateOrUpdateStateDefinition(state: WITProcessInterfaces.WorkItemStateResultModel): WITProcessDefinitionsInterfaces.WorkItemStateInputModel { const updateState: WITProcessDefinitionsInterfaces.WorkItemStateInputModel = { color: state.color, @@ -178,17 +192,97 @@ export class Utility { return updateState; } + /** + * Convert WorkItemBehavior to BehaviorCreateModel with Azure DevOps API compatibility fixes + */ public static toCreateBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorCreateModel { + // Extract parent behavior ID from various API property structures + let inheritsId: string | undefined; + + if (behavior.inherits && behavior.inherits.id) { + inheritsId = behavior.inherits.id; + } else if (behavior.inherits && (behavior.inherits as any).behaviorRefName) { + // New API structure: inherits.behaviorRefName + inheritsId = (behavior.inherits as any).behaviorRefName; + } else if ((behavior as any).inheritsId) { + inheritsId = (behavior as any).inheritsId; + } else if ((behavior as any).parentId) { + inheritsId = (behavior as any).parentId; + } + + // Apply behavior-specific parent overrides for API validation + const behaviorRefName = (behavior as any).referenceName || ''; + + // Force correct parent for known problematic behaviors + if (behaviorRefName === 'System.RequirementBacklogBehavior') { + inheritsId = 'System.PortfolioBacklogBehavior'; + } + + // Determine parent behavior when not explicitly set + if (!inheritsId) { + const behaviorName = behavior.name ? behavior.name.toLowerCase() : ''; + const behaviorRefName = (behavior as any).referenceName || ''; + + // Requirement behaviors must inherit from portfolio behaviors + if (behaviorRefName === 'System.RequirementBacklogBehavior' || + (behaviorName === 'stories' && behaviorRefName.includes('Requirement'))) { + inheritsId = 'System.PortfolioBacklogBehavior'; + } + // Portfolio-level behaviors (Features, Epics) + else if (behaviorName.includes('portfolio') || + behaviorName.includes('epic') || + behaviorName.includes('feature') || + behaviorRefName.includes('Portfolio')) { + inheritsId = 'System.PortfolioBacklogBehavior'; + } + // Requirement-level behaviors (User Stories, Product Backlog Items) + else if (behaviorName.includes('user story') || + behaviorName.includes('product backlog') || + behaviorName.includes('requirement') || + behaviorRefName.includes('RequirementBacklog')) { + inheritsId = 'System.RequirementBacklogBehavior'; + } + // Task-level behaviors (Tasks, Bugs, Issues) + else if (behaviorName.includes('task') || + behaviorName.includes('bug') || + behaviorName.includes('issue') || + behaviorRefName.includes('Task')) { + inheritsId = 'System.TaskBacklogBehavior'; + } + // Default fallback for unknown behaviors + else { + inheritsId = 'System.BacklogBehavior'; + } + } + + // Extract behavior ID from various API property structures + let behaviorId = behavior.id; + if (!behaviorId || behaviorId === 'undefined' || behaviorId.trim() === '') { + // Try alternative property names for behavior ID + behaviorId = (behavior as any).referenceName || + (behavior as any).behaviorId || + (behavior as any).refName || + behavior.name; // Use name as fallback + } + + // Final validation - ensure we have a valid behavior ID + if (!behaviorId || behaviorId === 'undefined' || behaviorId.trim() === '') { + throw new Error(`Cannot create behavior '${behavior.name}' - no valid ID found. API structure may have changed.`); + } + const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = { color: behavior.color, - inherits: behavior.inherits.id, + inherits: inheritsId, name: behavior.name }; - // TODO: Move post S135 when generated model has id. - (createBehavior).id = behavior.id; + // TODO: Remove when generated model includes id property + (createBehavior).id = behaviorId; return createBehavior; } + /** + * Convert WorkItemBehavior to BehaviorReplaceModel + */ public static toReplaceBehavior(behavior: WITProcessInterfaces.WorkItemBehavior): WITProcessDefinitionsInterfaces.BehaviorReplaceModel { const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = { color: behavior.color, @@ -197,11 +291,31 @@ export class Utility { return replaceBehavior; } + /** + * Validates if a string is a valid URL with a host + * @param urlString The URL string to validate + * @returns true if the URL is valid and has a host, false otherwise + */ + public static isValidUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return !!url.host; + } catch { + return false; + } + } + + /** + * Handle known errors by re-throwing, otherwise log exception + */ public static handleKnownError(error: any) { if (error instanceof KnownError) { throw error; } logger.logException(error); } + /** + * Execute action with known error handling + */ public static async tryCatchWithKnownError(action: () => Promise | T, thrower: () => Error): Promise { try { return await action(); @@ -212,9 +326,12 @@ export class Utility { } } + /** + * Validate configuration file settings for the specified mode + */ public static validateConfiguration(configuration: IConfigurationFile, mode: Modes): boolean { if (mode === Modes.export || mode === Modes.migrate) { - if (!configuration.sourceAccountUrl || !url.parse(configuration.sourceAccountUrl).host) { + if (!configuration.sourceAccountUrl || !Utility.isValidUrl(configuration.sourceAccountUrl)) { logger.logError(`[Configuration validation] Missing or invalid source account url: '${configuration.sourceAccountUrl}'.`); return false; } @@ -229,7 +346,7 @@ export class Utility { } if (mode === Modes.import || mode === Modes.migrate) { - if (!configuration.targetAccountUrl || !url.parse(configuration.targetAccountUrl).host) { + if (!configuration.targetAccountUrl || !Utility.isValidUrl(configuration.targetAccountUrl)) { logger.logError(`[Configuration validation] Missing or invalid target account url: '${configuration.targetAccountUrl}'.`); return false; } @@ -260,16 +377,86 @@ export class Utility { return false; } + // Validate retry configuration + if (configuration.options && configuration.options.enableRetries !== undefined && (configuration.options.enableRetries !== true && configuration.options.enableRetries !== false)) { + logger.logError(`[Configuration validation] Option 'enableRetries' is not a valid boolean.`); + return false; + } + if (configuration.options && configuration.options.maxRetries !== undefined && (typeof configuration.options.maxRetries !== 'number' || configuration.options.maxRetries < 0 || configuration.options.maxRetries > 10)) { + logger.logError(`[Configuration validation] Option 'maxRetries' must be a number between 0 and 10.`); + return false; + } + if (configuration.options && configuration.options.retryBaseDelayMs !== undefined && (typeof configuration.options.retryBaseDelayMs !== 'number' || configuration.options.retryBaseDelayMs < 100 || configuration.options.retryBaseDelayMs > 30000)) { + logger.logError(`[Configuration validation] Option 'retryBaseDelayMs' must be a number between 100 and 30000 milliseconds.`); + return false; + } + return true; } + /** + * Check if user has cancelled the operation + */ public static didUserCancel(): boolean { return Utility.isCancelled; } + /** + * Generate GUID string without hyphens + */ public static createGuidWithoutHyphen(): string { return Guid.create().toString().replace(regexRemoveHypen, ""); } + /** + * Executes a function with retry logic for network timeout errors + * @param fn The function to execute + * @param maxRetries Maximum number of retries (default: 3) + * @param baseDelayMs Base delay in milliseconds between retries (default: 1000) + * @param operation Description of the operation for logging + * @returns Promise resolving to the function result + */ + public static async executeWithRetry( + fn: () => Promise, + maxRetries: number = 3, + baseDelayMs: number = 1000, + operation: string = "operation" + ): Promise { + let lastError: any; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error: any) { + lastError = error; + + // Check if this is a network timeout error that we should retry + const isTimeoutError = error && ( + error.code === 'ETIMEDOUT' || + error.code === 'ECONNRESET' || + error.code === 'ECONNREFUSED' || + (error.message && error.message.includes('ETIMEDOUT')) || + (error.name === 'AggregateError' && error.message && error.message.includes('ETIMEDOUT')) + ); + + // If not a timeout error or we've exhausted retries, throw the error + if (!isTimeoutError || attempt === maxRetries) { + throw error; + } + + // Calculate delay with exponential backoff and jitter + const delay = baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000; + + logger.logWarning(`${operation} failed with timeout error (attempt ${attempt + 1}/${maxRetries + 1}). Retrying in ${Math.round(delay)}ms...`); + logger.logVerbose(`Timeout error details: ${error.message || error}`); + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } + protected static isCancelled = false; } diff --git a/src/nodejs/ConfigurationProcessor.ts b/src/nodejs/ConfigurationProcessor.ts index 64c22f0..50ee975 100644 --- a/src/nodejs/ConfigurationProcessor.ts +++ b/src/nodejs/ConfigurationProcessor.ts @@ -2,12 +2,15 @@ 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, paramSourceToken, paramTargetToken } from "../common/Constants"; +import { defaultConfiguration, defaultConfigurationFilename, defaultEncoding, paramConfig, paramMode, paramSourceToken, paramTargetToken, 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"; +/** + * Parse command line arguments and return options + */ export function ProcesCommandLine(): ICommandLineOptions { const parseOptions: minimist.Opts = { boolean: true, @@ -46,10 +49,14 @@ export function ProcesCommandLine(): ICommandLineOptions { ret[paramConfig] = configFileName; ret[paramSourceToken] = parsedArgs[paramSourceToken]; ret[paramTargetToken] = parsedArgs[paramTargetToken]; + ret[paramOverwriteProcessOnTarget] = parsedArgs[paramOverwriteProcessOnTarget] || false; return ret; } +/** + * Load and validate configuration file + */ export async function ProcessConfigurationFile(commandLineOptions: ICommandLineOptions): Promise { // Load configuration file const configFile = commandLineOptions.config; @@ -65,7 +72,7 @@ export async function ProcessConfigurationFile(commandLineOptions: ICommandLineO const configuration = jsoncParse(readFileSync(configFile, defaultEncoding)) as IConfigurationFile; - // replace token if overriden from command line + // Override tokens from command line if specified configuration.sourceAccountToken = commandLineOptions.sourceToken ? commandLineOptions.sourceToken : configuration.sourceAccountToken; configuration.targetAccountToken = commandLineOptions.targetToken ? commandLineOptions.targetToken : configuration.targetAccountToken; diff --git a/src/nodejs/FileLogger.ts b/src/nodejs/FileLogger.ts index e4b614f..50d03b8 100644 --- a/src/nodejs/FileLogger.ts +++ b/src/nodejs/FileLogger.ts @@ -4,6 +4,9 @@ import { appendFileSync, existsSync, unlinkSync, mkdirSync } from "fs"; import { dirname } from "path"; import { sync as mkdirpSync } from "mkdirp"; +/** + * File-based logger with console output filtering + */ export class FileLogger implements ILogger { constructor(private _logFilename: string, private _maxLogLevel: LogLevel) { if (existsSync(_logFilename)) { @@ -46,6 +49,9 @@ export class FileLogger implements ILogger { } } +/** + * Initialize file logger and set as default logger + */ export function InitializeFileLogger(logFilename: string, maxLogLevel: LogLevel) { const folder = dirname(logFilename); if (!existsSync(folder)) { diff --git a/src/nodejs/Main.ts b/src/nodejs/Main.ts index 1054ce7..754fdf1 100644 --- a/src/nodejs/Main.ts +++ b/src/nodejs/Main.ts @@ -12,16 +12,19 @@ import { ProcessImporter } from "../common/ProcessImporter"; import { Engine } from "../common/Engine"; import { NodeJsUtility } from "./NodeJsUtilities"; +/** + * Main entry point for process migration tool + */ async function main() { const startTime = Date.now(); - // Parse command line + // Parse command line arguments const commandLineOptions = ProcesCommandLine(); - // Read configuration file + // Load and process configuration const configuration = await ProcessConfigurationFile(commandLineOptions) - // Overwrite token if specified on command line + // Override tokens from command line if provided if (commandLineOptions.sourceToken) { configuration.sourceAccountToken = commandLineOptions.sourceToken; } @@ -30,19 +33,22 @@ async function main() { configuration.targetAccountToken = commandLineOptions.targetToken; } - // Initialize logger + // Setup logging and configuration 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)}' `) + // Configure Engine with retry settings + Engine.setConfiguration(configuration); + // Enable user cancellation NodeJsUtility.startCancellationListener(); const mode = commandLineOptions.mode; const userOptions = configuration.options as IConfigurationOptions; try { - // Export + // Execute export phase if needed 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}'`); @@ -54,9 +60,9 @@ async function main() { logger.logInfo(`Export process completed successfully to '${resolve(exportFilename)}'.`); } - // Import + // Execute import phase if needed if (mode === Modes.import || mode == Modes.migrate) { - if (mode === Modes.import) { // Read payload from file instead + if (mode === Modes.import) { // Load payload from file for import-only mode const processFileName = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename); if (!existsSync(processFileName)) { throw new ImportError(`Process payload file '${processFileName}' does not exist.`) @@ -73,12 +79,12 @@ async function main() { } catch (error) { if (error instanceof KnownError) { - // Known errors, just log error message + // Expected errors - log message only logger.logError(error.message); } else { logger.logException(error); - logger.logError(`Encountered unkonwn error, check log file for details.`) + logger.logError(`Encountered unknown error, check log file for details.`) } process.exit(1); } diff --git a/src/nodejs/NodeJsUtilities.ts b/src/nodejs/NodeJsUtilities.ts index 2b79dd1..22a4919 100644 --- a/src/nodejs/NodeJsUtilities.ts +++ b/src/nodejs/NodeJsUtilities.ts @@ -9,8 +9,14 @@ import { logger } from "../common/Logger"; import { Utility } from "../common/Utilities"; import { KnownError } from "../common/Errors"; +/** + * Node.js-specific utility functions extending base Utility class + */ export class NodeJsUtility extends Utility { + /** + * Write JSON object to file with directory creation + */ public static async writeJsonToFile(exportFilename: string, payload: Object) { const folder = dirname(exportFilename); if (!existsSync(folder)) { @@ -19,6 +25,9 @@ export class NodeJsUtility extends Utility { await writeFileSync(exportFilename, JSON.stringify(payload, null, 2), { flag: "w" }); } + /** + * Start keyboard listener for user cancellation (press 'q' to quit) + */ public static startCancellationListener() { const stdin = process.stdin; if (typeof stdin.setRawMode !== "function") { @@ -31,10 +40,16 @@ export class NodeJsUtility extends Utility { logger.logVerbose("Keyboard listener added"); } + /** + * Get log file path from configuration options + */ public static getLogFilePath(options: IConfigurationOptions): string { return options.logFilename ? options.logFilename : normalize(defaultLogFileName); } + /** + * Create Azure DevOps REST API clients with authentication + */ public static async getRestClients(accountUrl: string, PAT: string): Promise { const authHandler = vsts.getPersonalAccessTokenHandler(PAT); const vstsWebApi = new vsts.WebApi(accountUrl, authHandler);