Skip to content

Commit 29fe1df

Browse files
Merge pull request #293 from contentstack/fix/dx-3882-bulk-operation-progress-manager
feat(bulk-operations): integrate progress manager UI [DX-3882]
2 parents a3bead5 + 64a5512 commit 29fe1df

4 files changed

Lines changed: 263 additions & 11 deletions

File tree

.talismanrc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,14 @@ fileignoreconfig:
9797
checksum: f612661a8b6784b20ea62794b35192b340a34d8424a54a1a2cffec1878ab7528
9898
- filename: packages/contentstack-import/test/unit/utils/marketplace-app-helper.test.ts
9999
checksum: de694e861560c4c242100eaf17a6d8e230247b99f94e4b428e55ef065084c10c
100+
- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts
101+
checksum: 8bca423db4c3815c651ad922d986a53b6271cce3ea27f5987b66ee594151165e
102+
- filename: packages/contentstack-bulk-operations/src/messages/index.ts
103+
checksum: 07c2bf3f3130ad5e8b6a2971d76139e9b643c70a9ff5f7450adfb5c9bf3d7164
104+
- filename: packages/contentstack-bulk-operations/test/unit/utils/operation-flag-matrix.test.ts
105+
checksum: 35451ca4c03359b06531a8461091f4a3a45c4dea1f8853081fb53e4ce28c1cc3
106+
- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-init.test.ts
107+
checksum: 590b1cfe42d46d0917ac90f363b1ccd05200b180bd9c58c770ffd1f12eb18327
108+
- filename: packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts
109+
checksum: 99a3c3eb422a17f73f4c8f15088004fa4c95df3545bdf510310c1f3a20e4c2c2
100110
version: ""

packages/contentstack-bulk-operations/src/base-bulk-command.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import chalk from 'chalk';
21
import { Command } from '@contentstack/cli-command';
3-
import { flags, log, createLogContext, getLogPath, handleAndLogError, FlagInput } from '@contentstack/cli-utilities';
2+
import {
3+
flags,
4+
log,
5+
createLogContext,
6+
getLogPath,
7+
handleAndLogError,
8+
FlagInput,
9+
getChalk,
10+
loadChalk,
11+
configHandler,
12+
CLIProgressManager,
13+
clearProgressModuleSetting,
14+
} from '@contentstack/cli-utilities';
415

