Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
449 changes: 381 additions & 68 deletions package-lock.json

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
31 changes: 19 additions & 12 deletions src/common/Constants.ts
Original file line number Diff line number Diff line change
@@ -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");
48 changes: 48 additions & 0 deletions src/common/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(step: () => Promise<T>, stepName?: string): Promise<T> {
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<T>(step: () => Promise<T>, stepName?: string): Promise<T> {
if (Utility.didUserCancel()) {
throw new CancellationError();
}
logger.logVerbose(`Begin step '${stepName}'.`);
const ret: T = await step();
logger.logVerbose(`Finished step '${stepName}'.`);
return ret;
Expand Down
16 changes: 15 additions & 1 deletion src/common/Errors.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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}`);
Expand Down
23 changes: 21 additions & 2 deletions src/common/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ 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,
information,
verbose
}

/**
* Operation modes for the process migration tool
*/
export enum Modes {
import,
export,
Expand All @@ -30,6 +36,9 @@ export interface ICommandLineOptions {
targetToken?: string;
}

/**
* Configuration file structure for process migration
*/
export interface IConfigurationFile {
sourceProcessName?: string;
targetProcessName?: string;
Expand All @@ -40,6 +49,9 @@ export interface IConfigurationFile {
options?: IConfigurationOptions;
}

/**
* Optional configuration settings
*/
export interface IConfigurationOptions {
logLevel?: string;
logFilename?: string;
Expand All @@ -48,12 +60,19 @@ 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[];
fields: WITProcessInterfaces.FieldModel[];
fields: WITInterfaces.WorkItemField[];
workItemTypeFields: IWITypeFields[];
witFieldPicklists: IWITFieldPicklist[];
layouts: IWITLayout[];
Expand Down Expand Up @@ -91,7 +110,7 @@ export interface IWITStates {

export interface IWITRules {
workItemTypeRefName: string;
rules: WITProcessInterfaces.FieldRuleModel[];
rules: WITProcessInterfaces.ProcessRule[];
}

export interface IWITBehaviors {
Expand Down
6 changes: 4 additions & 2 deletions src/common/Logger.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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;
Expand Down
38 changes: 25 additions & 13 deletions src/common/ProcessExporter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,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;
Expand All @@ -23,8 +27,11 @@ export class ProcessExporter {
this._witProcessDefinitionApi = restClients.witProcessDefinitionApi;
}

/**
* Get source process ID from configuration
*/
private async _getSourceProcessId(): Promise<string> {
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
Expand All @@ -38,16 +45,19 @@ export class ProcessExporter {
}

const process = matchProcesses[0];
if (process.properties.class !== WITProcessInterfaces.ProcessClass.Derived) {
throw new ExportError(`Proces '${this._config.sourceProcessName}' is not a derived process, not supported.`);
if (process.customizationType === WITProcessInterfaces.CustomizationType.System) {
throw new ExportError(`Process '${this._config.sourceProcessName}' is not a derived process, not supported.`);
}
return process.typeId;
}

/**
* Extract all process components and artifacts
*/
private async _getComponents(processId: string): Promise<IProcessPayload> {
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[] = [];
Expand All @@ -58,10 +68,10 @@ export class ProcessExporter {
const _nonSystemWorkItemTypes: WITProcessDefinitionsInterfaces.WorkItemTypeModel[] = [];
const processPromises: Promise<any>[] = [];

processPromises.push(this._witProcessApi.getProcessById(processId).then(process => _process = process));
processPromises.push(this._witProcessApi.getFields(processId).then(fields => _fieldsCollectionScope = fields));
processPromises.push(this._witProcessApi.getBehaviors(processId).then(behaviors => _behaviorsCollectionScope = behaviors));
processPromises.push(this._witProcessApi.getWorkItemTypes(processId).then(workitemtypes => {
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<any>[] = [];

for (const workitemtype of workitemtypes) {
Expand All @@ -88,7 +98,7 @@ export class ProcessExporter {

const picklistPromises: Promise<any>[] = [];
for (const field of fields) {
if (field.pickList && !knownPicklists[field.referenceName]) { // Same picklist field may exist in multiple work item types but we only need to export once (At this moment the picklist is still collection-scoped)
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(
{
Expand Down Expand Up @@ -118,7 +128,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
Expand All @@ -132,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 = {
Expand All @@ -153,6 +162,9 @@ export class ProcessExporter {
return processPayload;
}

/**
* Export complete process with all components
*/
public async exportProcess(): Promise<IProcessPayload> {
logger.logInfo("Export process started.");

Expand Down
Loading