1) normalize file path
2) skip webpage control
3) skip non-custom type of pages
4) allow rule import failure as optional parameter
5) allow user choose if import control/group/form contributions
6) add jsonc support for config file
This commit is contained in:
Liang Zhu 2018-05-18 17:01:17 -07:00
Родитель 75c57bd739
Коммит ee8c11710b
12 изменённых файлов: 101 добавлений и 53 удалений

4
.vscode/launch.json поставляемый
Просмотреть файл

@ -5,11 +5,11 @@
"type": "node",
"request": "launch",
"name": "Launch process import/export ",
"program": "${workspaceFolder}/build/main.js",
"program": "${workspaceFolder}/build/nodejs/nodejs/main.js",
"sourceMaps": true,
"args": [
"--mode=both",
"--config=configuration.json",
"--config=output\\repro.json",
"--overwriteProcessOnTarget"
]
}

Просмотреть файл

@ -10,9 +10,9 @@ NOTE: This only works with 'Inherited Process', for 'XML process' you can upload
## Run
- Install npm if haven't - [link](https://www.npmjs.com/get-npm)
- Install this package through `npm install vsts-process-import-export -g`
- Install this package through `npm install process-import-export -g`
- Create a configuration.json, see [doc section](#documentation) for explanation on details
- Run `vstspie --mode=<import/export/both> [--config=<your-configuration-file-path>] [--overwriteProcessOnTarget]`
- Run `pie --mode=<import/export/both> [--config=<your-configuration-file-path>] [--overwriteProcessOnTarget]`
## Contribute

7
package-lock.json сгенерированный
Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "process-import-export",
"version": "0.9.0",
"version": "0.9.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -68,6 +68,11 @@
"resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/guid-typescript/-/guid-typescript-1.0.7.tgz",
"integrity": "sha512-j1XPiaDUuNa0PO8EyGAikyKMpAnGbSDzr81UAzXz3H2xAoQW3c2hH8RS7Omo+DEgtD9emYdgJaxnfKZPYtDNRQ=="
},
"jsonc-parser": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.0.0.tgz",
"integrity": "sha512-gYk8VcFDwky0AjrKeJSWgCm/lYGteP9hszGWtgg67Elz4owvhJF9qATjuIRAk5jgBMGM65MPAc+I4RTeoqoykA=="
},
"minimist": {
"version": "1.2.0",
"resolved": "https://witiq.pkgs.visualstudio.com/_packaging/processimportexport/npm/registry/minimist/-/minimist-1.2.0.tgz",

Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "process-import-export",
"version": "0.9.0",
"version": "0.9.2",
"description": "Proces import/export Node.js application",
"main": "",
"bin": {
@ -35,6 +35,7 @@
},
"dependencies": {
"guid-typescript": "^1.0.7",
"jsonc-parser": "^2.0.0",
"minimist": "^1.2.0",
"mkdirp": "^0.5.1",
"url": "^0.11.0",

Просмотреть файл

@ -1,24 +1,27 @@
export const PICKLIST_NO_ACTION = "PICKLIST_NO_ACTION";
export const defaultEncoding = "utf-8";
export const defaultConfigurationFilename = "configuration.json";
export const defaultLogFileName = "output\\process_import_export.log";
export const defaultLogFileName = "output\\pie.log";
export const defaultProcessFilename = "output\\process.json";
export const paramMode = "mode";
export const paramConfig = "config";
export const paramOverwriteProcessOnTarget = "overwriteProcessOnTarget";
export const defaultConfiguration =
{
`{
"sourceAccountUrl": "<Source account url>",
"sourceAccountToken": "<Source account PAT>",
"sourceAccountToken": "<Source account personal access token>",
"targetAccountUrl": "<Target account url>",
"targetAccountToken": "<Target account PAT>",
"sourceProcessName": "Process name for export, optional in import only mode, required in export/both mode",
"targetProcessName<Optional>": "Set to override process name on import, remove <Optional> from param name",
"targetAccountToken": "<Target account personal access token>",
"sourceProcessName": "Process name for export, ignored in import only mode, required in export/both mode",
// "targetProcessName": "Set to override process name on import",
"options": {
"processFilename<Optional>": "Set to override default export file name, remove <Optional> from param name",
"logLevel<Optional>": "Set to override default log level (Information), remove <Optional> from param name",
"logFilename<Optional>": "Set to override default log file name, remove <Optional> from param name",
"overwritePicklist": false
// "processFilename": "Default is 'output/process.json', set to override default export file name",
// "logLevel": "Default is information, set to override log level, possible values are verbose/information/warning/error",
// "logFilename": "Default is 'output/pie.log', set to override default log file name",
// "continueOnRuleImportFailure": "Default is false, Set true to continue import on failure importing rules, warning will still be provided"
// "skipImportControlContributions": "Default is false, Set true to skip import control contributions on work item form."
// "skipImportGroupOrPageContributions": "Default is true, Set false to allow import group/page contributions on work item form. This should only be used when you want to hide contribution group/page."
// "overwritePicklist": "Default is false, Set true to overwrite picklist if exist on target account."
}
};
}`;
export const regexRemoveHypen = new RegExp("-","g");

Просмотреть файл

@ -43,6 +43,9 @@ export interface IConfigurationOptions {
logFilename?: string;
processFilename?: string;
overwritePicklist?: boolean;
continueOnRuleImportFailure?: boolean;
skipImportControlContributions?: boolean;
skipImportGroupOrPageContributions?: boolean;
}
export interface IImportConfiguration extends IConfigurationFile {

Просмотреть файл

@ -1,4 +1,3 @@
import { existsSync, writeFileSync } from "fs";
import * as WITProcessDefinitionsInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessDefinitionsInterfaces";
import * as WITProcessInterfaces from "vso-node-api/interfaces/WorkItemTrackingProcessInterfaces";

Просмотреть файл

@ -8,7 +8,7 @@ import { IWorkItemTrackingProcessDefinitionsApi as WITProcessDefinitionApi_NOREQ
import { IWorkItemTrackingProcessApi as WITProcessApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingProcessApi";
import { IWorkItemTrackingApi as WITApi_NOREQUIRE } from "vso-node-api/WorkItemTrackingApi";
import { PICKLIST_NO_ACTION, regexRemoveHypen } from "./Constants";
import { PICKLIST_NO_ACTION } from "./Constants";
import { Engine } from "./Engine";
import { ImportError, ValidationError } from "./Errors";
import { ICommandLineOptions, IConfigurationFile, IDictionaryStringTo, IProcessPayload, IWITLayout, IWITRules, IWITStates, IRestClients } from "./Interfaces";
@ -188,7 +188,11 @@ export class ProcessImporter {
private async _importPage(targetLayout: WITProcessDefinitionsInterfaces.FormLayout, witLayout: IWITLayout, page: WITProcessDefinitionsInterfaces.Page, payload: IProcessPayload) {
if (!page) {
throw new ImportError(`Encourtered null page in work item type '${witLayout.workItemTypeRefName}'`);
throw new ImportError(`Encountered null page in work item type '${witLayout.workItemTypeRefName}'`);
}
if (page.isContribution && !this._config.options.skipImportGroupOrPageContributions !== false) {
// skip import page contriubtion unless user explicitly asks so
}
let newPage: WITProcessDefinitionsInterfaces.Page; //The newly created page, contains the pageId required to create groups.
@ -214,6 +218,10 @@ export class ProcessImporter {
for (const group of section.groups) {
let newGroup: WITProcessDefinitionsInterfaces.Group;
if (group.isContribution === true && !this._config.options.skipImportGroupOrPageContributions !== false) {
// skip import group contriubtion unless user explicitly asks so
}
if (group.controls.length !== 0 && group.controls[0].controlType === "HtmlFieldControl") {
//Handle groups with HTML Controls
const createGroup: WITProcessDefinitionsInterfaces.Group = Utility.toCreateGroup(group);
@ -272,20 +280,27 @@ export class ProcessImporter {
try {
let createControl: WITProcessDefinitionsInterfaces.Control = Utility.toCreateControl(control);
if (control.controlType === "WebpageControl" || (control.isContribution === true && this._config.options.skipImportControlContributions)) {
// Skip web page control for now since not supported in inherited process.
continue;
}
if (control.inherited) {
if (control.overridden) {
//edit
await this._witProcessDefinitionApi.editControl(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, control.id);
await Engine.Task(() => this._witProcessDefinitionApi.editControl(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id, control.id),
`Edit control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`);
}
}
else {
//create
await this._witProcessDefinitionApi.addControlToGroup(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id);
await Engine.Task(() => this._witProcessDefinitionApi.addControlToGroup(createControl, payload.process.typeId, witLayout.workItemTypeRefName, group.id),
`Create control '${control.id}' in group '${group.id}' in page '${page.id}' in work item type '${witLayout.workItemTypeRefName}'.`);
}
}
catch (error) {
Utility.handleKnownError(error);
throw new ImportError(`Unable to add '${control}' control to page '${page}' in '${witLayout.workItemTypeRefName}'. ${error}`);
throw new ImportError(`Unable to add '${control.id}' control to group '${group.id}' in page '${page.id}' in '${witLayout.workItemTypeRefName}'. ${error}`);
}
}
}
@ -304,7 +319,9 @@ export class ProcessImporter {
() => this._witProcessDefinitionApi.getFormLayout(payload.process.typeId, witLayoutEntry.workItemTypeRefName),
`Get layout on target process for work item type '${witLayoutEntry.workItemTypeRefName}'`);
for (const page of witLayoutEntry.layout.pages) {
await this._importPage(targetLayout, witLayoutEntry, page, payload);
if (page.pageType === WITProcessDefinitionsInterfaces.PageType.Custom) {
await this._importPage(targetLayout, witLayoutEntry, page, payload);
}
}
}
}
@ -329,7 +346,7 @@ export class ProcessImporter {
const existingStates: WITProcessDefinitionsInterfaces.WorkItemStateResultModel[] = targetWITStates.filter(targetState => sourceState.name === targetState.name);
if (existingStates.length === 0) { // does not exist on target
const createdState = await Engine.Task(
() => this._witProcessDefinitionApi.createStateDefinition(sourceState, payload.process.typeId, witStateEntry.workItemTypeRefName),
() => this._witProcessDefinitionApi.createStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName),
`Create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`);
if (!createdState || !createdState.id) {
throw new ImportError(`Unable to create state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result or id.`);
@ -349,7 +366,7 @@ export class ProcessImporter {
if (sourceState.color !== existingState.color || sourceState.stateCategory !== existingState.stateCategory || sourceState.name !== existingState.name) {
// Inherited state can be edited in custom work item types.
const updatedState = await Engine.Task(
() => this._witProcessDefinitionApi.updateStateDefinition(Utility.toUdpateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName, existingState.id),
() => this._witProcessDefinitionApi.updateStateDefinition(Utility.toCreateOrUpdateStateDefinition(sourceState), payload.process.typeId, witStateEntry.workItemTypeRefName, existingState.id),
`Update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type`);
if (!updatedState || updatedState.name !== sourceState.name) {
throw new ImportError(`Unable to update state '${sourceState.name}' in '${witStateEntry.workItemTypeRefName}' work item type, server returned empty result, id or state is not hidden.`);
@ -377,7 +394,6 @@ export class ProcessImporter {
}
}
private async _importStates(payload: IProcessPayload): Promise<void> {
for (const witStateEntry of payload.states) {
await this._importWITStates(witStateEntry, payload);
@ -395,8 +411,13 @@ export class ProcessImporter {
}
}
catch (error) {
Utility.handleKnownError(error);
throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', see logs for details.`);
if (this._config.options.continueOnRuleImportFailure === true) {
logger.logWarning(`Failed to import rule below, continue importing rest of process.\r\n:Error:${error}\r\n${JSON.stringify(rule, null, 2)}`);
}
else {
Utility.handleKnownError(error);
throw new ImportError(`Unable to create rule '${rule.id}' in work item type '${witRulesEntry.workItemTypeRefName}', see logs for details.`);
}
}
}
@ -427,7 +448,7 @@ export class ProcessImporter {
const createBehavior: WITProcessDefinitionsInterfaces.BehaviorCreateModel = Utility.toCreateBehavior(behavior);
// Use a random name to avoid conflict on scenarios involving a name swap
behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior);
createBehavior.name = Guid.create().toString().replace(regexRemoveHypen, "");
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}'`);
@ -438,7 +459,7 @@ export class ProcessImporter {
else {
const replaceBehavior: WITProcessDefinitionsInterfaces.BehaviorReplaceModel = Utility.toReplaceBehavior(behavior);
behaviorIdToRealNameBehavior[behavior.id] = Utility.toReplaceBehavior(behavior);
replaceBehavior.name = Guid.create().toString().replace(regexRemoveHypen, "");
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}'`);

Просмотреть файл

@ -5,6 +5,8 @@ import { KnownError } from "./Errors";
import { logger } from "./Logger";
import { Modes, IConfigurationFile, LogLevel } from "./Interfaces";
import * as url from "url";
import { Guid } from "guid-typescript";
import { regexRemoveHypen } from "./Constants";
export class Utility {
/** Convert from WITProcess FieldModel to WITProcessDefinitions FieldModel
@ -56,7 +58,7 @@ export class Utility {
description: processModel.description,
name: processModel.name,
parentProcessTypeId: processModel.properties.parentProcessTypeId,
referenceName: processModel.referenceName
referenceName: Utility.createGuidWithoutHyphen() // Reference name does not really matter since we already have typeId
};
return createModel;
}
@ -64,7 +66,7 @@ export class Utility {
/**Convert group from getLayout group interface to WITProcessDefinitionsInterfaces.Group
* @param group
*/
public static toCreateGroup(group: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Group {
public static toCreateGroup(group: WITProcessDefinitionsInterfaces.Group): WITProcessDefinitionsInterfaces.Group {
let createGroup: WITProcessDefinitionsInterfaces.Group = {
id: group.id,
inherited: group.inherited,
@ -72,8 +74,8 @@ export class Utility {
isContribution: group.isContribution,
visible: group.visible,
controls: null,
contribution: null,
height: null,
contribution: group.contribution,
height: group.height,
order: null,
overridden: null
}
@ -83,7 +85,7 @@ export class Utility {
/**Convert control from getLayout control interface to WITProcessDefinitionsInterfaces.Control
* @param control
*/
public static toCreateControl(control: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Control {
public static toCreateControl(control: WITProcessDefinitionsInterfaces.Control): WITProcessDefinitionsInterfaces.Control {
let createControl: WITProcessDefinitionsInterfaces.Control = {
id: control.id,
inherited: control.inherited,
@ -94,8 +96,8 @@ export class Utility {
metadata: control.metadata,
visible: control.visible,
isContribution: control.isContribution,
contribution: null,
height: null,
contribution: control.contribution,
height: control.height,
order: null,
overridden: null
}
@ -105,17 +107,17 @@ export class Utility {
/**Convert page from getLayout page interface to WITProcessDefinitionsInterfaces.Page
* @param control
*/
public static toCreatePage(page: any/*TODO: Change this type, not any*/): WITProcessDefinitionsInterfaces.Page {
public static toCreatePage(page: WITProcessDefinitionsInterfaces.Page): WITProcessDefinitionsInterfaces.Page {
let createPage: WITProcessDefinitionsInterfaces.Page = {
id: page.id,
inherited: page.inherited,
label: page.label,
pageType: page.pageType,
locked: page.loacked,
locked: page.locked,
visible: page.visible,
isContribution: page.isContribution,
sections: null,//yeah??
contribution: null,
sections: null,
contribution: page.contribution,
order: null,
overridden: null
}
@ -125,7 +127,7 @@ export class Utility {
/**Convert a state result to state input
* @param group
*/
public static toUdpateStateDefinition(state: WITProcessInterfaces.WorkItemStateResultModel): WITProcessDefinitionsInterfaces.WorkItemStateInputModel {
public static toCreateOrUpdateStateDefinition(state: WITProcessInterfaces.WorkItemStateResultModel): WITProcessDefinitionsInterfaces.WorkItemStateInputModel {
const updateState: WITProcessDefinitionsInterfaces.WorkItemStateInputModel = {
color: state.color,
name: state.name,
@ -198,6 +200,14 @@ export class Utility {
logger.logError(`[Configuration validation] Option 'overwritePicklist' is not a valid boolean.`);
return false;
}
if (configuration.options && configuration.options.continueOnRuleImportFailure && (configuration.options.continueOnRuleImportFailure !== true && configuration.options.continueOnRuleImportFailure !== false)) {
logger.logError(`[Configuration validation] Option 'continueOnRuleImportFailure' is not a valid boolean.`);
return false;
}
if (configuration.options && configuration.options.skipImportControlContributions && (configuration.options.skipImportControlContributions !== true && configuration.options.skipImportControlContributions !== false)) {
logger.logError(`[Configuration validation] Option 'skipImportControlContributions' is not a valid boolean.`);
return false;
}
}
if (configuration.options && configuration.options.logLevel && LogLevel[configuration.options.logLevel] === undefined) {
@ -212,5 +222,9 @@ export class Utility {
return Utility.isCancelled;
}
public static createGuidWithoutHyphen(): string {
return Guid.create().toString().replace(regexRemoveHypen, "");
}
protected static isCancelled = false;
}

Просмотреть файл

@ -1,10 +1,12 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import { normalize } from "path";
import * as minimist from "minimist";
import * as url from "url";
import { defaultConfiguration, defaultConfigurationFilename, defaultEncoding, paramConfig, paramMode, paramOverwriteProcessOnTarget } from "../common/Constants";
import { IConfigurationFile, LogLevel, Modes, ICommandLineOptions } from "../common/Interfaces";
import { logger } from "../common/Logger";
import { Utility } from "../common/Utilities";
import { parse as jsoncParse } from "jsonc-parser";
export function ProcesCommandLine(): ICommandLineOptions {
const parseOptions: minimist.Opts = {
@ -22,7 +24,7 @@ export function ProcesCommandLine(): ICommandLineOptions {
process.exit(0);
}
const configFileName = parsedArgs[paramConfig] || defaultConfigurationFilename;
const configFileName = parsedArgs[paramConfig] || normalize(defaultConfigurationFilename);
const userSpecifiedMode = parsedArgs[paramMode] as string;
let mode;
@ -38,12 +40,12 @@ export function ProcesCommandLine(): ICommandLineOptions {
mode = Modes.both;
}
const ret = {};
const ret = {};
ret[paramMode] = mode;
ret[paramConfig] = configFileName;
ret[paramOverwriteProcessOnTarget] = !!parsedArgs[paramOverwriteProcessOnTarget];
return <ICommandLineOptions> ret;
return <ICommandLineOptions>ret;
}
export async function ProcessConfigurationFile(configFilename: string, mode: Modes): Promise<IConfigurationFile> {
@ -56,8 +58,8 @@ export async function ProcessConfigurationFile(configFilename: string, mode: Mod
}
process.exit(1);
}
const configuration = JSON.parse(await readFileSync(configFilename, defaultEncoding)) as IConfigurationFile;
const configuration = jsoncParse(readFileSync(configFilename, defaultEncoding)) as IConfigurationFile;
if (!Utility.validateConfiguration(configuration, mode)) {
process.exit(1);
}

Просмотреть файл

@ -1,6 +1,6 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
import { resolve, normalize } from "path";
import { ProcesCommandLine, ProcessConfigurationFile } from "./ConfigurationProcessor";
import { defaultEncoding, defaultProcessFilename } from "../common/Constants";
import { ImportError, KnownError } from "../common/Errors";
@ -40,7 +40,7 @@ async function main() {
const exporter: ProcessExporter = new ProcessExporter(sourceRestClients, configuration);
processPayload = await exporter.exportProcess();
const exportFilename = (configuration.options && configuration.options.processFilename) || defaultProcessFilename;
const exportFilename = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename);
await Engine.Task(() => NodeJsUtility.writeJsonToFile(exportFilename, processPayload), "Write process payload to file")
logger.logInfo(`Export process completed successfully to '${resolve(exportFilename)}'.`);
}
@ -48,7 +48,7 @@ async function main() {
// Import
if (mode === Modes.both || mode === Modes.import) {
if (mode === Modes.import) { // Read payload from file instead
const processFileName = (configuration.options && configuration.options.processFilename) || defaultProcessFilename;
const processFileName = (configuration.options && configuration.options.processFilename) || normalize(defaultProcessFilename);
if (!existsSync(processFileName)) {
throw new ImportError(`Process payload file '${processFileName}' does not exist.`)
}

Просмотреть файл

@ -1,6 +1,6 @@
import * as vsts from "vso-node-api/WebApi";
import { existsSync, writeFileSync } from "fs";
import { dirname } from "path";
import { dirname, normalize } from "path";
import { sync as mkdirpSync } from "mkdirp";
import * as readline from "readline";
import { isFunction } from "util";
@ -33,7 +33,7 @@ export class NodeJsUtility extends Utility {
}
public static getLogFilePath(options: IConfigurationOptions): string {
return options.logFilename ? options.logFilename : defaultLogFileName;
return options.logFilename ? options.logFilename : normalize(defaultLogFileName);
}
public static async getRestClients(accountUrl: string, PAT: string): Promise<IRestClients> {