516
import config from './config';
617
import messages, { $t } from './messages';
@@ -33,6 +44,7 @@ import {
3344
generateBulkPublishStatusUrl,
3445
validateBranch,
3546
validateEnvironments,
47+
aggregateBatchResults,
3648
} from './utils';
3749
import {
3850
OperationType,
@@ -150,6 +162,9 @@ export abstract class BaseBulkCommand extends Command {
150162
protected async init(): Promise<void> {
151163
await super.init();
152164

165+
// Load chalk (ESM) up-front. The progress-module flag is set in buildConfig()
166+
// (config layer, mirrors export-config-handler) before the first log call.
167+
await loadChalk();
153168
if (this.shouldSkipBulkPipeline()) {
154169
return;
155170
}
@@ -233,6 +248,9 @@ export abstract class BaseBulkCommand extends Command {
233248
await this.initializeComponents();
234249

235250
await this.handleRevertOrRetry(mergedFlags);
251+
// This early exit bypasses finally(); print the run summary and clear the progress-module
252+
// flag here so it doesn't leak into the persisted config / later commands.
253+
this.finalizeProgressSummary();
236254
process.exit(0);
237255
}
238256

@@ -281,6 +299,13 @@ export abstract class BaseBulkCommand extends Command {
281299
* Build operation configuration
282300
*/
283301
protected async buildConfiguration(flags: any): Promise<void> {
302+
// Enable the progress-bar UI + suppress the timestamped console logs for the whole command.
303+
// Set here (command lifecycle, runs before the first log call) rather than in the pure
304+
// buildConfig() util so it isn't triggered by direct/unit-test callers of buildConfig.
305+
// Cleared on every exit path: finally() on normal runs, and finalizeProgressSummary() before
306+
// the validation exit(1) below and the revert/retry exit(0).
307+
configHandler.set('log.progressSupportedModule', 'bulk-operations');
308+
284309
this.bulkOperationConfig = buildConfig(flags);
285310

286311
// buildConfig splits comma-separated oclif `multiple` values; mirror onto flags so
@@ -293,6 +318,9 @@ export abstract class BaseBulkCommand extends Command {
293318

294319
const validation = validateFlags(this.bulkOperationConfig);
295320
if (!validation.valid) {
321+
// The progress-module flag was set above; this early exit bypasses finally(), so clear it
322+
// here to avoid leaking the setting into the persisted config / later commands.
323+
this.finalizeProgressSummary();
296324
process.exit(1);
297325
}
298326

@@ -378,20 +406,91 @@ export abstract class BaseBulkCommand extends Command {
378406
this.logger.debug($t(messages.EXECUTING_OPERATION, { count: items.length }), this.loggerContext);
379407
const startTime = Date.now();
380408

409+
// Initialize the run-level summary + header once (progress-manager UX, same as import).
410+
this.beginOperationSummary(items.length);
411+
412+
let result: BulkOperationResult;
381413
try {
382414
logOperationInfo(items, this.logger);
383415

384416
const publishMode = this.bulkOperationConfig.publishMode || PublishMode.BULK;
385417
this.logger.debug(`Using ${publishMode.toUpperCase()} mode for operation`, this.loggerContext);
386418

387-
if (publishMode === PublishMode.SINGLE) {
388-
return await this.executeSingleMode(items, startTime);
389-
}
390-
391-
return await this.executeBulkMode(items, startTime);
419+
result =
420+
publishMode === PublishMode.SINGLE
421+
? await this.executeSingleMode(items, startTime)
422+
: await this.executeBulkMode(items, startTime);
392423
} catch (error: any) {
393-
return handleOperationError(error, items, startTime);
424+
result = handleOperationError(error, items, startTime);
425+
}
426+
427+
this.recordModuleSummary(result, items.length);
428+
return result;
429+
}
430+
431+
/**
432+
* Initialize the run-level summary + header once. The label includes the branch, which
433+
* defaults to 'main' from the --branch flag, so it normally reads e.g. "BULK PUBLISH-main" —
434+
* the same "<OP>-<branch>" title shape export/import produce (SummaryManager uses the passed
435+
* operationName verbatim). The branch is dropped from the label only in the edge case where
436+
* it is unset. Shared with bulk-taxonomies via inheritance.
437+
*/
438+
protected beginOperationSummary(itemCount: number): void {
439+
const operationLabel = (this.bulkOperationConfig?.operation || 'operation').toString().toUpperCase();
440+
const branchName = this.bulkOperationConfig?.branch || '';
441+
CLIProgressManager.initializeGlobalSummary(
442+
branchName ? `BULK ${operationLabel}-${branchName}` : `BULK ${operationLabel}`,
443+
branchName,
444+
$t(messages.EXECUTING_OPERATION, { count: itemCount })
445+
);
446+
}
447+
448+
/**
449+
* Record a per-module row in the summary so the final summary shows Module Details for this
450+
* command (entry/asset/taxonomy).
451+
*
452+
* SINGLE mode processes items individually and returns real success/failed counts, so those
453+
* are used directly. BULK mode submits async jobs — buildBulkModeResult reports success/failed
454+
* as 0 because the publish runs server-side — so we count submission-level failures from
455+
* batchResults (recorded as status:'failed') and treat the remaining submitted items as
456+
* success. The printed status URL remains the source of truth for the real publish outcome.
457+
*/
458+
protected recordModuleSummary(result: BulkOperationResult, submittedCount: number): void {
459+
const showConsoleLogs = Boolean(configHandler.get('log')?.showConsoleLogs);
460+
const publishMode = this.bulkOperationConfig?.publishMode || PublishMode.BULK;
461+
const total = result?.total || submittedCount || 0;
462+
463+
let success: number;
464+
let failed: number;
465+
if (publishMode === PublishMode.SINGLE) {
466+
failed = result?.failed || 0;
467+
success = typeof result?.success === 'number' ? result.success : Math.max(total - failed, 0);
468+
} else {
469+
// BULK: derive failures from batch submissions (result.failed is always 0 here).
470+
failed = aggregateBatchResults(this.batchResults).totalFailed;
471+
success = Math.max(total - failed, 0);
394472
}
473+
474+
// Clamp so counts never over/under-report relative to total.
475+
failed = Math.min(Math.max(failed, 0), total);
476+
success = Math.min(Math.max(success, 0), total - failed);
477+
478+
const progress = CLIProgressManager.createSimple(this.resourceType, total, showConsoleLogs);
479+
for (let i = 0; i < success; i++) progress.tick(true);
480+
for (let i = 0; i < failed; i++) progress.tick(false);
481+
progress.complete(failed === 0);
482+
}
483+
484+
/**
485+
* Print the run-level summary once and clear progress state. Idempotent: subclasses call
486+
* finally() explicitly AND oclif calls it again, so clearing the summary after printing makes
487+
* the second invocation a no-op. Also clears the progress-module flag so it never leaks into
488+
* a later command in the same process (mirrors export/import/clone).
489+
*/
490+
protected finalizeProgressSummary(): void {
491+
CLIProgressManager.printGlobalSummary();
492+
CLIProgressManager.clearGlobalSummary();
493+
clearProgressModuleSetting();
395494
}
396495

397496
/**
@@ -457,6 +556,7 @@ export abstract class BaseBulkCommand extends Command {
457556
* Called at the end of run() method in subclasses
458557
*/
459558
protected printOperationSummary(result: BulkOperationResult): void {
559+
const chalk = getChalk();
460560
const publishMode = this.bulkOperationConfig.publishMode || PublishMode.BULK;
461561

462562
console.log('');
@@ -568,6 +668,7 @@ export abstract class BaseBulkCommand extends Command {
568668
abstract run(): Promise<void>;
569669

570670
protected async finally(_error: Error | undefined): Promise<void> {
671+
this.finalizeProgressSummary();
571672
await this.cleanup();
572673
}
573674
}

packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-taxonomies.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from '@contentstack/cli-command';
2-
import { flags, log, createLogContext, handleAndLogError } from '@contentstack/cli-utilities';
2+
import { flags, log, createLogContext, handleAndLogError, loadChalk } from '@contentstack/cli-utilities';
33

44
import messages, { $t } from '../../../messages';
55
import { BaseBulkCommand } from '../../../base-bulk-command';
@@ -58,6 +58,10 @@ export default class BulkTaxonomies extends BaseBulkCommand {
5858
// Call oclif Command init without running BaseBulkCommand.init (taxonomy uses its own prompts).
5959
await (Command.prototype as unknown as { init(this: Command): Promise<void> }).init.call(this);
6060

61+
// Load chalk (ESM) up-front. The progress-module flag is set in buildConfig() (invoked by
62+
// buildConfiguration below), before the first log call.
63+
await loadChalk();
64+
6165
let { flags: parsed } = await this.parse(BulkTaxonomies);
6266

6367
if (parsed.revert || parsed['retry-failed']) {
@@ -161,6 +165,9 @@ export default class BulkTaxonomies extends BaseBulkCommand {
161165
this.logger.debug($t(messages.EXECUTING_OPERATION, { count: items.length }), this.loggerContext);
162166
const startTime = Date.now();
163167

168+
// Initialize the run-level summary + header once (inherited from BaseBulkCommand).
169+
this.beginOperationSummary(items.length);
170+
164171
const operation = this.bulkOperationConfig.operation;
165172
if (operation !== OperationType.PUBLISH && operation !== OperationType.UNPUBLISH) {
166173
throw new Error($t(messages.UNSUPPORTED_OPERATION, { operation: operation ?? 'unknown' }));
@@ -190,12 +197,17 @@ export default class BulkTaxonomies extends BaseBulkCommand {
190197
this.logger.info(String(response.notice));
191198
}
192199

193-
return {
200+
const result: BulkOperationResult = {
194201
success: 0,
195202
failed: 0,
196203
total: items.length,
197204
duration,
198205
jobIds: jobId ? [jobId] : [],
199206
};
207+
208+
// Record the taxonomy module row in the summary (inherited from BaseBulkCommand).
209+
this.recordModuleSummary(result, items.length);
210+
211+
return result;
200212
}
201213
}

packages/contentstack-bulk-operations/test/unit/base-bulk-command.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import sinon from 'sinon';
44
import { Command } from '@contentstack/cli-command';
55
import messages, { $t } from '../../src/messages';
66
import { BaseBulkCommand } from '../../src/base-bulk-command';
7-
import { ResourceType, BulkOperationResult } from '../../src/interfaces';
7+
import { ResourceType, BulkOperationResult, PublishMode } from '../../src/interfaces';
88
import * as utils from '../../src/utils';
99

1010
class TestBulkCommand extends BaseBulkCommand {
@@ -521,6 +521,135 @@ describe('BaseBulkCommand', () => {
521521
});
522522
});
523523

524+
describe('progress manager (summary + cleanup)', () => {
525+
let cliUtils: any;
526+
let progressStub: { tick: sinon.SinonStub; complete: sinon.SinonStub };
527+
528+
beforeEach(() => {
529+
cliUtils = require('@contentstack/cli-utilities');
530+
progressStub = { tick: sandbox.stub(), complete: sandbox.stub() };
531+
sandbox.stub(cliUtils.CLIProgressManager, 'createSimple').returns(progressStub as any);
532+
sandbox.stub(cliUtils.CLIProgressManager, 'initializeGlobalSummary').returns({} as any);
533+
sandbox.stub(cliUtils.CLIProgressManager, 'printGlobalSummary').callsFake(() => {});
534+
sandbox.stub(cliUtils.CLIProgressManager, 'clearGlobalSummary').callsFake(() => {});
535+
// clearProgressModuleSetting is a frozen re-export (not stubbable); let the real one run
536+
// and drive/assert it through configHandler instead.
537+
sandbox.stub(cliUtils.configHandler, 'get').returns({ showConsoleLogs: false });
538+
sandbox.stub(cliUtils.configHandler, 'set').callsFake(() => {});
539+
});
540+
541+
describe('beginOperationSummary', () => {
542+
it('builds a "BULK <OP>-<branch>" label and passes the branch', () => {
543+
(command as any).bulkOperationConfig = { operation: 'publish', branch: 'main' };
544+
545+
(command as any).beginOperationSummary(5);
546+
547+
expect(cliUtils.CLIProgressManager.initializeGlobalSummary.calledOnce).to.be.true;
548+
const args = cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args;
549+
expect(args[0]).to.equal('BULK PUBLISH-main');
550+
expect(args[1]).to.equal('main');
551+
});
552+
553+
it('omits the branch suffix when the branch is unset', () => {
554+
(command as any).bulkOperationConfig = { operation: 'unpublish', branch: '' };
555+
556+
(command as any).beginOperationSummary(0);
557+
558+
expect(cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args[0]).to.equal('BULK UNPUBLISH');
559+
});
560+
561+
it('does not throw when bulkOperationConfig is undefined', () => {
562+
(command as any).bulkOperationConfig = undefined;
563+
564+
expect(() => (command as any).beginOperationSummary(3)).to.not.throw();
565+
expect(cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args[0]).to.equal('BULK OPERATION');
566+
});
567+
});
568+
569+
describe('recordModuleSummary', () => {
570+
it('SINGLE mode: uses the real success/failed counts from the result', () => {
571+
(command as any).bulkOperationConfig = { publishMode: PublishMode.SINGLE };
572+
573+
(command as any).recordModuleSummary({ success: 8, failed: 2, total: 10 } as BulkOperationResult, 10);
574+
575+
expect(cliUtils.CLIProgressManager.createSimple.calledWith(ResourceType.ENTRY, 10)).to.be.true;
576+
expect(progressStub.tick.withArgs(true).callCount).to.equal(8);
577+
expect(progressStub.tick.withArgs(false).callCount).to.equal(2);
578+
expect(progressStub.complete.calledWith(false)).to.be.true;
579+
});
580+
581+
it('BULK mode: derives failures from batchResults and counts the rest as submitted', () => {
582+
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
583+
// buildBulkModeResult reports success:0/failed:0; failures live in batchResults.
584+
(command as any).batchResults = new Map<string, any>([
585+
['b1', { status: 'failed', success: 0, failed: 50, jobId: '' }],
586+
['b2', { status: 'success', success: 0, failed: 0, jobId: 'job-2' }],
587+
]);
588+
589+
(command as any).recordModuleSummary({ success: 0, failed: 0, total: 126 } as BulkOperationResult, 126);
590+
591+
// failed = 50 (from batchResults); success = 126 - 50 = 76
592+
expect(progressStub.tick.withArgs(true).callCount).to.equal(76);
593+
expect(progressStub.tick.withArgs(false).callCount).to.equal(50);
594+
expect(progressStub.complete.calledWith(false)).to.be.true;
595+
});
596+
597+
it('BULK mode: with no submission failures, all submitted items count as success', () => {
598+
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
599+
(command as any).batchResults = new Map<string, any>([
600+
['b1', { status: 'success', success: 0, failed: 0, jobId: 'job-1' }],
601+
]);
602+
603+
(command as any).recordModuleSummary({ success: 0, failed: 0, total: 50 } as BulkOperationResult, 50);
604+
605+
expect(progressStub.tick.withArgs(true).callCount).to.equal(50);
606+
expect(progressStub.tick.withArgs(false).callCount).to.equal(0);
607+
expect(progressStub.complete.calledWith(true)).to.be.true;
608+
});
609+
610+
it('clamps counts so failures never exceed the total', () => {
611+
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
612+
(command as any).batchResults = new Map<string, any>([
613+
['b1', { status: 'failed', success: 0, failed: 999, jobId: '' }],
614+
]);
615+
616+
(command as any).recordModuleSummary({ success: 0, failed: 0, total: 10 } as BulkOperationResult, 10);
617+
618+
expect(progressStub.tick.withArgs(false).callCount).to.equal(10); // clamped to total
619+
expect(progressStub.tick.withArgs(true).callCount).to.equal(0);
620+
});
621+
622+
it('falls back to submittedCount when the result has no total', () => {
623+
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
624+
(command as any).batchResults = new Map<string, any>();
625+
626+
(command as any).recordModuleSummary({ success: 0, failed: 0 } as BulkOperationResult, 7);
627+
628+
expect(cliUtils.CLIProgressManager.createSimple.calledWith(ResourceType.ENTRY, 7)).to.be.true;
629+
expect(progressStub.tick.withArgs(true).callCount).to.equal(7);
630+
});
631+
});
632+
633+
describe('finalizeProgressSummary', () => {
634+
it('prints the summary and clears it', () => {
635+
(command as any).finalizeProgressSummary();
636+
637+
expect(cliUtils.CLIProgressManager.printGlobalSummary.calledOnce).to.be.true;
638+
expect(cliUtils.CLIProgressManager.clearGlobalSummary.calledOnce).to.be.true;
639+
});
640+
641+
it('clears the persisted progress-module flag', () => {
642+
// Simulate the flag being set, then verify clearProgressModuleSetting removes it.
643+
cliUtils.configHandler.get.returns({ progressSupportedModule: 'bulk-operations', showConsoleLogs: false });
644+
645+
(command as any).finalizeProgressSummary();
646+
647+
expect(cliUtils.configHandler.set.calledWith('log', sinon.match((v: any) => !('progressSupportedModule' in v)))).to
648+
.be.true;
649+
});
650+
});
651+
});
652+
524653
describe('printOperationSummary', () => {
525654
beforeEach(() => {
526655
// Initialize bulkOperationConfig required for printOperationSummary

0 commit comments

Comments
 (0)