diff --git a/.talismanrc b/.talismanrc index 564e618c4..70a828210 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,4 @@ fileignoreconfig: - - filename: pnpm-lock.yaml - checksum: 90c094faa23d82277ad7d48858147354ad28d11c0f317722a79bd70658b23835 + - filename: packages/contentstack-branches/src/commands/cm/branches/generate-scripts.ts + checksum: 84b73eab889d8ee4b39f6e7317a3c55de0322f03113bc7cbd498062018353a97 version: '1.0' diff --git a/packages/contentstack-branches/README.md b/packages/contentstack-branches/README.md index baa022197..4e7405de7 100755 --- a/packages/contentstack-branches/README.md +++ b/packages/contentstack-branches/README.md @@ -37,7 +37,7 @@ $ npm install -g @contentstack/cli-cm-branches $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-branches/1.7.0-beta.1 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-branches/1.7.0 darwin-arm64 node-v24.14.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND @@ -52,7 +52,9 @@ USAGE * [`csdx cm:branches:create`](#csdx-cmbranchescreate) * [`csdx cm:branches:delete [-uid ] [-k ]`](#csdx-cmbranchesdelete--uid-value--k-value) * [`csdx cm:branches:diff [--base-branch ] [--compare-branch ] [-k ][--module ] [--format ] [--csv-path ]`](#csdx-cmbranchesdiff---base-branch-value---compare-branch-value--k-value--module-value---format-value---csv-path-value) +* [`csdx cm:branches:generate-scripts -k --merge-uid `](#csdx-cmbranchesgenerate-scripts--k-value---merge-uid-value) * [`csdx cm:branches:merge [-k ][--compare-branch ] [--no-revert] [--export-summary-path ] [--use-merge-summary ] [--comment ] [--base-branch ]`](#csdx-cmbranchesmerge--k-value--compare-branch-value---no-revert---export-summary-path-value---use-merge-summary-value---comment-value---base-branch-value) +* [`csdx cm:branches:merge-status -k --merge-uid `](#csdx-cmbranchesmerge-status--k-value---merge-uid-value) ## `csdx cm:branches` @@ -192,6 +194,29 @@ EXAMPLES _See code: [src/commands/cm/branches/diff.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/diff.ts)_ +## `csdx cm:branches:generate-scripts -k --merge-uid ` + +Generate entry migration scripts for a completed merge job + +``` +USAGE + $ csdx cm:branches:generate-scripts -k --merge-uid + +FLAGS + -k, --stack-api-key= (required) Provide your stack API key. + --merge-uid= (required) Merge job UID to generate scripts for. + +DESCRIPTION + Generate entry migration scripts for a completed merge job + +EXAMPLES + $ csdx cm:branches:generate-scripts -k bltxxxxxxxx --merge-uid merge_abc123 + + $ csdx cm:branches:generate-scripts --stack-api-key bltxxxxxxxx --merge-uid merge_abc123 +``` + +_See code: [src/commands/cm/branches/generate-scripts.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/generate-scripts.ts)_ + ## `csdx cm:branches:merge [-k ][--compare-branch ] [--no-revert] [--export-summary-path ] [--use-merge-summary ] [--comment ] [--base-branch ]` Merge changes from a branch @@ -230,4 +255,27 @@ EXAMPLES ``` _See code: [src/commands/cm/branches/merge.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge.ts)_ + +## `csdx cm:branches:merge-status -k --merge-uid ` + +Check the status of a branch merge job + +``` +USAGE + $ csdx cm:branches:merge-status -k --merge-uid + +FLAGS + -k, --stack-api-key= (required) Provide your stack API key. + --merge-uid= (required) Merge job UID to check status for. + +DESCRIPTION + Check the status of a branch merge job + +EXAMPLES + $ csdx cm:branches:merge-status -k bltxxxxxxxx --merge-uid merge_abc123 + + $ csdx cm:branches:merge-status --stack-api-key bltxxxxxxxx --merge-uid merge_abc123 +``` + +_See code: [src/commands/cm/branches/merge-status.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge-status.ts)_ diff --git a/packages/contentstack-branches/package.json b/packages/contentstack-branches/package.json index 1e27161cc..97ecdfb5a 100644 --- a/packages/contentstack-branches/package.json +++ b/packages/contentstack-branches/package.json @@ -1,7 +1,7 @@ { "name": "@contentstack/cli-cm-branches", "description": "Contentstack CLI plugin to do branches operations", - "version": "1.7.0", + "version": "1.8.0", "author": "Contentstack", "bugs": "https://github.com/contentstack/cli/issues", "dependencies": { @@ -75,6 +75,8 @@ "cm:branches:delete": "BRDEL", "cm:branches:diff": "BRDIF", "cm:branches:merge": "BRMRG", + "cm:branches:merge-status": "BRMST", + "cm:branches:generate-scripts": "BRGNS", "cm:branches": "BRLS" } }, diff --git a/packages/contentstack-branches/src/branch/diff-handler.ts b/packages/contentstack-branches/src/branch/diff-handler.ts index 72aeaf0f2..4ada1bc48 100644 --- a/packages/contentstack-branches/src/branch/diff-handler.ts +++ b/packages/contentstack-branches/src/branch/diff-handler.ts @@ -1,20 +1,21 @@ -import startCase from 'lodash/startCase'; -import camelCase from 'lodash/camelCase'; import { cliux } from '@contentstack/cli-utilities'; +import camelCase from 'lodash/camelCase'; +import startCase from 'lodash/startCase'; + +import { BranchDiffPayload, BranchOptions } from '../interfaces'; import { getbranchConfig } from '../utils'; -import { BranchOptions, BranchDiffPayload } from '../interfaces'; -import { askBaseBranch, askCompareBranch, askStackAPIKey, selectModule } from '../utils/interactive'; import { fetchBranchesDiff, - parseSummary, - printSummary, + filterBranchDiffDataByModule, parseCompactText, - printCompactTextView, + parseSummary, parseVerbose, + printCompactTextView, + printSummary, printVerboseTextView, - filterBranchDiffDataByModule, } from '../utils/branch-diff-utility'; import { exportCSVReport } from '../utils/csv-utility'; +import { askBaseBranch, askCompareBranch, askStackAPIKey, selectModule } from '../utils/interactive'; export default class BranchDiffHandler { private options: BranchOptions; @@ -23,46 +24,36 @@ export default class BranchDiffHandler { this.options = params; } - async run(): Promise { - await this.validateMandatoryFlags(); - await this.initBranchDiffUtility(); - } - /** - * @methods validateMandatoryFlags - validate flags and prompt to select required flags - * @returns {*} {Promise} + * @methods displayBranchDiffTextAndVerbose - to show branch differences in compactText or detailText format + * @returns {*} {void} * @memberof BranchDiff */ - async validateMandatoryFlags(): Promise { - let baseBranch: string; - if (!this.options.stackAPIKey) { - this.options.stackAPIKey = await askStackAPIKey(); - } - - if (!this.options.baseBranch) { - baseBranch = getbranchConfig(this.options.stackAPIKey); - if (!baseBranch) { - this.options.baseBranch = await askBaseBranch(); - } else { - this.options.baseBranch = baseBranch; - } - } - - if (!this.options.compareBranch) { - this.options.compareBranch = await askCompareBranch(); - } - - if (!this.options.module) { - this.options.module = await selectModule(); - } + async displayBranchDiffTextAndVerbose(branchDiffData: any[], payload: BranchDiffPayload): Promise { + const spinner = cliux.loaderV2('Loading branch differences...'); + if (this.options.format === 'compact-text') { + const branchTextRes = parseCompactText(branchDiffData); + cliux.loaderV2('', spinner); + printCompactTextView(branchTextRes); + } else if (this.options.format === 'detailed-text') { + const verboseRes = await parseVerbose(branchDiffData, payload); + cliux.loaderV2('', spinner); + printVerboseTextView(verboseRes); - if (this.options.format === 'detailed-text' && !this.options.csvPath) { - this.options.csvPath = process.cwd(); + exportCSVReport(payload.module, verboseRes, this.options.csvPath); } + } - if(baseBranch){ - cliux.print(`\nBase branch: ${baseBranch}`, { color: 'grey' }); - } + /** + * @methods displaySummary - show branches summary on CLI + * @returns {*} {void} + * @memberof BranchDiff + */ + displaySummary(branchDiffData: any[], module: string): void { + cliux.print(' '); + cliux.print(`${startCase(camelCase(module))} Summary:`, { color: 'yellow' }); + const diffSummary = parseSummary(branchDiffData, this.options.baseBranch, this.options.compareBranch); + printSummary(diffSummary); } /** @@ -73,11 +64,11 @@ export default class BranchDiffHandler { async initBranchDiffUtility(): Promise { const spinner = cliux.loaderV2('Loading branch differences...'); const payload: BranchDiffPayload = { - module: '', apiKey: this.options.stackAPIKey, baseBranch: this.options.baseBranch, compareBranch: this.options.compareBranch, - host: this.options.host + host: this.options.host, + module: '' }; if (this.options.module === 'content-types') { @@ -91,7 +82,7 @@ export default class BranchDiffHandler { cliux.loaderV2('', spinner); if(this.options.module === 'all'){ - for (let module in diffData) { + for (const module in diffData) { const branchDiff = diffData[module]; payload.module = module; this.displaySummary(branchDiff, module); @@ -104,35 +95,45 @@ export default class BranchDiffHandler { } } - /** - * @methods displaySummary - show branches summary on CLI - * @returns {*} {void} - * @memberof BranchDiff - */ - displaySummary(branchDiffData: any[], module: string): void { - cliux.print(' '); - cliux.print(`${startCase(camelCase(module))} Summary:`, { color: 'yellow' }); - const diffSummary = parseSummary(branchDiffData, this.options.baseBranch, this.options.compareBranch); - printSummary(diffSummary); + async run(): Promise { + await this.validateMandatoryFlags(); + await this.initBranchDiffUtility(); } /** - * @methods displayBranchDiffTextAndVerbose - to show branch differences in compactText or detailText format - * @returns {*} {void} + * @methods validateMandatoryFlags - validate flags and prompt to select required flags + * @returns {*} {Promise} * @memberof BranchDiff */ - async displayBranchDiffTextAndVerbose(branchDiffData: any[], payload: BranchDiffPayload): Promise { - const spinner = cliux.loaderV2('Loading branch differences...'); - if (this.options.format === 'compact-text') { - const branchTextRes = parseCompactText(branchDiffData); - cliux.loaderV2('', spinner); - printCompactTextView(branchTextRes); - } else if (this.options.format === 'detailed-text') { - const verboseRes = await parseVerbose(branchDiffData, payload); - cliux.loaderV2('', spinner); - printVerboseTextView(verboseRes); + async validateMandatoryFlags(): Promise { + let baseBranch: string; + if (!this.options.stackAPIKey) { + this.options.stackAPIKey = await askStackAPIKey(); + } - exportCSVReport(payload.module, verboseRes, this.options.csvPath); + if (!this.options.baseBranch) { + baseBranch = getbranchConfig(this.options.stackAPIKey); + if (!baseBranch) { + this.options.baseBranch = await askBaseBranch(); + } else { + this.options.baseBranch = baseBranch; + } + } + + if (!this.options.compareBranch) { + this.options.compareBranch = await askCompareBranch(); + } + + if (!this.options.module) { + this.options.module = await selectModule(); + } + + if (this.options.format === 'detailed-text' && !this.options.csvPath) { + this.options.csvPath = process.cwd(); + } + + if(baseBranch){ + cliux.print(`\nBase branch: ${baseBranch}`, { color: 'grey' }); } } } diff --git a/packages/contentstack-branches/src/branch/index.ts b/packages/contentstack-branches/src/branch/index.ts index 9da452fc1..032e22966 100644 --- a/packages/contentstack-branches/src/branch/index.ts +++ b/packages/contentstack-branches/src/branch/index.ts @@ -2,5 +2,5 @@ * Business logics can be written inside this directory */ -export { default as MergeHandler } from './merge-handler'; export { default as BranchDiffHandler } from './diff-handler'; +export { default as MergeHandler } from './merge-handler'; diff --git a/packages/contentstack-branches/src/branch/merge-handler.ts b/packages/contentstack-branches/src/branch/merge-handler.ts index 0d2755215..e0ce61a0f 100644 --- a/packages/contentstack-branches/src/branch/merge-handler.ts +++ b/packages/contentstack-branches/src/branch/merge-handler.ts @@ -1,38 +1,39 @@ -import os from 'os'; -import path from 'path'; -import forEach from 'lodash/forEach'; import { cliux } from '@contentstack/cli-utilities'; import chalk from 'chalk'; +import forEach from 'lodash/forEach'; +import os from 'os'; +import path from 'path'; + import { MergeInputOptions, MergeSummary } from '../interfaces'; import { - selectMergeStrategy, - selectMergeStrategySubOptions, - selectMergeExecution, - prepareMergeRequestPayload, - displayMergeSummary, askExportMergeSummaryPath, askMergeComment, - writeFile, + displayMergeSummary, executeMerge, generateMergeScripts, - selectCustomPreferences, - selectContentMergePreference, + prepareMergeRequestPayload, selectContentMergeCustomPreferences, + selectContentMergePreference, + selectCustomPreferences, + selectMergeExecution, + selectMergeStrategy, + selectMergeStrategySubOptions, + writeFile, } from '../utils'; export default class MergeHandler { - private strategy: string; - private strategySubOption?: string; private branchCompareData: any; - private mergeSettings: any; - private executeOption?: string; private displayFormat: string; + private enableEntryExp: boolean; + private executeOption?: string; private exportSummaryPath: string; + private host: string; + private mergeSettings: any; private mergeSummary: MergeSummary; private stackAPIKey: string; + private strategy: string; + private strategySubOption?: string; private userInputs: MergeInputOptions; - private host: string; - private enableEntryExp: boolean; constructor(options: MergeInputOptions) { this.stackAPIKey = options.stackAPIKey; @@ -45,8 +46,8 @@ export default class MergeHandler { this.mergeSummary = options.mergeSummary; this.userInputs = options; this.mergeSettings = { - baseBranch: options.baseBranch, // UID of the base branch, where the changes will be merged into - compareBranch: options.compareBranch, // UID of the branch to merge + baseBranch: options.baseBranch, + compareBranch: options.compareBranch, mergeComment: options.mergeComment, mergeContent: {}, noRevert: options.noRevert, @@ -55,29 +56,67 @@ export default class MergeHandler { this.enableEntryExp = options.enableEntryExp; } - async start() { - if (this.mergeSummary) { - this.loadMergeSettings(); - await this.displayMergeSummary(); - return await this.executeMerge(this.mergeSummary.requestPayload); - } - await this.collectMergeSettings(); - const mergePayload = prepareMergeRequestPayload(this.mergeSettings); - if (this.executeOption === 'execute') { - await this.exportSummary(mergePayload); - await this.executeMerge(mergePayload); - } else if (this.executeOption === 'export') { - await this.exportSummary(mergePayload); - } else if (this.executeOption === 'merge_n_scripts') { - this.enableEntryExp = true; - await this.executeMerge(mergePayload); - } else if (this.executeOption === 'summary_n_scripts') { - this.enableEntryExp = true; - await this.exportSummary(mergePayload); - } else { - await this.exportSummary(mergePayload); - await this.executeMerge(mergePayload); + /** + * Checks whether the selection of modules in the compare branch data is empty. + * + * This method evaluates the branch compare data and determines if there are any changes + * (added, modified, or deleted) in the modules based on the merge strategy defined in the + * merge settings. It categorizes the status of each module as either existing and empty or + * not empty. + * + * @returns An object containing: + * - `allEmpty`: A boolean indicating whether all modules are either non-existent or empty. + * - `moduleStatus`: A record mapping module types (`contentType` and `globalField`) to their + * respective statuses, which include: + * - `exists`: A boolean indicating whether the module exists in the branch comparison data. + * - `empty`: A boolean indicating whether the module has no changes (added, modified, or deleted). + */ + checkEmptySelection(): { + allEmpty: boolean; + moduleStatus: Record; + } { + const strategy = this.mergeSettings.strategy; + + const useMergeContent = new Set(['custom_preferences', 'ignore']); + const modifiedOnlyStrategies = new Set(['merge_modified_only_prefer_base', 'merge_modified_only_prefer_compare']); + const addedOnlyStrategies = new Set(['merge_new_only']); + + const moduleStatus: Record = { + contentType: { empty: true, exists: false }, + globalField: { empty: true, exists: false }, + }; + + for (const module in this.branchCompareData) { + const content = useMergeContent.has(strategy) + ? this.mergeSettings.mergeContent[module] + : this.branchCompareData[module]; + + if (!content) continue; + + const isGlobalField = module === 'global_fields'; + const type = isGlobalField ? 'globalField' : 'contentType'; + moduleStatus[type].exists = true; + + let hasChanges = false; + if (modifiedOnlyStrategies.has(strategy)) { + hasChanges = Array.isArray(content.modified) && content.modified.length > 0; + } else if (addedOnlyStrategies.has(strategy)) { + hasChanges = Array.isArray(content.added) && content.added.length > 0; + } else { + hasChanges = + (Array.isArray(content.modified) && content.modified.length > 0) || + (Array.isArray(content.added) && content.added.length > 0) || + (Array.isArray(content.deleted) && content.deleted.length > 0); + } + + if (hasChanges) { + moduleStatus[type].empty = false; + } } + + const allEmpty = Object.values(moduleStatus).every((status) => !status.exists || status.empty); + + return { allEmpty, moduleStatus }; } async collectMergeSettings() { @@ -101,11 +140,11 @@ export default class MergeHandler { } if (this.strategy === 'custom_preferences') { this.mergeSettings.itemMergeStrategies = []; - for (let module in this.branchCompareData) { + for (const module in this.branchCompareData) { this.mergeSettings.mergeContent[module] = { added: [], - modified: [], deleted: [], + modified: [], }; const selectedItems = await selectCustomPreferences(module, this.branchCompareData[module]); if (selectedItems?.length) { @@ -138,22 +177,22 @@ export default class MergeHandler { const { allEmpty, moduleStatus } = this.checkEmptySelection(); const strategyName = this.mergeSettings.strategy; - + if (allEmpty) { cliux.print(chalk.red(`No items selected according to the '${strategyName}' strategy.`)); process.exit(1); } - - for (const [type, { exists, empty }] of Object.entries(moduleStatus)) { + + for (const [type, { empty, exists }] of Object.entries(moduleStatus)) { if (exists && empty) { const readable = type === 'contentType' ? 'Content Types' : 'Global fields'; - cliux.print('\n') + cliux.print('\n'); cliux.print(chalk.yellow(`Note: No ${readable} selected according to the '${strategyName}' strategy.`)); } } - + this.displayMergeSummary(); - + if (!this.executeOption) { const executionResponse = await selectMergeExecution(); if (executionResponse === 'previous') { @@ -171,160 +210,22 @@ export default class MergeHandler { } } - /** - * Checks whether the selection of modules in the compare branch data is empty. - * - * This method evaluates the branch compare data and determines if there are any changes - * (added, modified, or deleted) in the modules based on the merge strategy defined in the - * merge settings. It categorizes the status of each module as either existing and empty or - * not empty. - * - * @returns An object containing: - * - `allEmpty`: A boolean indicating whether all modules are either non-existent or empty. - * - `moduleStatus`: A record mapping module types (`contentType` and `globalField`) to their - * respective statuses, which include: - * - `exists`: A boolean indicating whether the module exists in the branch comparison data. - * - `empty`: A boolean indicating whether the module has no changes (added, modified, or deleted). - */ - checkEmptySelection(): { - allEmpty: boolean; - moduleStatus: Record; - } { - const strategy = this.mergeSettings.strategy; - - const useMergeContent = new Set(['custom_preferences', 'ignore']); - const modifiedOnlyStrategies = new Set(['merge_modified_only_prefer_base', 'merge_modified_only_prefer_compare']); - const addedOnlyStrategies = new Set(['merge_new_only']); - - const moduleStatus: Record = { - contentType: { exists: false, empty: true }, - globalField: { exists: false, empty: true }, - }; - - for (const module in this.branchCompareData) { - const content = useMergeContent.has(strategy) - ? this.mergeSettings.mergeContent[module] - : this.branchCompareData[module]; - - if (!content) continue; - - const isGlobalField = module === 'global_fields'; - const type = isGlobalField ? 'globalField' : 'contentType'; - moduleStatus[type].exists = true; - - let hasChanges = false; - if (modifiedOnlyStrategies.has(strategy)) { - hasChanges = Array.isArray(content.modified) && content.modified.length > 0; - } else if (addedOnlyStrategies.has(strategy)) { - hasChanges = Array.isArray(content.added) && content.added.length > 0; - } else { - hasChanges = - (Array.isArray(content.modified) && content.modified.length > 0) || - (Array.isArray(content.added) && content.added.length > 0) || - (Array.isArray(content.deleted) && content.deleted.length > 0); - } - - if (hasChanges) { - moduleStatus[type].empty = false; - } - } - - const allEmpty = Object.values(moduleStatus).every( - (status) => !status.exists || status.empty - ); - - return { allEmpty, moduleStatus }; - } - displayMergeSummary() { if (this.mergeSettings.strategy !== 'ignore') { - for (let module in this.branchCompareData) { + for (const module in this.branchCompareData) { this.mergeSettings.mergeContent[module] = {}; this.filterBranchCompareData(module, this.branchCompareData[module]); } } displayMergeSummary({ - format: this.displayFormat, compareData: this.mergeSettings.mergeContent, + format: this.displayFormat, }); } - filterBranchCompareData(module, moduleBranchCompareData) { - const { strategy, mergeContent } = this.mergeSettings; - switch (strategy) { - case 'merge_prefer_base': - mergeContent[module].added = moduleBranchCompareData.added; - mergeContent[module].modified = moduleBranchCompareData.modified; - mergeContent[module].deleted = moduleBranchCompareData.deleted; - break; - case 'merge_prefer_compare': - mergeContent[module].added = moduleBranchCompareData.added; - mergeContent[module].modified = moduleBranchCompareData.modified; - mergeContent[module].deleted = moduleBranchCompareData.deleted; - break; - case 'merge_new_only': - mergeContent[module].added = moduleBranchCompareData.added; - break; - case 'merge_modified_only_prefer_base': - mergeContent[module].modified = moduleBranchCompareData.modified; - break; - case 'merge_modified_only_prefer_compare': - mergeContent[module].modified = moduleBranchCompareData.modified; - break; - case 'merge_modified_only_prefer_compare': - mergeContent[module].modified = moduleBranchCompareData.modified; - break; - case 'overwrite_with_compare': - mergeContent[module].added = moduleBranchCompareData.added; - mergeContent[module].modified = moduleBranchCompareData.modified; - mergeContent[module].deleted = moduleBranchCompareData.deleted; - break; - default: - cliux.error(`Error: Invalid strategy '${strategy}'`); - process.exit(1); - } - } - - async exportSummary(mergePayload) { - if (!this.exportSummaryPath) { - this.exportSummaryPath = await askExportMergeSummaryPath(); - } - const summary: MergeSummary = { - requestPayload: mergePayload, - }; - await writeFile(path.join(this.exportSummaryPath, 'merge-summary.json'), summary); - cliux.success('Exported the summary successfully'); - - if (this.enableEntryExp) { - this.executeEntryExpFlow(this.stackAPIKey, mergePayload); - } - } - - async executeMerge(mergePayload) { - let spinner; - try { - if (!this.mergeSettings.mergeComment) { - this.mergeSettings.mergeComment = await askMergeComment(); - mergePayload.merge_comment = this.mergeSettings.mergeComment; - } - - spinner = cliux.loaderV2('Merging the changes...'); - const mergeResponse = await executeMerge(this.stackAPIKey, mergePayload, this.host); - cliux.loaderV2('', spinner); - cliux.success(`Merged the changes successfully. Merge UID: ${mergeResponse.uid}`); - - if (this.enableEntryExp) { - this.executeEntryExpFlow(mergeResponse.uid, mergePayload); - } - } catch (error) { - cliux.loaderV2('', spinner); - cliux.error('Failed to merge the changes', error.message || error); - } - } - async executeEntryExpFlow(mergeJobUID: string, mergePayload) { const { mergeContent } = this.mergeSettings; - let mergePreference = await selectContentMergePreference(); + const mergePreference = await selectContentMergePreference(); const updateEntryMergeStrategy = (items, mergeStrategy) => { items && @@ -334,10 +235,10 @@ export default class MergeHandler { }; const mergePreferencesMap = { + ask_preference: 'custom', + existing: 'merge_existing', existing_new: 'merge_existing_new', new: 'merge_new', - existing: 'merge_existing', - ask_preference: 'custom', }; const selectedMergePreference = mergePreferencesMap[mergePreference]; @@ -346,8 +247,8 @@ export default class MergeHandler { const selectedMergeItems = await selectContentMergeCustomPreferences(mergeContent.content_types); mergeContent.content_types = { added: [], - modified: [], deleted: [], + modified: [], }; selectedMergeItems?.forEach((item) => { @@ -362,7 +263,7 @@ export default class MergeHandler { process.exit(1); } - let scriptFolderPath = generateMergeScripts(mergeContent.content_types, mergeJobUID); + const scriptFolderPath = generateMergeScripts(mergeContent.content_types, mergeJobUID); if (scriptFolderPath !== undefined) { cliux.success(`\nSuccess! We have generated entry migration files in the folder ${scriptFolderPath}`); @@ -385,6 +286,106 @@ export default class MergeHandler { } } + /** + * Executes the merge operation with improved polling. + * Handles polling timeout gracefully by returning merge UID for later status checking. + * If enableEntryExp is true and merge is complete, generates scripts. + * + * @param mergePayload - Merge request payload with branch info + */ + async executeMerge(mergePayload) { + let spinner; + try { + if (!this.mergeSettings.mergeComment) { + this.mergeSettings.mergeComment = await askMergeComment(); + mergePayload.merge_comment = this.mergeSettings.mergeComment; + } + + spinner = cliux.loaderV2('Merging the changes...'); + const mergeResponse = await executeMerge(this.stackAPIKey, mergePayload, this.host); + cliux.loaderV2('', spinner); + + if (mergeResponse.merge_details?.status === 'complete') { + cliux.success(`Merged the changes successfully. Merge UID: ${mergeResponse.uid}`); + + if (this.enableEntryExp) { + await this.executeEntryExpFlow(mergeResponse.uid, mergePayload); + } + } else if (mergeResponse.pollingTimeout) { + cliux.success(`Merge job initiated successfully. Merge UID: ${mergeResponse.uid}`); + cliux.print('\n⏱ The merge is still processing in the background...', { color: 'yellow' }); + cliux.print('\nCheck status later using:', { color: 'grey' }); + cliux.print(` csdx cm:branches:merge-status -k ${this.stackAPIKey} --merge-uid ${mergeResponse.uid}`, { + color: 'cyan', + }); + cliux.print('\nGenerate entry migration scripts (when merge completes successfully):', { color: 'grey' }); + cliux.print(` csdx cm:branches:generate-scripts -k ${this.stackAPIKey} --merge-uid ${mergeResponse.uid}`, { + color: 'cyan', + }); + } + } catch (error) { + cliux.loaderV2('', spinner); + cliux.error('Failed to merge the changes', error.message || error); + } + } + + async exportSummary(mergePayload) { + if (!this.exportSummaryPath) { + this.exportSummaryPath = await askExportMergeSummaryPath(); + } + const summary: MergeSummary = { + requestPayload: mergePayload, + }; + await writeFile(path.join(this.exportSummaryPath, 'merge-summary.json'), summary); + cliux.success('Exported the summary successfully'); + + if (this.enableEntryExp) { + await this.executeEntryExpFlow(this.stackAPIKey, mergePayload); + } + } + + filterBranchCompareData(module, moduleBranchCompareData) { + const { mergeContent, strategy } = this.mergeSettings; + switch (strategy) { + case 'merge_prefer_base': + mergeContent[module].added = moduleBranchCompareData.added; + mergeContent[module].modified = moduleBranchCompareData.modified; + mergeContent[module].deleted = moduleBranchCompareData.deleted; + break; + case 'merge_prefer_compare': + mergeContent[module].added = moduleBranchCompareData.added; + mergeContent[module].modified = moduleBranchCompareData.modified; + mergeContent[module].deleted = moduleBranchCompareData.deleted; + break; + case 'merge_new_only': + mergeContent[module].added = moduleBranchCompareData.added; + break; + case 'merge_modified_only_prefer_base': + mergeContent[module].modified = moduleBranchCompareData.modified; + break; + case 'merge_modified_only_prefer_compare': + mergeContent[module].modified = moduleBranchCompareData.modified; + break; + case 'overwrite_with_compare': + mergeContent[module].added = moduleBranchCompareData.added; + mergeContent[module].modified = moduleBranchCompareData.modified; + mergeContent[module].deleted = moduleBranchCompareData.deleted; + break; + default: + cliux.error(`Error: Invalid strategy '${strategy}'`); + process.exit(1); + } + } + + loadMergeSettings() { + this.mergeSettings.baseBranch = this.mergeSummary.requestPayload.base_branch; + this.mergeSettings.compareBranch = this.mergeSummary.requestPayload.compare_branch; + this.mergeSettings.strategy = this.mergeSummary.requestPayload.default_merge_strategy; + this.mergeSettings.itemMergeStrategies = this.mergeSummary.requestPayload.item_merge_strategies; + this.mergeSettings.noRevert = this.mergeSummary.requestPayload.no_revert; + this.mergeSettings.mergeComment = this.mergeSummary.requestPayload.merge_comment; + } + async restartMergeProcess() { if (!this.userInputs.strategy) { this.strategy = null; @@ -404,12 +405,29 @@ export default class MergeHandler { await this.start(); } - loadMergeSettings() { - this.mergeSettings.baseBranch = this.mergeSummary.requestPayload.base_branch; - this.mergeSettings.compareBranch = this.mergeSummary.requestPayload.compare_branch; - this.mergeSettings.strategy = this.mergeSummary.requestPayload.default_merge_strategy; - this.mergeSettings.itemMergeStrategies = this.mergeSummary.requestPayload.item_merge_strategies; - this.mergeSettings.noRevert = this.mergeSummary.requestPayload.no_revert; - this.mergeSettings.mergeComment = this.mergeSummary.requestPayload.merge_comment; + async start() { + if (this.mergeSummary) { + this.loadMergeSettings(); + await this.displayMergeSummary(); + return await this.executeMerge(this.mergeSummary.requestPayload); + } + await this.collectMergeSettings(); + const mergePayload = prepareMergeRequestPayload(this.mergeSettings); + if (this.executeOption === 'execute') { + await this.exportSummary(mergePayload); + await this.executeMerge(mergePayload); + } else if (this.executeOption === 'export') { + await this.exportSummary(mergePayload); + } else if (this.executeOption === 'merge_n_scripts') { + this.enableEntryExp = true; + await this.executeMerge(mergePayload); + } else if (this.executeOption === 'summary_n_scripts') { + this.enableEntryExp = true; + await this.exportSummary(mergePayload); + } else { + await this.exportSummary(mergePayload); + await this.executeMerge(mergePayload); + this.enableEntryExp = true; + } } } diff --git a/packages/contentstack-branches/src/commands/cm/branches/generate-scripts.ts b/packages/contentstack-branches/src/commands/cm/branches/generate-scripts.ts new file mode 100644 index 000000000..d4e5fa443 --- /dev/null +++ b/packages/contentstack-branches/src/commands/cm/branches/generate-scripts.ts @@ -0,0 +1,150 @@ +import { Command } from '@contentstack/cli-command'; +import { cliux, flags, isAuthenticated, managementSDKClient } from '@contentstack/cli-utilities'; +import { + getMergeStatusWithContentTypes, + handleErrorMsg, + selectContentMergePreference, + selectContentMergeCustomPreferences, + generateMergeScripts, +} from '../../../utils'; +import os from 'os'; + +/** + * Command to generate entry migration scripts for a completed merge job. + * Validates that merge is complete before allowing script generation. + */ +export default class BranchGenerateScriptsCommand extends Command { + static readonly aliases: string[] = []; + static readonly description: string = 'Generate entry migration scripts for a completed merge job'; + + static readonly examples: string[] = [ + 'csdx cm:branches:generate-scripts -k bltxxxxxxxx --merge-uid merge_abc123', + 'csdx cm:branches:generate-scripts --stack-api-key bltxxxxxxxx --merge-uid merge_abc123', + ]; + + static readonly flags = { + 'merge-uid': flags.string({ + description: 'Merge job UID to generate scripts for.', + required: true, + }), + 'stack-api-key': flags.string({ + char: 'k', + description: 'Provide your stack API key.', + required: true, + }), + }; + + static readonly usage: string = 'cm:branches:generate-scripts -k --merge-uid '; + + /** + * Generates entry migration scripts for a completed merge job. + * Validates merge status is 'complete' before proceeding with script generation. + * Prompts user for merge preference (new entries, existing, or both). + * Throws error if merge is not complete - user should check status using merge-status command. + */ + async run(): Promise { + let spinner; + try { + const { flags: generateScriptsFlags } = await this.parse(BranchGenerateScriptsCommand); + + if (!isAuthenticated()) { + const err = { errorMessage: 'You are not logged in. Please login with command $ csdx auth:login' }; + handleErrorMsg(err); + } + + const { 'merge-uid': mergeUID, 'stack-api-key': stackAPIKey } = generateScriptsFlags; + + const stackAPIClient = await ( + await managementSDKClient({ host: this.cmaHost }) + ).stack({ + api_key: stackAPIKey, + }); + + spinner = cliux.loaderV2('Fetching merge status...'); + const mergeStatusResponse = await getMergeStatusWithContentTypes(stackAPIClient, mergeUID); + cliux.loaderV2('', spinner); + + // Check if merge is complete + if (mergeStatusResponse.error) { + cliux.print('⏳ Merge job is still in progress. Please wait for it to complete.', { color: 'yellow' }); + cliux.print('\nCheck status using:', { color: 'grey' }); + cliux.print(` csdx cm:branches:merge-status -k ${stackAPIKey} --merge-uid ${mergeUID}`, { color: 'cyan' }); + cliux.print('\nTry script generation again once merge completes.', { color: 'grey' }); + process.exit(1); + } + + // Extract merge details for script generation + const { uid } = mergeStatusResponse; + + // Ask user for merge preference + const mergePreference = await selectContentMergePreference(); + + // Get content types data + const contentTypes = mergeStatusResponse.content_types ?? { added: [], deleted: [], modified: [] }; + + const updateEntryMergeStrategy = (items, mergeStrategy) => { + items && + items.forEach((item) => { + item.entry_merge_strategy = mergeStrategy; + }); + }; + + const mergePreferencesMap = { + ask_preference: 'custom', + existing: 'merge_existing', + existing_new: 'merge_existing_new', + new: 'merge_new', + }; + const selectedMergePreference = mergePreferencesMap[mergePreference]; + + if (selectedMergePreference) { + if (selectedMergePreference === 'custom') { + const selectedMergeItems = await selectContentMergeCustomPreferences(contentTypes); + contentTypes.added = []; + contentTypes.modified = []; + contentTypes.deleted = []; + + selectedMergeItems?.forEach((item) => { + contentTypes[item.status].push(item.value); + }); + } else { + updateEntryMergeStrategy(contentTypes.added, selectedMergePreference); + updateEntryMergeStrategy(contentTypes.modified, selectedMergePreference); + } + } else { + cliux.error(`error: Invalid preference ${mergePreference}`); + process.exit(1); + } + + // Generate merge scripts + let scriptFolderPath = generateMergeScripts(contentTypes, uid); + + if (scriptFolderPath !== undefined) { + cliux.success(`\nSuccess! Generated entry migration files in folder ${scriptFolderPath}`); + cliux.print( + '\nWARNING!!! Migration is not intended to be run more than once. Migrated(entries/assets) will be duplicated if run more than once', + { color: 'yellow' }, + ); + + let migrationCommand: string; + const compareBranch = mergeStatusResponse?.merge_details?.compare_branch; + const baseBranch = mergeStatusResponse?.merge_details?.base_branch; + + if (os.platform() === 'win32') { + migrationCommand = `csdx cm:stacks:migration --multiple --file-path ./${scriptFolderPath} --config compare-branch:${compareBranch} file-path:./${scriptFolderPath} --branch ${baseBranch} --stack-api-key ${stackAPIKey}`; + } else { + migrationCommand = `csdx cm:stacks:migration --multiple --file-path ./${scriptFolderPath} --config {compare-branch:${compareBranch},file-path:./${scriptFolderPath}} --branch ${baseBranch} --stack-api-key ${stackAPIKey}`; + } + + cliux.print( + `\nKindly follow the steps in the guide "https://www.contentstack.com/docs/developers/cli/entry-migration" to update the migration scripts, and then run the command:\n\n${migrationCommand}`, + { color: 'blue' }, + ); + } + } catch (error) { + if (spinner) cliux.loaderV2('', spinner); + cliux.error('Failed to generate scripts', error.message || error); + process.exit(1); + } + } +} diff --git a/packages/contentstack-branches/src/commands/cm/branches/merge-status.ts b/packages/contentstack-branches/src/commands/cm/branches/merge-status.ts new file mode 100644 index 000000000..0bb22571a --- /dev/null +++ b/packages/contentstack-branches/src/commands/cm/branches/merge-status.ts @@ -0,0 +1,71 @@ +import { Command } from '@contentstack/cli-command'; +import { cliux, flags, isAuthenticated, managementSDKClient } from '@contentstack/cli-utilities'; +import { displayMergeStatusDetails, handleErrorMsg } from '../../../utils'; + +/** + * Command to check the status of a branch merge job. + * Allows users to check merge progress and status asynchronously. + */ +export default class BranchMergeStatusCommand extends Command { + static readonly description: string = 'Check the status of a branch merge job'; + + static readonly examples: string[] = [ + 'csdx cm:branches:merge-status -k bltxxxxxxxx --merge-uid merge_abc123', + 'csdx cm:branches:merge-status --stack-api-key bltxxxxxxxx --merge-uid merge_abc123', + ]; + + static readonly usage: string = 'cm:branches:merge-status -k --merge-uid '; + + static readonly flags = { + 'stack-api-key': flags.string({ + char: 'k', + description: 'Provide your stack API key.', + required: true, + }), + 'merge-uid': flags.string({ + description: 'Merge job UID to check status for.', + required: true, + }), + }; + + static readonly aliases: string[] = []; + + /** + * Fetches and displays the current status of a branch merge job. + * Useful for checking long-running merges asynchronously without blocking. + */ + async run(): Promise { + try { + const { flags: mergeStatusFlags } = await this.parse(BranchMergeStatusCommand); + + if (!isAuthenticated()) { + const err = { errorMessage: 'You are not logged in. Please login with command $ csdx auth:login' }; + handleErrorMsg(err); + } + + const { 'stack-api-key': stackAPIKey, 'merge-uid': mergeUID } = mergeStatusFlags; + + const stackAPIClient = await (await managementSDKClient({ host: this.cmaHost })).stack({ + api_key: stackAPIKey, + }); + + const spinner = cliux.loaderV2('Fetching merge status...'); + const mergeStatusResponse = await stackAPIClient + .branch() + .mergeQueue(mergeUID) + .fetch(); + cliux.loaderV2('', spinner); + + if (!mergeStatusResponse?.queue?.length) { + cliux.error(`No merge job found with UID: ${mergeUID}`); + process.exit(1); + } + + const mergeJobStatus = mergeStatusResponse.queue[0]; + displayMergeStatusDetails(mergeJobStatus); + } catch (error) { + cliux.error('Failed to fetch merge status', error.message || error); + process.exit(1); + } + } +} diff --git a/packages/contentstack-branches/src/config/index.ts b/packages/contentstack-branches/src/config/index.ts index c5a62ca1c..cb195a8d6 100644 --- a/packages/contentstack-branches/src/config/index.ts +++ b/packages/contentstack-branches/src/config/index.ts @@ -1,5 +1,5 @@ const config = { - skip: 0, - limit: 100 + limit: 100, + skip: 0 }; export default config; diff --git a/packages/contentstack-branches/src/interfaces/index.ts b/packages/contentstack-branches/src/interfaces/index.ts index 62b5655f6..29eba9801 100644 --- a/packages/contentstack-branches/src/interfaces/index.ts +++ b/packages/contentstack-branches/src/interfaces/index.ts @@ -1,34 +1,34 @@ export interface BranchOptions { + authToken?: string; + baseBranch?: string; compareBranch: string; - stackAPIKey: string; - module: string; + csvPath?: string; format: string; - baseBranch?: string; - authToken?: string; host?: string; - csvPath?: string; + module: string; + stackAPIKey: string; } export interface BranchDiffRes { - uid: string; + merge_strategy?: string; + status: string; title: string; type: string; - status: string; - merge_strategy?: string; + uid: string; } export interface BranchDiffSummary { base: string; - compare: string; base_only: number; + compare: string; compare_only: number; modified: number; } export interface BranchCompactTextRes { - modified?: BranchDiffRes[]; added?: BranchDiffRes[]; deleted?: BranchDiffRes[]; + modified?: BranchDiffRes[]; } export interface MergeSummary { @@ -40,61 +40,61 @@ type MergeSummaryRequestPayload = { compare_branch: string; default_merge_strategy: string; item_merge_strategies?: any[]; - no_revert?: boolean; merge_comment?: string; + no_revert?: boolean; }; export interface MergeInputOptions { - compareBranch: string; - strategy: string; - strategySubOption: string; + baseBranch: string; branchCompareData: any; - mergeComment?: string; + compareBranch: string; + enableEntryExp: boolean; executeOption?: string; - noRevert?: boolean; - baseBranch: string; - format?: string; exportSummaryPath?: string; + format?: string; + host: string; + mergeComment?: string; mergeSummary?: MergeSummary; + noRevert?: boolean; stackAPIKey: string; - host: string; - enableEntryExp: boolean; + strategy: string; + strategySubOption: string; } export interface ModifiedFieldsType { - uid: string; - displayName: string; - path: string; - field: string; - propertyChanges?: PropertyChange[]; changeCount?: number; changeDetails?: string; - oldValue?: any; + displayName: string; + field: string; newValue?: any; + oldValue?: any; + path: string; + propertyChanges?: PropertyChange[]; + uid: string; } export interface PropertyChange { - property: string; - changeType: 'modified' | 'added' | 'deleted'; - oldValue?: any; + changeType: 'added' | 'deleted' | 'modified'; newValue?: any; + oldValue?: any; + property: string; } export interface CSVRow { - srNo: number; contentTypeName: string; fieldName: string; fieldPath: string; operation: string; sourceBranchValue: string; + srNo: number; targetBranchValue: string; } export interface AddCSVRowParams { - srNo: number; contentTypeName: string; fieldName: string; fieldType: string; sourceValue: string; + srNo: number; targetValue: string; } @@ -108,43 +108,43 @@ export interface ContentTypeItem { } export interface ModifiedFieldsInput { - modified?: ModifiedFieldsType[]; added?: ModifiedFieldsType[]; deleted?: ModifiedFieldsType[]; + modified?: ModifiedFieldsType[]; } export interface BranchModifiedDetails { - moduleDetails: BranchDiffRes; modifiedFields: ModifiedFieldsInput; + moduleDetails: BranchDiffRes; } export interface BranchDiffVerboseRes { - modified?: BranchModifiedDetails[]; added?: BranchDiffRes[]; - deleted?: BranchDiffRes[]; csvData?: CSVRow[]; // Pre-processed CSV data + deleted?: BranchDiffRes[]; + modified?: BranchModifiedDetails[]; } export interface BranchDiffPayload { - module: string; apiKey: string; baseBranch: string; compareBranch: string; filter?: string; host?: string; - uid?: string; + module: string; spinner?: any; + uid?: string; url?: string; } export type MergeStrategy = - | 'merge_prefer_base' - | 'merge_prefer_compare' - | 'overwrite_with_compare' - | 'merge_new_only' + | 'ignore' | 'merge_modified_only_prefer_base' | 'merge_modified_only_prefer_compare' - | 'ignore'; + | 'merge_new_only' + | 'merge_prefer_base' + | 'merge_prefer_compare' + | 'overwrite_with_compare'; export interface MergeParams { base_branch: string; @@ -153,3 +153,33 @@ export interface MergeParams { merge_comment: string; no_revert?: boolean; } + +export interface MergeStatusOptions { + host?: string; + mergeUID: string; + stackAPIKey: string; +} + +export interface GenerateScriptsOptions { + host?: string; + mergeUID: string; + stackAPIKey: string; +} + +export interface MergeJobStatusResponse { + errors?: Array<{ details?: string; field?: string; message: string }>; + merge_details: { + completed_at?: string; + completion_percentage?: number; + created_at: string; + status: string; + updated_at: string; + }; + merge_summary: { + content_types: { added: number; deleted: number; modified: number }; + global_fields: { added: number; deleted: number; modified: number }; + }; + pollingTimeout?: boolean; + status: 'complete' | 'failed' | 'in_progress' | 'unknown'; + uid: string; +} diff --git a/packages/contentstack-branches/src/utils/branch-diff-utility.ts b/packages/contentstack-branches/src/utils/branch-diff-utility.ts index fc76eb5d5..28b9bf3c6 100644 --- a/packages/contentstack-branches/src/utils/branch-diff-utility.ts +++ b/packages/contentstack-branches/src/utils/branch-diff-utility.ts @@ -1,26 +1,26 @@ +import { cliux, managementSDKClient, messageHandler } from '@contentstack/cli-utilities'; import chalk from 'chalk'; +import { diff } from 'just-diff'; +import camelCase from 'lodash/camelCase'; +import find from 'lodash/find'; import forEach from 'lodash/forEach'; +import isArray from 'lodash/isArray'; import padStart from 'lodash/padStart'; import startCase from 'lodash/startCase'; -import camelCase from 'lodash/camelCase'; import unionWith from 'lodash/unionWith'; -import find from 'lodash/find'; -import { cliux, messageHandler, managementSDKClient } from '@contentstack/cli-utilities'; -import isArray from 'lodash/isArray'; -import { diff } from 'just-diff'; -import { extractValueFromPath, getFieldDisplayName, generateCSVDataFromVerbose } from './csv-utility'; +import config from '../config'; import { - BranchDiffRes, - ModifiedFieldsInput, - ModifiedFieldsType, - BranchModifiedDetails, + BranchCompactTextRes, BranchDiffPayload, + BranchDiffRes, BranchDiffSummary, - BranchCompactTextRes, BranchDiffVerboseRes, + BranchModifiedDetails, + ModifiedFieldsInput, + ModifiedFieldsType, } from '../interfaces/index'; -import config from '../config'; +import { extractValueFromPath, generateCSVDataFromVerbose, getFieldDisplayName } from './csv-utility'; /** * Fetch differences between two branches @@ -124,9 +124,9 @@ function handleErrorMsg(err, spinner) { * @returns {*} BranchDiffSummary */ function parseSummary(branchesDiffData: any[], baseBranch: string, compareBranch: string): BranchDiffSummary { - let baseCount: number = 0, - compareCount: number = 0, - modifiedCount: number = 0; + let baseCount = 0, + compareCount = 0, + modifiedCount = 0; if (branchesDiffData?.length) { forEach(branchesDiffData, (diff: BranchDiffRes) => { @@ -138,8 +138,8 @@ function parseSummary(branchesDiffData: any[], baseBranch: string, compareBranch const branchSummary: BranchDiffSummary = { base: baseBranch, - compare: compareBranch, base_only: baseCount, + compare: compareBranch, compare_only: compareCount, modified: modifiedCount, }; @@ -166,7 +166,7 @@ function printSummary(diffSummary: BranchDiffSummary): void { * @returns {*} BranchCompactTextRes */ function parseCompactText(branchesDiffData: any[]): BranchCompactTextRes { - let listOfModified: BranchDiffRes[] = [], + const listOfModified: BranchDiffRes[] = [], listOfAdded: BranchDiffRes[] = [], listOfDeleted: BranchDiffRes[] = []; @@ -179,9 +179,9 @@ function parseCompactText(branchesDiffData: any[]): BranchCompactTextRes { } const branchTextRes: BranchCompactTextRes = { - modified: listOfModified, added: listOfAdded, deleted: listOfDeleted, + modified: listOfModified, }; return branchTextRes; } @@ -223,32 +223,32 @@ function printCompactTextView(branchTextRes: BranchCompactTextRes): void { * @returns {*} Promise */ async function parseVerbose(branchesDiffData: any[], payload: BranchDiffPayload): Promise { - const { added, modified, deleted } = parseCompactText(branchesDiffData); - let modifiedDetailList: BranchModifiedDetails[] = []; + const { added, deleted, modified } = parseCompactText(branchesDiffData); + const modifiedDetailList: BranchModifiedDetails[] = []; for (let i = 0; i < modified?.length; i++) { const diff: BranchDiffRes = modified[i]; payload.uid = diff?.uid; const branchDiff = await branchCompareSDK(payload); if (branchDiff) { - const { listOfModifiedFields, listOfAddedFields, listOfDeletedFields } = await prepareBranchVerboseRes( + const { listOfAddedFields, listOfDeletedFields, listOfModifiedFields } = await prepareBranchVerboseRes( branchDiff, ); modifiedDetailList.push({ - moduleDetails: diff, modifiedFields: { - modified: listOfModifiedFields, - deleted: listOfDeletedFields, added: listOfAddedFields, + deleted: listOfDeletedFields, + modified: listOfModifiedFields, }, + moduleDetails: diff, }); } } const verboseRes: BranchDiffVerboseRes = { - modified: modifiedDetailList, added: added, deleted: deleted, + modified: modifiedDetailList, }; verboseRes.csvData = generateCSVDataFromVerbose(verboseRes); @@ -263,7 +263,7 @@ async function parseVerbose(branchesDiffData: any[], payload: BranchDiffPayload) * @returns */ async function prepareBranchVerboseRes(branchDiff: any) { - let listOfModifiedFields = [], + const listOfModifiedFields = [], listOfDeletedFields = [], listOfAddedFields = []; @@ -287,9 +287,9 @@ async function prepareBranchVerboseRes(branchDiff: any) { baseBranchFieldExists, compareBranchFieldExists, diffData, - listOfModifiedFields, - listOfDeletedFields, listOfAddedFields, + listOfDeletedFields, + listOfModifiedFields, }); }); } @@ -306,9 +306,9 @@ async function baseAndCompareBranchDiff(params: { baseBranchFieldExists: any; compareBranchFieldExists: any; diffData: any; - listOfModifiedFields: any[]; - listOfDeletedFields: any[]; listOfAddedFields: any[]; + listOfDeletedFields: any[]; + listOfModifiedFields: any[]; }) { const { baseBranchFieldExists, compareBranchFieldExists } = params; if (baseBranchFieldExists && compareBranchFieldExists) { @@ -323,10 +323,10 @@ async function baseAndCompareBranchDiff(params: { field = 'metadata' } params.listOfDeletedFields.push({ - path: path, displayName:displayName, - uid: baseBranchFieldExists?.uid, field: field, + path: path, + uid: baseBranchFieldExists?.uid, }); } else if (!baseBranchFieldExists && compareBranchFieldExists) { let displayName= compareBranchFieldExists?.display_name; @@ -338,10 +338,10 @@ async function baseAndCompareBranchDiff(params: { field = 'metadata' } params.listOfAddedFields.push({ - path: path, displayName: displayName, - uid: compareBranchFieldExists?.uid, field: field, + path: path, + uid: compareBranchFieldExists?.uid, }); } } @@ -349,9 +349,9 @@ async function baseAndCompareBranchDiff(params: { async function prepareModifiedDiff(params: { baseBranchFieldExists: any; compareBranchFieldExists: any; - listOfModifiedFields: any[]; - listOfDeletedFields: any[]; listOfAddedFields: any[]; + listOfDeletedFields: any[]; + listOfModifiedFields: any[]; }) { const { baseBranchFieldExists, compareBranchFieldExists } = params; if ( @@ -381,49 +381,49 @@ async function prepareModifiedDiff(params: { changeDetails = `Changed from "${oldTitle}" to "${newTitle}"`; } params.listOfModifiedFields.push({ - path: '', + changeDetails, displayName: displayName, - uid: baseBranchFieldExists.path, field: 'changed', - changeDetails, - oldValue: baseBranchFieldExists.value, newValue: compareBranchFieldExists.value, + oldValue: baseBranchFieldExists.value, + path: '', + uid: baseBranchFieldExists.path, }); } else { const fieldDisplayName = getFieldDisplayName(compareBranchFieldExists); - const { modified, deleted, added } = await deepDiff(baseBranchFieldExists, compareBranchFieldExists); - for (let field of Object.values(added)) { + const { added, deleted, modified } = await deepDiff(baseBranchFieldExists, compareBranchFieldExists); + for (const field of Object.values(added)) { if (field) { params.listOfAddedFields.push({ - path: field['path'], - displayName: getFieldDisplayName(field), - uid: field['uid'], + displayName: getFieldDisplayName(field), field: field['fieldType'] || field['data_type'] || 'field', + path: field['path'], + uid: field['uid'], }); } } - for (let field of Object.values(deleted)) { + for (const field of Object.values(deleted)) { if (field) { params.listOfDeletedFields.push({ - path: field['path'], - displayName: getFieldDisplayName(field), - uid: field['uid'], + displayName: getFieldDisplayName(field), field: field['fieldType'] || field['data_type'] || 'field', + path: field['path'], + uid: field['uid'], }); } } - for (let field of Object.values(modified)) { + for (const field of Object.values(modified)) { if (field) { params.listOfModifiedFields.push({ - path: field['path'], + changeCount: field['changeCount'], displayName: field['displayName'] || field['display_name'] || fieldDisplayName, - uid: field['uid'] || compareBranchFieldExists?.uid, field: `${field['fieldType'] || field['data_type'] || compareBranchFieldExists?.data_type || 'field'} field`, + path: field['path'], propertyChanges: field['propertyChanges'], - changeCount: field['changeCount'], + uid: field['uid'] || compareBranchFieldExists?.uid, }); } } @@ -487,7 +487,7 @@ function printModifiedFields(modfiedFields: ModifiedFieldsInput): void { * @returns */ function filterBranchDiffDataByModule(branchDiffData: any[]) { - let moduleRes = { + const moduleRes = { content_types: [], global_fields: [], }; @@ -503,22 +503,22 @@ const buildPath = (path, key) => (path === '' ? key : `${path}.${key}`); async function deepDiff(baseObj, compareObj) { const changes = { - modified: {}, added: {}, deleted: {}, + modified: {}, }; function baseAndCompareSchemaDiff(baseObj, compareObj, path = '') { - const { schema: baseSchema, path: basePath, ...restBaseObj } = baseObj; - const { schema: compareSchema, path: comparePath, ...restCompareObj } = compareObj; + const { path: basePath, schema: baseSchema, ...restBaseObj } = baseObj; + const { path: comparePath, schema: compareSchema, ...restCompareObj } = compareObj; const currentPath = buildPath(path, baseObj['uid']); if (restBaseObj['uid'] === restCompareObj['uid']) { prepareModifiedField({ - restBaseObj, - restCompareObj, - currentPath, changes, + currentPath, fullFieldContext: baseObj, parentContext: baseObj, + restBaseObj, + restCompareObj, }); } @@ -531,10 +531,10 @@ async function deepDiff(baseObj, compareObj) { let newPath: string; if (baseBranchField && !compareBranchField) { newPath = `${currentPath}.${baseBranchField['uid']}`; - prepareDeletedField({ path: newPath, changes, baseField: baseBranchField }); + prepareDeletedField({ baseField: baseBranchField, changes, path: newPath }); } else if (compareBranchField && !baseBranchField) { newPath = `${currentPath}.${compareBranchField['uid']}`; - prepareAddedField({ path: newPath, changes, compareField: compareBranchField }); + prepareAddedField({ changes, compareField: compareBranchField, path: newPath }); } else if (compareBranchField && baseBranchField) { baseAndCompareSchemaDiff(baseBranchField, compareBranchField, currentPath); } @@ -545,14 +545,14 @@ async function deepDiff(baseObj, compareObj) { if (baseSchema?.length && !compareSchema?.length && isArray(baseSchema)) { forEach(baseSchema, (base, key) => { const newPath = `${currentPath}.${base['uid']}`; - prepareDeletedField({ path: newPath, changes, baseField: base }); + prepareDeletedField({ baseField: base, changes, path: newPath }); }); } //case3:- compare schema exists only if (!baseSchema?.length && compareSchema?.length && isArray(compareSchema)) { forEach(compareSchema, (compare, key) => { const newPath = `${currentPath}.${compare['uid']}`; - prepareAddedField({ path: newPath, changes, compareField: compare }); + prepareAddedField({ changes, compareField: compare, path: newPath }); }); } } @@ -560,53 +560,53 @@ async function deepDiff(baseObj, compareObj) { return changes; } -function prepareAddedField(params: { path: string; changes: any; compareField: any }) { - const { path, changes, compareField } = params; +function prepareAddedField(params: { changes: any; compareField: any; path: string }) { + const { changes, compareField, path } = params; if (!changes.added[path]) { const obj = { - path: path, - uid: compareField['uid'], displayName: compareField['display_name'], fieldType: compareField['data_type'], - oldValue: undefined, newValue: compareField, + oldValue: undefined, + path: path, + uid: compareField['uid'], }; changes.added[path] = obj; } } -function prepareDeletedField(params: { path: string; changes: any; baseField: any }) { - const { path, changes, baseField } = params; +function prepareDeletedField(params: { baseField: any; changes: any; path: string }) { + const { baseField, changes, path } = params; if (!changes.added[path]) { const obj = { - path: path, - uid: baseField['uid'], displayName: baseField['display_name'], fieldType: baseField['data_type'], + path: path, + uid: baseField['uid'], }; changes.deleted[path] = obj; } } function prepareModifiedField(params: { - restBaseObj: any; - restCompareObj: any; - currentPath: string; changes: any; + currentPath: string; fullFieldContext: any; parentContext: any; + restBaseObj: any; + restCompareObj: any; }) { - const { restBaseObj, restCompareObj, currentPath, changes, fullFieldContext } = params; + const { changes, currentPath, fullFieldContext, restBaseObj, restCompareObj } = params; const differences = diff(restBaseObj, restCompareObj); if (differences.length) { const modifiedField = { - path: currentPath, - uid: fullFieldContext['uid'] || restCompareObj['uid'], + changeCount: differences.length, displayName: getFieldDisplayName(fullFieldContext) || getFieldDisplayName(restCompareObj) || 'Field', fieldType: restCompareObj['data_type'] || 'field', + path: currentPath, propertyChanges: differences.map((diff) => { let oldValue = 'from' in diff ? diff.from : undefined; - let newValue = diff.value; + const newValue = diff.value; if (!('from' in diff) && fullFieldContext && diff.path && diff.path.length > 0) { const contextValue = extractValueFromPath(fullFieldContext, diff.path); if (contextValue !== undefined) { @@ -615,29 +615,29 @@ function prepareModifiedField(params: { } return { - property: diff.path.join('.'), changeType: diff.op === 'add' ? 'added' : diff.op === 'remove' ? 'deleted' : 'modified', - oldValue: oldValue, newValue: newValue, + oldValue: oldValue, + property: diff.path.join('.'), }; }), - changeCount: differences.length, + uid: fullFieldContext['uid'] || restCompareObj['uid'], }; if (!changes.modified[currentPath]) changes.modified[currentPath] = modifiedField; } } export { + branchCompareSDK, + deepDiff, fetchBranchesDiff, - parseSummary, - printSummary, + filterBranchDiffDataByModule, parseCompactText, - printCompactTextView, + parseSummary, parseVerbose, - printVerboseTextView, - filterBranchDiffDataByModule, - branchCompareSDK, prepareBranchVerboseRes, - deepDiff, prepareModifiedDiff, + printCompactTextView, + printSummary, + printVerboseTextView, }; diff --git a/packages/contentstack-branches/src/utils/create-branch.ts b/packages/contentstack-branches/src/utils/create-branch.ts index 2eb6f5d92..fd0691a93 100644 --- a/packages/contentstack-branches/src/utils/create-branch.ts +++ b/packages/contentstack-branches/src/utils/create-branch.ts @@ -1,6 +1,6 @@ import { cliux, managementSDKClient } from '@contentstack/cli-utilities'; -export async function createBranch(host: string, apiKey: string, branch: { uid: string; source: string }) { +export async function createBranch(host: string, apiKey: string, branch: { source: string; uid: string }) { const managementAPIClient = await managementSDKClient({ host }); managementAPIClient .stack({ api_key: apiKey }) @@ -11,7 +11,7 @@ export async function createBranch(host: string, apiKey: string, branch: { uid: 'Branch creation in progress. Once ready, it will show in the results of the branch list command `csdx cm:branches`', ), ) - .catch((err: { errorCode: number; errorMessage: string, errors:any }) => { + .catch((err: { errorCode: number; errorMessage: string, errors: any }) => { if (err.errorCode === 910) cliux.error(`error : Branch with UID '${branch.uid}' already exists, please enter a unique branch UID`); else if (err.errorCode === 903){ diff --git a/packages/contentstack-branches/src/utils/create-merge-scripts.ts b/packages/contentstack-branches/src/utils/create-merge-scripts.ts index bc1c7c3b3..0d2e08abe 100644 --- a/packages/contentstack-branches/src/utils/create-merge-scripts.ts +++ b/packages/contentstack-branches/src/utils/create-merge-scripts.ts @@ -1,15 +1,16 @@ +import { cliux, formatDate, formatTime } from '@contentstack/cli-utilities'; import fs from 'fs'; -import { cliux, formatTime, formatDate } from '@contentstack/cli-utilities'; + +import { assetFolderCreateScript } from './asset-folder-create-script'; import { entryCreateScript } from './entry-create-script'; -import { entryUpdateScript } from './entry-update-script'; import { entryCreateUpdateScript } from './entry-create-update-script'; -import { assetFolderCreateScript } from './asset-folder-create-script'; +import { entryUpdateScript } from './entry-update-script'; type CreateMergeScriptsProps = { - uid: string; entry_merge_strategy?: string; - type?: string; status?: string; + type?: string; + uid: string; }; export function generateMergeScripts(mergeSummary, mergeJobUID) { @@ -28,8 +29,8 @@ export function generateMergeScripts(mergeSummary, mergeJobUID) { const mergeStrategies = { asset_create_folder: assetFolderCreateScript, - merge_existing_new: entryCreateUpdateScript, merge_existing: entryUpdateScript, + merge_existing_new: entryCreateUpdateScript, merge_new: entryCreateScript, }; @@ -45,7 +46,7 @@ export function generateMergeScripts(mergeSummary, mergeJobUID) { } }; - processContentType({ type: 'assets', uid: '', entry_merge_strategy: '' }, mergeStrategies['asset_create_folder']); + processContentType({ entry_merge_strategy: '', type: 'assets', uid: '' }, mergeStrategies['asset_create_folder']); processContentTypes(mergeSummary.modified, 'Modified'); processContentTypes(mergeSummary.added, 'New'); @@ -90,7 +91,7 @@ export function createMergeScripts(contentType: CreateMergeScriptsProps, mergeJo fs.mkdirSync(fullPath); } let filePath: string; - let milliSeconds = date.getMilliseconds().toString().padStart(3, '0'); + const milliSeconds = date.getMilliseconds().toString().padStart(3, '0'); if (contentType.type === 'assets') { filePath = `${fullPath}/${fileCreatedAt}${milliSeconds}_create_assets_folder.js`; } else { diff --git a/packages/contentstack-branches/src/utils/csv-utility.ts b/packages/contentstack-branches/src/utils/csv-utility.ts index f1328099d..0d5d7e513 100644 --- a/packages/contentstack-branches/src/utils/csv-utility.ts +++ b/packages/contentstack-branches/src/utils/csv-utility.ts @@ -1,7 +1,8 @@ -import { writeFileSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; import { cliux, log, sanitizePath } from '@contentstack/cli-utilities'; -import { BranchDiffVerboseRes, CSVRow, ModifiedFieldsInput, ContentTypeItem, AddCSVRowParams, FIELD_TYPES, CSV_HEADER } from '../interfaces'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { AddCSVRowParams, BranchDiffVerboseRes, CSV_HEADER, CSVRow, ContentTypeItem, FIELD_TYPES, ModifiedFieldsInput } from '../interfaces'; /** * Get display name for a field with special handling for system fields @@ -95,7 +96,7 @@ export function formatValue(value: any): string { * @param path - Array of path segments * @returns The value at the path, or undefined if not found */ -export function extractValueFromPath(obj: any, path: (string | number)[]): any { +export function extractValueFromPath(obj: any, path: (number | string)[]): any { if (!obj || !path || path.length === 0) return undefined; try { @@ -179,11 +180,11 @@ function addContentTypeRows( const contentTypeName = item?.title || item?.uid || 'Unknown'; addCSVRow(csvRows, { - srNo: getSrNo(), contentTypeName, fieldName: 'Content Type', fieldType: operation, sourceValue: 'N/A', + srNo: getSrNo(), targetValue: 'N/A' }); } @@ -227,12 +228,12 @@ export function generateCSVDataFromVerbose(verboseRes: BranchDiffVerboseRes): CS */ function addCSVRow(csvRows: CSVRow[], params: AddCSVRowParams): void { csvRows.push({ - srNo: params.srNo, contentTypeName: params.contentTypeName, fieldName: params.fieldName, fieldPath: 'N/A', operation: params.fieldType, sourceBranchValue: params.sourceValue, + srNo: params.srNo, targetBranchValue: params.targetValue, }); } @@ -261,21 +262,21 @@ function addFieldChangesToCSV(csvRows: CSVRow[], params: { if (field.propertyChanges?.length > 0) { field.propertyChanges.forEach(propertyChange => { addCSVRow(csvRows, { - srNo: srNo++, contentTypeName: params.contentTypeName, fieldName, fieldType, sourceValue: formatValue(propertyChange.newValue), + srNo: srNo++, targetValue: formatValue(propertyChange.oldValue) }); }); } else { addCSVRow(csvRows, { - srNo: srNo++, contentTypeName: params.contentTypeName, fieldName, fieldType, sourceValue: fieldType === 'added' ? 'N/A' : formatValue(field), + srNo: srNo++, targetValue: fieldType === 'deleted' ? 'N/A' : formatValue(field) }); } diff --git a/packages/contentstack-branches/src/utils/delete-branch.ts b/packages/contentstack-branches/src/utils/delete-branch.ts index 9c691cbd9..bdb3940bb 100644 --- a/packages/contentstack-branches/src/utils/delete-branch.ts +++ b/packages/contentstack-branches/src/utils/delete-branch.ts @@ -1,4 +1,5 @@ import { cliux, managementSDKClient } from '@contentstack/cli-utilities'; + import { refreshbranchConfig } from '.'; export async function deleteBranch(host: string, apiKey: string, uid: string) { diff --git a/packages/contentstack-branches/src/utils/index.ts b/packages/contentstack-branches/src/utils/index.ts index 64645a298..6220fabc8 100644 --- a/packages/contentstack-branches/src/utils/index.ts +++ b/packages/contentstack-branches/src/utils/index.ts @@ -1,10 +1,11 @@ /** * Command specific utilities can be written here */ +import { cliux, configHandler, messageHandler, sanitizePath } from '@contentstack/cli-utilities'; import fs from 'fs'; -import path from 'path'; import forEach from 'lodash/forEach'; -import { configHandler, cliux, messageHandler, sanitizePath } from '@contentstack/cli-utilities'; +import path from 'path'; + import { MergeParams } from '../interfaces'; export const getbranchesList = (branchResult, baseBranch: string) => { @@ -12,10 +13,10 @@ export const getbranchesList = (branchResult, baseBranch: string) => { branchResult.map((item) => { branches.push({ - Branch: item.uid, - Source: item.source, Aliases: item.alias, + Branch: item.uid, Created: new Date(item.created_at).toLocaleDateString(), + Source: item.source, Updated: new Date(item.updated_at).toLocaleDateString(), }); }); @@ -23,7 +24,7 @@ export const getbranchesList = (branchResult, baseBranch: string) => { const currentBranch = branches.filter((branch) => branch.Branch === baseBranch); const otherBranches = branches.filter((branch) => branch.Branch !== baseBranch); - return { currentBranch, otherBranches, branches }; + return { branches, currentBranch, otherBranches }; }; export const getbranchConfig = (stackApiKey: string) => { @@ -78,8 +79,8 @@ export async function getMergeQueueStatus(stackAPIClient, payload): Promise export async function executeMergeRequest(stackAPIClient, payload): Promise { const { - host, apiKey, + host, params: { base_branch, compare_branch, default_merge_strategy, item_merge_strategies, merge_comment, no_revert }, } = payload; const queryParams: MergeParams = { @@ -139,11 +140,12 @@ export function validateCompareData(branchCompareData) { return validCompareData; } -export * from './interactive'; -export * from './merge-helper'; +export * as branchDiffUtility from './branch-diff-utility'; export * from './create-merge-scripts'; -export * from './entry-update-script'; +export * as deleteBranchUtility from './delete-branch'; export * from './entry-create-script'; +export * from './entry-update-script'; +export * from './interactive'; export * as interactive from './interactive'; -export * as branchDiffUtility from './branch-diff-utility'; -export * as deleteBranchUtility from './delete-branch'; +export * from './merge-helper'; +export * from './merge-status-helper'; diff --git a/packages/contentstack-branches/src/utils/interactive.ts b/packages/contentstack-branches/src/utils/interactive.ts index dc8730fd6..73356b848 100644 --- a/packages/contentstack-branches/src/utils/interactive.ts +++ b/packages/contentstack-branches/src/utils/interactive.ts @@ -1,81 +1,81 @@ -import isEmpty from 'lodash/isEmpty'; -import startCase from 'lodash/startCase'; +import { cliux, messageHandler, validatePath } from '@contentstack/cli-utilities'; import camelCase from 'lodash/camelCase'; import forEach from 'lodash/forEach'; +import isEmpty from 'lodash/isEmpty'; +import startCase from 'lodash/startCase'; import path from 'path'; -import { cliux, messageHandler, validatePath } from '@contentstack/cli-utilities'; import { BranchDiffRes } from '../interfaces'; export async function selectModule(): Promise { return await cliux.inquire({ - type: 'list', - name: 'module', - message: 'CLI_BRANCH_MODULE', choices: [ { name: 'Content Types', value: 'content-types' }, { name: 'Global Fields', value: 'global-fields' }, { name: 'All', value: 'all' }, ], + message: 'CLI_BRANCH_MODULE', + name: 'module', + type: 'list', validate: inquireRequireFieldValidation, }); } export async function askCompareBranch(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_COMPARE_BRANCH', name: 'compare_branch', + type: 'input', validate: inquireRequireFieldValidation, }); } export async function askStackAPIKey(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_STACK_API_KEY', name: 'api_key', + type: 'input', validate: inquireRequireFieldValidation, }); } export async function askBaseBranch(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_BASE_BRANCH', name: 'branch_branch', + type: 'input', validate: inquireRequireFieldValidation, }); } export async function askSourceBranch(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_SOURCE_BRANCH', name: 'source_branch', + type: 'input', validate: inquireRequireFieldValidation, }); } export async function askBranchUid(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_BRANCH_UID', name: 'branch_uid', + type: 'input', validate: inquireRequireFieldValidation, }); } export async function askConfirmation(): Promise { const resp = await cliux.inquire({ - type: 'confirm', message: 'Are you sure you want to delete this branch?', name: 'confirm', + type: 'confirm', }); return resp; } -export function inquireRequireFieldValidation(input: any): string | boolean { +export function inquireRequireFieldValidation(input: any): boolean | string { if (isEmpty(input)) { return messageHandler.parse('CLI_BRANCH_REQUIRED_FIELD'); } @@ -85,8 +85,6 @@ export function inquireRequireFieldValidation(input: any): string | boolean { export async function selectMergeStrategy(): Promise { const strategy = await cliux .inquire({ - type: 'list', - name: 'module', choices: [ { name: 'Merge, Prefer Base', value: 'merge_prefer_base' }, { name: 'Merge, Prefer Compare', value: 'merge_prefer_compare' }, @@ -94,6 +92,8 @@ export async function selectMergeStrategy(): Promise { { name: 'Overwrite with Compare', value: 'overwrite_with_compare' }, ], message: 'What merge strategy would you like to choose? ', + name: 'module', + type: 'list', }) .then((name) => name as string) .catch((err) => { @@ -107,8 +107,6 @@ export async function selectMergeStrategy(): Promise { export async function selectMergeStrategySubOptions(): Promise { const strategy = await cliux .inquire({ - type: 'list', - name: 'module', choices: [ { name: 'New in Compare Only', value: 'new' }, { name: 'Modified Only', value: 'modified' }, @@ -117,6 +115,8 @@ export async function selectMergeStrategySubOptions(): Promise { { name: 'Start Over', value: 'restart' }, ], message: 'What do you want to merge?', + name: 'module', + type: 'list', }) .then((name) => name as string) .catch((err) => { @@ -130,8 +130,6 @@ export async function selectMergeStrategySubOptions(): Promise { export async function selectMergeExecution(): Promise { const strategy = await cliux .inquire({ - type: 'list', - name: 'module', choices: [ { name: 'Execute Merge', value: 'both' }, { name: 'Export Merge Summary', value: 'export' }, @@ -141,6 +139,8 @@ export async function selectMergeExecution(): Promise { { name: 'Start Over', value: 'restart' }, ], message: 'What would you like to do?', + name: 'module', + type: 'list', }) .then((name) => name as string) .catch((err) => { @@ -154,8 +154,6 @@ export async function selectMergeExecution(): Promise { export async function selectContentMergePreference(): Promise { const strategy = await cliux .inquire({ - type: 'list', - name: 'module', choices: [ { name: 'Both existing and new', value: 'existing_new' }, { name: 'New only', value: 'new' }, @@ -163,6 +161,8 @@ export async function selectContentMergePreference(): Promise { { name: 'Ask for preference', value: 'ask_preference' }, ], message: 'What content entries do you want to migrate?', + name: 'module', + type: 'list', }) .then((name) => name as string) .catch((err) => { @@ -175,9 +175,9 @@ export async function selectContentMergePreference(): Promise { export async function askExportMergeSummaryPath(): Promise { return await cliux.inquire({ - type: 'input', message: 'Enter the file path to export the summary', name: 'filePath', + type: 'input', validate: inquireRequireFieldValidation, }); } @@ -185,9 +185,9 @@ export async function askExportMergeSummaryPath(): Promise { export async function askMergeComment(): Promise { return await cliux.inquire({ - type: 'input', message: 'Enter a comment for merge', name: 'comment', + type: 'input', validate: inquireRequireFieldValidation, }); } @@ -226,11 +226,6 @@ export async function selectCustomPreferences(module, payload) { } const selectedStrategies = await cliux.inquire({ - type: 'table', - message: `Select the ${startCase(camelCase(module))} changes for merge`, - name: 'mergeContentTypePreferences', - selectAll: true, - pageSize: 10, columns: [ { name: 'Merge Prefer Base', @@ -249,10 +244,15 @@ export async function selectCustomPreferences(module, payload) { value: 'ignore', }, ], + message: `Select the ${startCase(camelCase(module))} changes for merge`, + name: 'mergeContentTypePreferences', + pageSize: 10, rows: tableRows, + selectAll: true, + type: 'table', }); - let updatedArray = []; + const updatedArray = []; forEach(selectedStrategies, (strategy: string, index: number) => { const selectedItem = tableRows[index]; if (strategy && selectedItem) { @@ -267,9 +267,9 @@ export async function selectCustomPreferences(module, payload) { export async function askBranchNameConfirmation(): Promise { return await cliux.inquire({ - type: 'input', message: 'CLI_BRANCH_NAME_CONFIRMATION', name: 'branch_name', + type: 'input', validate: inquireRequireFieldValidation, }); } @@ -298,11 +298,6 @@ export async function selectContentMergeCustomPreferences(payload) { } const selectedStrategies = await cliux.inquire({ - type: 'table', - message: `Select the Content Entry changes for merge`, - name: 'mergeContentEntriesPreferences', - selectAll: true, - pageSize: 10, columns: [ { name: 'Merge New Only', @@ -321,10 +316,15 @@ export async function selectContentMergeCustomPreferences(payload) { value: 'ignore', }, ], + message: `Select the Content Entry changes for merge`, + name: 'mergeContentEntriesPreferences', + pageSize: 10, rows: tableRows, + selectAll: true, + type: 'table', }); - let updatedArray = []; + const updatedArray = []; forEach(selectedStrategies, (strategy: string, index: number) => { const selectedItem = tableRows[index]; diff --git a/packages/contentstack-branches/src/utils/merge-helper.ts b/packages/contentstack-branches/src/utils/merge-helper.ts index 2fbcef286..404c2d0d3 100644 --- a/packages/contentstack-branches/src/utils/merge-helper.ts +++ b/packages/contentstack-branches/src/utils/merge-helper.ts @@ -1,18 +1,19 @@ -import startCase from 'lodash/startCase'; +import { cliux, managementSDKClient } from '@contentstack/cli-utilities'; import camelCase from 'lodash/camelCase'; +import startCase from 'lodash/startCase'; import path from 'path'; -import { cliux, managementSDKClient } from '@contentstack/cli-utilities'; + import { BranchDiffPayload, MergeSummary } from '../interfaces'; import { + askBaseBranch, askCompareBranch, askStackAPIKey, - askBaseBranch, - getbranchConfig, branchDiffUtility as branchDiff, - writeFile, executeMergeRequest, getMergeQueueStatus, + getbranchConfig, readFile, + writeFile, } from './'; export const prepareMergeRequestPayload = (options) => { @@ -50,12 +51,12 @@ function validateMergeSummary(mergeSummary: MergeSummary) { export const setupMergeInputs = async (mergeFlags) => { if (mergeFlags['use-merge-summary']) { - let mergeSummary: MergeSummary = (await readFile(mergeFlags['use-merge-summary'])) as MergeSummary; + const mergeSummary: MergeSummary = (await readFile(mergeFlags['use-merge-summary'])) as MergeSummary; validateMergeSummary(mergeSummary); mergeFlags.mergeSummary = mergeSummary; } - let { requestPayload: { base_branch = null, compare_branch = null } = {} } = mergeFlags.mergeSummary || {}; + const { requestPayload: { base_branch = null, compare_branch = null } = {} } = mergeFlags.mergeSummary || {}; if (!mergeFlags['stack-api-key']) { mergeFlags['stack-api-key'] = await askStackAPIKey(); @@ -85,12 +86,12 @@ export const setupMergeInputs = async (mergeFlags) => { export const displayBranchStatus = async (options) => { const spinner = cliux.loaderV2('Loading branch differences...'); - let payload: BranchDiffPayload = { - module: '', + const payload: BranchDiffPayload = { apiKey: options.stackAPIKey, baseBranch: options.baseBranch, compareBranch: options.compareBranch, host: options.host, + module: '', }; payload.spinner = spinner; @@ -98,8 +99,8 @@ export const displayBranchStatus = async (options) => { const diffData = branchDiff.filterBranchDiffDataByModule(branchDiffData); cliux.loaderV2('', spinner); - let parsedResponse = {}; - for (let module in diffData) { + const parsedResponse = {}; + for (const module in diffData) { const branchModuleData = diffData[module]; payload.module = module; cliux.print(' '); @@ -125,7 +126,7 @@ export const displayBranchStatus = async (options) => { export const displayMergeSummary = (options) => { cliux.print(' '); cliux.print(`Merge Summary:`, { color: 'yellow' }); - for (let module in options.compareData) { + for (const module in options.compareData) { if (options.format === 'compact-text') { branchDiff.printCompactTextView(options.compareData[module]); } else if (options.format === 'detailed-text') { @@ -135,6 +136,16 @@ export const displayMergeSummary = (options) => { cliux.print(' '); }; +/** + * Executes a merge request and waits for completion with limited polling. + * If the merge is in_progress, polls for status with max 10 retries and exponential backoff. + * Returns immediately if merge is complete, throws error if failed. + * + * @param apiKey - Stack API key + * @param mergePayload - Merge request payload + * @param host - API host + * @returns Promise - Merge response with status and details + */ export const executeMerge = async (apiKey, mergePayload, host): Promise => { const stackAPIClient = await (await managementSDKClient({ host })).stack({ api_key: apiKey }); const mergeResponse = await executeMergeRequest(stackAPIClient, { params: mergePayload }); @@ -147,31 +158,61 @@ export const executeMerge = async (apiKey, mergePayload, host): Promise => } }; -export const fetchMergeStatus = async (stackAPIClient, mergePayload, delay = 5000): Promise => { - return new Promise(async (resolve, reject) => { +/** + * Fetches merge status with retry-limited polling (max 10 attempts) and exponential backoff. + * Returns a structured response on polling timeout instead of throwing an error. + * + * @param stackAPIClient - The stack API client for making requests + * @param mergePayload - The merge payload containing the UID + * @param initialDelay - Initial delay between retries in milliseconds (default: 5000ms) + * @param maxRetries - Maximum number of retry attempts (default: 10) + * @returns Promise - Merge response object with optional pollingTimeout flag + */ +export const fetchMergeStatus = async ( + stackAPIClient, + mergePayload, + initialDelay = 5000, + maxRetries = 10 +): Promise => { + let delayMs = initialDelay; + const maxDelayMs = 60000; // Cap delay at 60 seconds + + for (let attempt = 1; attempt <= maxRetries; attempt++) { const mergeStatusResponse = await getMergeQueueStatus(stackAPIClient, { uid: mergePayload.uid }); if (mergeStatusResponse?.queue?.length >= 1) { const mergeRequestStatusResponse = mergeStatusResponse.queue[0]; const mergeStatus = mergeRequestStatusResponse.merge_details?.status; + if (mergeStatus === 'complete') { - resolve(mergeRequestStatusResponse); + return mergeRequestStatusResponse; } else if (mergeStatus === 'in-progress' || mergeStatus === 'in_progress') { - setTimeout(async () => { - await fetchMergeStatus(stackAPIClient, mergePayload, delay).then(resolve).catch(reject); - }, delay); + if (attempt < maxRetries) { + cliux.print(`Merge in progress... (Attempt ${attempt}/${maxRetries})`, { color: 'grey' }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs + 1000, maxDelayMs); + } else { + // Polling timeout: return structured response instead of throwing + cliux.print(`Merge in progress... (Attempt ${attempt}/${maxRetries})`, { color: 'grey' }); + return { + merge_details: mergeRequestStatusResponse.merge_details, + pollingTimeout: true, + status: 'in_progress', + uid: mergePayload.uid, + }; + } } else if (mergeStatus === 'failed') { if (mergeRequestStatusResponse?.errors?.length > 0) { const errorPath = path.join(process.cwd(), 'merge-error.log'); await writeFile(errorPath, mergeRequestStatusResponse.errors); cliux.print(`\nComplete error log can be found in ${path.resolve(errorPath)}`, { color: 'grey' }); } - return reject(`merge uid: ${mergePayload.uid}`); + throw new Error(`merge uid: ${mergePayload.uid}`); } else { - return reject(`Invalid merge status found with merge ID ${mergePayload.uid}`); + throw new Error(`Invalid merge status found with merge ID ${mergePayload.uid}`); } } else { - return reject(`No queue found with merge ID ${mergePayload.uid}`); + throw new Error(`No queue found with merge ID ${mergePayload.uid}`); } - }); + } }; diff --git a/packages/contentstack-branches/src/utils/merge-status-helper.ts b/packages/contentstack-branches/src/utils/merge-status-helper.ts new file mode 100644 index 000000000..4f4d4f83c --- /dev/null +++ b/packages/contentstack-branches/src/utils/merge-status-helper.ts @@ -0,0 +1,165 @@ +import { cliux } from '@contentstack/cli-utilities'; + +import { getMergeQueueStatus } from './'; + +/** + * Maps merge status to a user-friendly message with visual indicator. + * @param status - The merge status (complete, in_progress, failed, or unknown) + * @returns User-friendly status message + */ +export const getMergeStatusMessage = (status: string): string => { + switch (status) { + case 'complete': + return '✅ Merge completed successfully'; + case 'in_progress': + case 'in-progress': + return '⏳ Merge is still processing'; + case 'failed': + return '❌ Merge failed'; + default: + return '⚠️ Unknown status'; + } +}; + +/** + * Formats and displays merge status details in a user-friendly format. + * Shows merge metadata, summary statistics, and errors if present. + * @param mergeResponse - The merge response object containing status details + */ +export const displayMergeStatusDetails = (mergeResponse: any): void => { + if (!mergeResponse) { + cliux.print('No merge information available', { color: 'yellow' }); + return; + } + + const { errors = [], merge_details = {}, merge_summary = {}, uid } = mergeResponse; + const status = merge_details.status || 'unknown'; + const statusMessage = getMergeStatusMessage(status); + + const statusColor = getStatusColor(status); + + cliux.print(' '); + cliux.print(`${statusMessage}`, { color: statusColor }); + + cliux.print(' '); + cliux.print('Merge Details:', { color: 'cyan' }); + cliux.print(` ├─ Merge UID: ${uid}`, { color: 'grey' }); + + if (merge_details.created_at) { + cliux.print(` ├─ Created: ${merge_details.created_at}`, { color: 'grey' }); + } + + if (merge_details.updated_at) { + cliux.print(` ├─ Updated: ${merge_details.updated_at}`, { color: 'grey' }); + } + + if (merge_details.completed_at && status === 'complete') { + cliux.print(` ├─ Completed: ${merge_details.completed_at}`, { color: 'grey' }); + } + + if (merge_details.completion_percentage !== undefined && status === 'in_progress') { + cliux.print(` ├─ Progress: ${merge_details.completion_percentage}%`, { color: 'grey' }); + } + + const statusIndicator = status === 'complete' ? ' ✓' : ''; + cliux.print(` └─ Status: ${status}${statusIndicator}`, { color: 'grey' }); + + displayMergeSummary(merge_summary); + displayMergeErrors(errors); + + cliux.print(' '); +}; + +/** + * Gets the appropriate color for the status message + * @param status - The merge status + * @returns The color name (green, red, or yellow) + */ +const getStatusColor = (status: string): 'green' | 'red' | 'yellow' => { + if (status === 'complete') return 'green'; + if (status === 'failed') return 'red'; + return 'yellow'; +}; + +/** + * Displays the merge summary statistics + * @param merge_summary - The merge summary object containing content types and global fields stats + */ +const displayMergeSummary = (merge_summary: any): void => { + if (!merge_summary || (!merge_summary.content_types && !merge_summary.global_fields)) { + return; + } + + cliux.print(' '); + cliux.print('Summary:', { color: 'cyan' }); + + if (merge_summary.content_types) { + const ct = merge_summary.content_types; + const added = ct.added || 0; + const modified = ct.modified || 0; + const deleted = ct.deleted || 0; + cliux.print(` ├─ Content Types: +${added}, ~${modified}, -${deleted}`, { color: 'grey' }); + } + + if (merge_summary.global_fields) { + const gf = merge_summary.global_fields; + const added = gf.added || 0; + const modified = gf.modified || 0; + const deleted = gf.deleted || 0; + cliux.print(` └─ Global Fields: +${added}, ~${modified}, -${deleted}`, { color: 'grey' }); + } +}; + +/** + * Displays merge errors if any exist + * @param errors - Array of error objects to display + */ +const displayMergeErrors = (errors: any[]): void => { + if (!errors || errors.length === 0) { + return; + } + + cliux.print(' '); + cliux.print('Errors:', { color: 'red' }); + errors.forEach((error, index) => { + const isLast = index === errors.length - 1; + const prefix = isLast ? '└─' : '├─'; + cliux.print(` ${prefix} ${error.message || error}`, { color: 'grey' }); + }); +}; + +/** + * Fetches merge status and extracts content type data for script generation. + * Validates that the merge status is 'complete' before returning content type data. + * @param stackAPIClient - The stack API client for making requests + * @param mergeUID - The merge job UID + * @returns Promise - Merge status response with content type data or error + */ +export const getMergeStatusWithContentTypes = async ( + stackAPIClient, + mergeUID: string +): Promise => { + try { + const mergeStatusResponse = await getMergeQueueStatus(stackAPIClient, { uid: mergeUID }); + + if (!mergeStatusResponse?.queue?.length) { + throw new Error(`No merge job found with UID: ${mergeUID}`); + } + + const mergeRequestStatusResponse = mergeStatusResponse.queue[0]; + const mergeStatus = mergeRequestStatusResponse.merge_details?.status; + + if (mergeStatus !== 'complete') { + return { + error: `Merge job is not complete. Current status: ${mergeStatus}`, + merge_details: mergeRequestStatusResponse.merge_details, + status: mergeStatus, + uid: mergeUID, + }; + } + + return mergeRequestStatusResponse; + } catch (error) { + throw new Error(`Failed to fetch merge status: ${error.message || error}`); + } +}; diff --git a/packages/contentstack-branches/test/unit/commands/cm/branches/generate-scripts.test.ts b/packages/contentstack-branches/test/unit/commands/cm/branches/generate-scripts.test.ts new file mode 100644 index 000000000..955106224 --- /dev/null +++ b/packages/contentstack-branches/test/unit/commands/cm/branches/generate-scripts.test.ts @@ -0,0 +1,55 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { cliux } from '@contentstack/cli-utilities'; +import BranchGenerateScriptsCommand from '../../../../../src/commands/cm/branches/generate-scripts'; +import * as utils from '../../../../../src/utils'; + +describe('Generate Scripts Command', () => { + let printStub; + let loaderStub; + let errorStub; + let successStub; + let isAuthenticatedStub; + let managementSDKClientStub; + let getMergeStatusWithContentTypesStub; + + beforeEach(() => { + printStub = stub(cliux, 'print'); + loaderStub = stub(cliux, 'loaderV2').returns('spinner'); + errorStub = stub(cliux, 'error'); + successStub = stub(cliux, 'success'); + isAuthenticatedStub = stub().returns(true); + managementSDKClientStub = stub(); + getMergeStatusWithContentTypesStub = stub(utils, 'getMergeStatusWithContentTypes'); + }); + + afterEach(() => { + printStub.restore(); + loaderStub.restore(); + errorStub.restore(); + successStub.restore(); + isAuthenticatedStub.restore(); + managementSDKClientStub.restore(); + getMergeStatusWithContentTypesStub.restore(); + }); + + it('should have correct description', () => { + expect(BranchGenerateScriptsCommand.description).to.equal('Generate entry migration scripts for a completed merge job'); + }); + + it('should have correct usage', () => { + expect(BranchGenerateScriptsCommand.usage).to.equal('cm:branches:generate-scripts -k --merge-uid '); + }); + + it('should have example command', () => { + expect(BranchGenerateScriptsCommand.examples.length).to.be.greaterThan(0); + expect(BranchGenerateScriptsCommand.examples[0]).to.include('generate-scripts'); + expect(BranchGenerateScriptsCommand.examples[0]).to.include('merge_abc123'); + }); + + it('should have required flags', () => { + expect(BranchGenerateScriptsCommand.flags['stack-api-key'].required).to.be.true; + expect(BranchGenerateScriptsCommand.flags['merge-uid'].required).to.be.true; + }); +}); diff --git a/packages/contentstack-branches/test/unit/commands/cm/branches/merge-status.test.ts b/packages/contentstack-branches/test/unit/commands/cm/branches/merge-status.test.ts new file mode 100644 index 000000000..a43a6d66f --- /dev/null +++ b/packages/contentstack-branches/test/unit/commands/cm/branches/merge-status.test.ts @@ -0,0 +1,49 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { cliux } from '@contentstack/cli-utilities'; +import BranchMergeStatusCommand from '../../../../../src/commands/cm/branches/merge-status'; +import * as utils from '../../../../../src/utils'; + +describe('Merge Status Command', () => { + let printStub; + let loaderStub; + let isAuthenticatedStub; + let managementSDKClientStub; + let displayMergeStatusDetailsStub; + + beforeEach(() => { + printStub = stub(cliux, 'print'); + loaderStub = stub(cliux, 'loaderV2').returns('spinner'); + isAuthenticatedStub = stub().returns(true); + managementSDKClientStub = stub(); + displayMergeStatusDetailsStub = stub(utils, 'displayMergeStatusDetails'); + }); + + afterEach(() => { + printStub.restore(); + loaderStub.restore(); + isAuthenticatedStub.restore(); + managementSDKClientStub.restore(); + displayMergeStatusDetailsStub.restore(); + }); + + it('should have correct description', () => { + expect(BranchMergeStatusCommand.description).to.equal('Check the status of a branch merge job'); + }); + + it('should have correct usage', () => { + expect(BranchMergeStatusCommand.usage).to.equal('cm:branches:merge-status -k --merge-uid '); + }); + + it('should have example command', () => { + expect(BranchMergeStatusCommand.examples.length).to.be.greaterThan(0); + expect(BranchMergeStatusCommand.examples[0]).to.include('merge-status'); + expect(BranchMergeStatusCommand.examples[0]).to.include('merge_abc123'); + }); + + it('should have required flags', () => { + expect(BranchMergeStatusCommand.flags['stack-api-key'].required).to.be.true; + expect(BranchMergeStatusCommand.flags['merge-uid'].required).to.be.true; + }); +}); diff --git a/packages/contentstack-branches/test/unit/utils/merge-status-helper.test.ts b/packages/contentstack-branches/test/unit/utils/merge-status-helper.test.ts new file mode 100644 index 000000000..61bc2e047 --- /dev/null +++ b/packages/contentstack-branches/test/unit/utils/merge-status-helper.test.ts @@ -0,0 +1,229 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { cliux } from '@contentstack/cli-utilities'; +import { displayMergeStatusDetails, getMergeStatusMessage, getMergeStatusWithContentTypes } from '../../../../../src/utils/merge-status-helper'; +import * as utils from '../../../../../src/utils'; + +describe('Merge Status Helper', () => { + let printStub; + + beforeEach(() => { + printStub = stub(cliux, 'print'); + }); + + afterEach(() => { + printStub.restore(); + }); + + describe('getMergeStatusMessage', () => { + it('should return complete status message for complete status', () => { + const message = getMergeStatusMessage('complete'); + expect(message).to.equal('✅ Merge completed successfully'); + }); + + it('should return in_progress status message for in_progress status', () => { + const message = getMergeStatusMessage('in_progress'); + expect(message).to.equal('⏳ Merge is still processing'); + }); + + it('should return in_progress status message for in-progress status', () => { + const message = getMergeStatusMessage('in-progress'); + expect(message).to.equal('⏳ Merge is still processing'); + }); + + it('should return failed status message for failed status', () => { + const message = getMergeStatusMessage('failed'); + expect(message).to.equal('❌ Merge failed'); + }); + + it('should return unknown status message for unknown status', () => { + const message = getMergeStatusMessage('unknown'); + expect(message).to.equal('⚠️ Unknown status'); + }); + }); + + describe('displayMergeStatusDetails', () => { + it('should display merge status details for completed merge', () => { + const mergeResponse = { + uid: 'merge_123', + merge_details: { + status: 'complete', + created_at: '2024-01-01T10:00:00Z', + updated_at: '2024-01-01T10:30:00Z', + completed_at: '2024-01-01T10:30:00Z', + }, + merge_summary: { + content_types: { added: 2, modified: 3, deleted: 1 }, + global_fields: { added: 0, modified: 1, deleted: 0 }, + }, + errors: [], + }; + + displayMergeStatusDetails(mergeResponse); + + expect(printStub.called).to.be.true; + const calls = printStub.getCalls(); + const printed = calls.map((c) => c.args[0]).join(' '); + expect(printed).to.include('merge_123'); + expect(printed).to.include('complete'); + }); + + it('should display merge status details for in-progress merge', () => { + const mergeResponse = { + uid: 'merge_456', + merge_details: { + status: 'in_progress', + created_at: '2024-01-01T10:00:00Z', + updated_at: '2024-01-01T10:15:00Z', + completion_percentage: 60, + }, + merge_summary: { + content_types: { added: 1, modified: 2, deleted: 0 }, + global_fields: { added: 0, modified: 0, deleted: 0 }, + }, + errors: [], + }; + + displayMergeStatusDetails(mergeResponse); + + expect(printStub.called).to.be.true; + const calls = printStub.getCalls(); + const printed = calls.map((c) => c.args[0]).join(' '); + expect(printed).to.include('merge_456'); + expect(printed).to.include('in_progress'); + expect(printed).to.include('60'); + }); + + it('should display merge status details with errors', () => { + const mergeResponse = { + uid: 'merge_789', + merge_details: { + status: 'failed', + created_at: '2024-01-01T10:00:00Z', + updated_at: '2024-01-01T10:20:00Z', + }, + merge_summary: { + content_types: { added: 0, modified: 0, deleted: 0 }, + global_fields: { added: 0, modified: 0, deleted: 0 }, + }, + errors: [{ message: 'Content type conflict' }, { message: 'Field mismatch' }], + }; + + displayMergeStatusDetails(mergeResponse); + + expect(printStub.called).to.be.true; + const calls = printStub.getCalls(); + const printed = calls.map((c) => c.args[0]).join(' '); + expect(printed).to.include('merge_789'); + expect(printed).to.include('failed'); + expect(printed).to.include('conflict'); + }); + + it('should handle null merge response gracefully', () => { + displayMergeStatusDetails(null); + + expect(printStub.called).to.be.true; + const calls = printStub.getCalls(); + expect(calls[0].args[0]).to.equal('No merge information available'); + }); + + it('should handle undefined merge response gracefully', () => { + displayMergeStatusDetails(undefined); + + expect(printStub.called).to.be.true; + const calls = printStub.getCalls(); + expect(calls[0].args[0]).to.equal('No merge information available'); + }); + }); + + describe('getMergeStatusWithContentTypes', () => { + let getMergeQueueStatusStub; + + beforeEach(() => { + getMergeQueueStatusStub = stub(utils, 'getMergeQueueStatus'); + }); + + afterEach(() => { + getMergeQueueStatusStub.restore(); + }); + + it('should return merge response when merge is complete', async () => { + const mockMergeResponse = { + queue: [ + { + uid: 'merge_complete', + merge_details: { status: 'complete' }, + content_types: { added: [], modified: [], deleted: [] }, + }, + ], + }; + + getMergeQueueStatusStub.resolves(mockMergeResponse); + + const result = await getMergeStatusWithContentTypes({}, 'merge_complete'); + + expect(result.uid).to.equal('merge_complete'); + expect(result.merge_details.status).to.equal('complete'); + }); + + it('should return error when merge is in_progress', async () => { + const mockMergeResponse = { + queue: [ + { + uid: 'merge_inprogress', + merge_details: { status: 'in_progress' }, + }, + ], + }; + + getMergeQueueStatusStub.resolves(mockMergeResponse); + + const result = await getMergeStatusWithContentTypes({}, 'merge_inprogress'); + + expect(result.error).to.exist; + expect(result.error).to.include('not complete'); + expect(result.status).to.equal('in_progress'); + }); + + it('should return error when merge is failed', async () => { + const mockMergeResponse = { + queue: [ + { + uid: 'merge_failed', + merge_details: { status: 'failed' }, + }, + ], + }; + + getMergeQueueStatusStub.resolves(mockMergeResponse); + + const result = await getMergeStatusWithContentTypes({}, 'merge_failed'); + + expect(result.error).to.exist; + expect(result.error).to.include('not complete'); + }); + + it('should throw error when no queue found', async () => { + getMergeQueueStatusStub.resolves({ queue: [] }); + + try { + await getMergeStatusWithContentTypes({}, 'merge_notfound'); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error.message).to.include('No merge job found'); + } + }); + + it('should throw error when response is invalid', async () => { + getMergeQueueStatusStub.resolves(null); + + try { + await getMergeStatusWithContentTypes({}, 'merge_invalid'); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error.message).to.include('No merge job found'); + } + }); + }); +});