From 343aafd70bcfdb0450538dff31bf799e67855aad Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 30 Apr 2026 17:58:39 +0530 Subject: [PATCH 1/4] CL-1753 | + Anuja | + venky | feat: add rollback command for previous deployments with GraphQL integration Co-authored-by: Venkatesh Co-authored-by: anujachordiya-contentstack --- src/commands/launch/rollback.ts | 335 ++++++++++++++++++++++++++++++++ src/graphql/mutation.ts | 16 ++ src/graphql/queries.ts | 4 + 3 files changed, 355 insertions(+) create mode 100644 src/commands/launch/rollback.ts diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts new file mode 100644 index 00000000..4b6a89a --- /dev/null +++ b/src/commands/launch/rollback.ts @@ -0,0 +1,335 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import filter from 'lodash/filter'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { + environmentsQuery, + latestLiveDeploymentQuery, + rollbackDeploymentMutation, +} from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Rollback extends BaseCommand { + static description = 'Roll back to previous deployment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + // eslint-disable-next-line max-len + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + deployment: Flags.string({ + description: '[Optional] Deployment UID to roll back to', + }), + reason: Flags.string({ + description: '[Optional] Reason for the rollback (saved to audit log)', + }), + }; + + async run(): Promise { + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + + if (!this.flags.environment) { + await this.getConfig(); + } + + await this.prepareApiClients(); + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.rollbackDeployment(); + } + + /** + * @method rollbackDeployment - resolve env, run select + review steps, fire mutation + * + * @memberof Rollback + */ + async rollbackDeployment(): Promise { + const environment = await this.resolveEnvironment(); + const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); + const eligibleSorted = this.getEligibleSorted(environment, currentLive?.uid); + + if (isEmpty(eligibleSorted)) { + this.log('No rollback-eligible deployments are available for this environment.', 'error'); + process.exit(1); + } + + this.printSelectStep(environment, currentLive, eligibleSorted); + const target = await this.selectDeployment(eligibleSorted); + + this.printReviewStep(currentLive, target, eligibleSorted); + const reason = await this.promptReason(); + const confirmed = await ux.inquire({ + type: 'confirm', + name: 'confirm', + message: 'Confirm & Rollback?', + }); + + if (!confirmed) { + ux.print(chalk.yellow('Rollback aborted.')); + return; + } + + await this.apolloClient + .mutate({ + mutation: rollbackDeploymentMutation, + variables: { + input: { + deployment: target.uid, + environment: environment.uid, + ...(reason ? { reason } : {}), + }, + }, + }) + .then(({ data: { deployment: rolledBack } }) => { + ux.print(''); + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + ux.print(` New deployment: ${chalk.cyan(rolledBack.uid)} status: ${chalk.cyan(rolledBack.status)}`); + ux.print(''); + }) + .catch((error) => { + const code = error?.graphQLErrors?.[0]?.extensions?.exception?.name || error?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + }); + } + + /** + * @method resolveEnvironment - resolve environment via flag, config, or prompt + * + * @memberof Rollback + */ + async resolveEnvironment(): Promise { + const environments = await this.apolloClient + .query({ query: environmentsQuery }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + let environment = find( + environments, + ({ uid, name }) => + uid === this.flags.environment || + name === this.flags.environment || + uid === this.sharedConfig.currentConfig?.environments?.[0]?.uid, + ); + + if (isEmpty(environment) && (this.flags.environment || this.sharedConfig.currentConfig?.environments?.[0]?.uid)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } else if (isEmpty(environment)) { + environment = await ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + } + + this.sharedConfig.environment = environment; + return environment; + } + + /** + * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment + * + * @memberof Rollback + */ + async fetchCurrentLiveDeployment(environmentUid: string): Promise { + return this.apolloClient + .query({ + query: latestLiveDeploymentQuery, + variables: { query: { environment: environmentUid } }, + }) + .then(({ data }) => data?.latestLiveDeployment) + .catch(() => undefined); + } + + /** + * @method getEligibleSorted - eligible deployments excluding current live, sorted by number desc + * + * @memberof Rollback + */ + getEligibleSorted(environment: any, currentLiveUid?: string): any[] { + const deployments = map(environment?.deployments?.edges, 'node'); + const eligible = filter( + deployments, + (d) => d.isRollbackEligible && d.uid !== currentLiveUid, + ); + return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); + } + + /** + * @method selectDeployment - resolve target via --deployment flag or interactive picker + * + * @memberof Rollback + */ + async selectDeployment(eligibleSorted: any[]): Promise { + if (this.flags.deployment) { + const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); + if (isEmpty(match)) { + this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); + process.exit(1); + } + return match; + } + + const choices = map(eligibleSorted, (d) => ({ + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${d.createdAt}`, + value: d.uid, + })); + + const selectedUid = await ux.inquire({ + type: 'search-list', + name: 'Deployment', + choices, + message: 'Select a version to restore', + }); + + return find(eligibleSorted, { uid: selectedUid }) as Record; + } + + /** + * @method promptReason - prompt for rollback reason unless provided via --reason flag + * + * @memberof Rollback + */ + async promptReason(): Promise { + if (this.flags.reason) { + return this.flags.reason.trim() || undefined; + } + const input = await ux.inquire({ + type: 'input', + name: 'reason', + message: 'Reason (saved to audit log) — press enter to skip:', + }); + const trimmed = (input || '').trim(); + return trimmed ? trimmed : undefined; + } + + /** + * @method printSelectStep - mirror the UI "select" step heading and table + * + * @memberof Rollback + */ + printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Roll back to previous deployment')); + ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); + ux.print(''); + ux.print(chalk.bold('Currently live')); + ux.print(` ${formatDeployment(currentLive)}`); + ux.print(''); + ux.print(chalk.bold('Select a version to restore')); + ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); + const count = eligibleSorted.length; + ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); + ux.print(''); + } + + /** + * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary + * + * @memberof Rollback + */ + printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Review rollback')); + ux.print(''); + ux.print('You are about to replace your live site with the version below.'); + ux.print('This build will be pushed to the edge immediately.'); + ux.print(''); + ux.print( + `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, + ); + ux.print(' associated with the selected deployment.'); + + const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); + const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; + if (skipped.length > 0) { + const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); + const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; + const verb = skipped.length === 1 ? 'stays' : 'stay'; + ux.print(''); + ux.print( + `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, + ); + ux.print(` ${verb} in history and can be restored later.`); + } + + ux.print(''); + ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); + ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); + ux.print(''); + ux.print( + chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), + ); + ux.print(''); + } +} + +function shortHash(hash?: string): string { + return hash ? hash.substring(0, 7) : ''; +} + +function sourceLabel(deployment?: any): string { + if (!deployment) { + return ''; + } + const hash = shortHash(deployment.commitHash); + if (deployment.gitBranch && hash) { + return `${deployment.gitBranch} - ${hash}`; + } + return deployment.gitBranch || hash || ''; +} + +function formatDeployment(deployment?: any): string { + if (!deployment) { + return chalk.dim('(none)'); + } + const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; + const source = sourceLabel(deployment); + const createdAt = deployment.createdAt || ''; + const numberCol = chalk.green(number.padEnd(6)); + const sourceCol = source ? chalk.cyan(source.padEnd(28)) : ''.padEnd(28); + return `${numberCol} ${sourceCol} ${chalk.dim(createdAt)}`; +} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 97bc3f6..fac8510 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,8 +76,24 @@ const importProjectMutation: DocumentNode = gql` } `; +const rollbackDeploymentMutation: DocumentNode = gql` + mutation RollbackDeployment($input: RollbackDeploymentInput!) { + deployment: rollbackDeployment(input: $input) { + uid + status + createdAt + updatedAt + commitHash + commitMessage + deploymentUrl + deploymentNumber + } + } +`; + export { importProjectMutation, createDeploymentMutation, + rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c27debe..a4a7e7c 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -161,10 +161,14 @@ const environmentsQuery: DocumentNode = gql` edges { node { uid + status + gitBranch + commitHash createdAt commitMessage deploymentUrl deploymentNumber + isRollbackEligible } } } From 6023ff77246d98f611e34a4b9b0d2fa2e19b31ec Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 30 Apr 2026 19:21:52 +0530 Subject: [PATCH 2/4] CL-1753 | enhance rollback command with deployment status polling and improved error handling --- src/commands/launch/rollback.ts | 85 ++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts index 4b6a89a..1b4f7c9 100644 --- a/src/commands/launch/rollback.ts +++ b/src/commands/launch/rollback.ts @@ -7,6 +7,7 @@ import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; import { BaseCommand } from '../../base-command'; import { + deploymentQuery, environmentsQuery, latestLiveDeploymentQuery, rollbackDeploymentMutation, @@ -104,8 +105,9 @@ export default class Rollback extends BaseCommand { return; } - await this.apolloClient - .mutate({ + let rolledBack: any; + try { + const { data } = await this.apolloClient.mutate({ mutation: rollbackDeploymentMutation, variables: { input: { @@ -114,18 +116,75 @@ export default class Rollback extends BaseCommand { ...(reason ? { reason } : {}), }, }, - }) - .then(({ data: { deployment: rolledBack } }) => { - ux.print(''); - ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); - ux.print(` New deployment: ${chalk.cyan(rolledBack.uid)} status: ${chalk.cyan(rolledBack.status)}`); - ux.print(''); - }) - .catch((error) => { - const code = error?.graphQLErrors?.[0]?.extensions?.exception?.name || error?.message; - this.log(`Rollback failed. Please try again. (${code})`, 'error'); - process.exit(1); }); + rolledBack = data?.deployment; + } catch (error: any) { + const code = error?.graphQLErrors?.[0]?.extensions?.exception?.name || error?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + } + + ux.print(''); + ux.print( + `Promoting deployment ${chalk.cyan(`#${rolledBack.deploymentNumber}`)} ` + + chalk.dim(`(${rolledBack.uid})`) + '…', + ); + + const finalStatus = await this.pollDeploymentStatus(environment.uid, target.uid); + + ux.print(''); + if (finalStatus === 'LIVE') { + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + const label = `${chalk.cyan(`#${rolledBack.deploymentNumber}`)} ${chalk.dim(`(${rolledBack.uid})`)}`; + ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); + } else if (finalStatus === 'FAILED' || finalStatus === 'CANCELLED') { + ux.print(chalk.red(`✘ Rollback ended with status: ${finalStatus}.`)); + process.exit(1); + } else { + ux.print(chalk.yellow(`Rollback is still in progress (status: ${finalStatus}).`)); + ux.print(chalk.dim(' Check the Launch dashboard for the final status.')); + } + ux.print(''); + } + + /** + * @method pollDeploymentStatus - poll the target deployment until it goes LIVE or terminal/timeout + * + * @memberof Rollback + */ + async pollDeploymentStatus(environmentUid: string, deploymentUid: string): Promise { + const intervalMs = 3000; + const timeoutMs = 90000; + const start = Date.now(); + const terminal = new Set(['LIVE', 'FAILED', 'CANCELLED']); + + while (Date.now() - start < timeoutMs) { + try { + const { data } = await this.apolloClient.query({ + query: deploymentQuery, + variables: { query: { environment: environmentUid, uid: deploymentUid } }, + fetchPolicy: 'no-cache', + }); + const status = data?.Deployment?.status; + if (status && terminal.has(status)) { + return status; + } + } catch (error: any) { + this.log(`Failed to fetch deployment status: ${error?.message}`, 'warn'); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + try { + const { data } = await this.apolloClient.query({ + query: deploymentQuery, + variables: { query: { environment: environmentUid, uid: deploymentUid } }, + fetchPolicy: 'no-cache', + }); + return data?.Deployment?.status || 'UNKNOWN'; + } catch { + return 'UNKNOWN'; + } } /** From 32b4da9740dd04f269b8491e3a66955a4e788d40 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 30 Apr 2026 21:22:12 +0530 Subject: [PATCH 3/4] CL-1753 | add unit tests for rollback command and enhance environment resolution logic --- .talismanrc | 2 + src/commands/launch/rollback.test.ts | 233 +++++++++++++++++++++++++++ src/commands/launch/rollback.ts | 67 ++++---- src/graphql/queries.ts | 15 +- 4 files changed, 282 insertions(+), 35 deletions(-) create mode 100644 src/commands/launch/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index fb07040..70638de 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,6 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 +- filename: src/commands/launch/rollback.test.ts + checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts new file mode 100644 index 00000000..0c1081d --- /dev/null +++ b/src/commands/launch/rollback.test.ts @@ -0,0 +1,233 @@ +import Rollback from './rollback'; +import { Logger } from '../../util'; +import { cliux } from '@contentstack/cli-utilities'; + +jest.mock('../../util', () => { + const actual = jest.requireActual('../../util'); + return { + ...actual, + Logger: jest.fn(), + selectOrg: jest.fn(), + selectProject: jest.fn(), + }; +}); + +jest.mock('@contentstack/cli-utilities', () => { + const actual = jest.requireActual('@contentstack/cli-utilities'); + return { + ...actual, + configHandler: { + get: jest.fn((key) => { + if (key === 'authtoken') return 'dummy-token'; + if (key === 'authorisationType') return 'OAuth'; + if (key === 'oauthAccessToken') return 'dummy-oauth-token'; + return undefined; + }), + }, + cliux: { + ...actual.cliux, + inquire: jest.fn(), + print: jest.fn(), + }, + }; +}); + +const targetDeployment = { + uid: 'target-uid', + status: 'ARCHIVED', + gitBranch: 'main', + commitHash: 'abcdef1', + createdAt: '2026-04-29T00:00:00Z', + commitMessage: 'previous good build', + deploymentUrl: 'https://example.com', + deploymentNumber: 2, + isRollbackEligible: true, +}; + +const liveDeployment = { + ...targetDeployment, + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, +}; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { + edges: [ + { node: liveDeployment }, + { node: targetDeployment }, + ], + }, + }, + }, + ], + }, + }, +}; + +const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { + const cmd = new Rollback([], {} as any); + (cmd as any).flags = flags; + (cmd as any).log = jest.fn(); + (cmd as any).logger = { log: jest.fn() }; + (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; + (cmd as any).apolloClient = { + query: queryImpl || jest.fn(), + mutate: mutateImpl || jest.fn(), + }; + return cmd; +}; + +describe('Rollback Command', () => { + let exitMock: jest.SpyInstance; + + beforeEach(() => { + (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); + exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no rollback-eligible deployments are available', async () => { + const noEligibleResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { edges: [{ node: liveDeployment }] }, + }, + }, + ], + }, + }, + }; + const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); + const mutate = jest.fn(); + const cmd = buildCommand({ environment: 'Default' }, query, mutate); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'No rollback-eligible deployments are available for this environment.', + 'error', + ); + }); + + it('exits when --deployment flag does not match an eligible deployment', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'unknown-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Provided deployment UID is not rollback-eligible or does not exist.', + 'error', + ); + }); + + it('skips the mutation when the user does not confirm', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt + + await (cmd as any).rollbackDeployment(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('fires the rollback mutation and polls until LIVE on success', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn().mockResolvedValueOnce({ + data: { deployment: { ...targetDeployment, status: 'QUEUED' } }, + }); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + jest.spyOn(cmd as any, 'pollDeploymentStatus').mockResolvedValueOnce('LIVE'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + await (cmd as any).rollbackDeployment(); + + expect(mutate).toHaveBeenCalledTimes(1); + const variables = mutate.mock.calls[0][0].variables; + expect(variables).toEqual({ + input: { + deployment: 'target-uid', + environment: 'env-uid', + reason: 'restoring', + }, + }); + expect((cmd as any).pollDeploymentStatus).toHaveBeenCalledWith('env-uid', 'target-uid'); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('logs an error and exits when the rollback mutation fails', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const error = Object.assign(new Error('boom'), { + graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], + }); + const mutate = jest.fn().mockRejectedValueOnce(error); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock) + .mockResolvedValueOnce('') // reason + .mockResolvedValueOnce(true); // confirm + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Rollback failed. Please try again. (DeploymentRollbackFailed)', + 'error', + ); + }); +}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts index 1b4f7c9..29aa5f0 100644 --- a/src/commands/launch/rollback.ts +++ b/src/commands/launch/rollback.ts @@ -194,37 +194,37 @@ export default class Rollback extends BaseCommand { */ async resolveEnvironment(): Promise { const environments = await this.apolloClient - .query({ query: environmentsQuery }) + .query({ + query: environmentsQuery, + variables: { skipRollbackData: false }, + }) .then(({ data: { Environments } }) => map(Environments.edges, 'node')) .catch((error) => { this.log(error?.message, 'error'); process.exit(1); }); - let environment = find( - environments, - ({ uid, name }) => - uid === this.flags.environment || - name === this.flags.environment || - uid === this.sharedConfig.currentConfig?.environments?.[0]?.uid, - ); - - if (isEmpty(environment) && (this.flags.environment || this.sharedConfig.currentConfig?.environments?.[0]?.uid)) { - this.log('Environment(s) not found!', 'error'); - process.exit(1); - } else if (isEmpty(environment)) { - environment = await ux - .inquire({ - type: 'search-list', - name: 'Environment', - choices: map(environments, (row) => ({ ...row, value: row.name })), - message: 'Choose an environment', - }) - .then((name: any) => find(environments, { name }) as Record); + if (this.flags.environment) { + const environment = find( + environments, + ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, + ); + if (isEmpty(environment)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } + return environment; } - this.sharedConfig.environment = environment; - return environment; + // NOTE: rollback is destructive; never auto-select from saved config — always prompt. + return ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); } /** @@ -271,11 +271,15 @@ export default class Rollback extends BaseCommand { return match; } - const choices = map(eligibleSorted, (d) => ({ - ...d, - name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${d.createdAt}`, - value: d.uid, - })); + const choices = map(eligibleSorted, (d) => { + const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; + const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; + return { + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, + value: d.uid, + }; + }); const selectedUid = await ux.inquire({ type: 'search-list', @@ -387,8 +391,11 @@ function formatDeployment(deployment?: any): string { } const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; const source = sourceLabel(deployment); + const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); + const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; const createdAt = deployment.createdAt || ''; const numberCol = chalk.green(number.padEnd(6)); - const sourceCol = source ? chalk.cyan(source.padEnd(28)) : ''.padEnd(28); - return `${numberCol} ${sourceCol} ${chalk.dim(createdAt)}`; + const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); + const messageCol = truncated || chalk.dim('—'); + return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; } diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index a4a7e7c..1cb0e81 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl + status + gitBranch + commitHash + commitMessage + createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments { + query Environments($skipRollbackData: Boolean = true) { Environments { edges { node { @@ -161,14 +166,14 @@ const environmentsQuery: DocumentNode = gql` edges { node { uid - status - gitBranch - commitHash createdAt commitMessage deploymentUrl deploymentNumber - isRollbackEligible + status @skip(if: $skipRollbackData) + gitBranch @skip(if: $skipRollbackData) + commitHash @skip(if: $skipRollbackData) + isRollbackEligible @skip(if: $skipRollbackData) } } } From d59619e5f78f81e7ef9463fdc55ca56e727c38f4 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Mon, 4 May 2026 12:07:00 +0530 Subject: [PATCH 4/4] CL-1753 | refactor rollback command: rename init method, enhance error handling, and update method names for clarity --- src/commands/launch/rollback.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts index 29aa5f0..da447ab 100644 --- a/src/commands/launch/rollback.ts +++ b/src/commands/launch/rollback.ts @@ -44,16 +44,18 @@ export default class Rollback extends BaseCommand { }), }; - async run(): Promise { + async init(): Promise { + await super.init(); this.logger = new Logger(this.sharedConfig); this.log = this.logger.log.bind(this.logger); + await this.prepareApiClients(); + } + async run(): Promise { if (!this.flags.environment) { await this.getConfig(); } - await this.prepareApiClients(); - if (!this.sharedConfig.currentConfig?.uid) { await selectOrg({ log: this.log, @@ -82,7 +84,7 @@ export default class Rollback extends BaseCommand { async rollbackDeployment(): Promise { const environment = await this.resolveEnvironment(); const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); - const eligibleSorted = this.getEligibleSorted(environment, currentLive?.uid); + const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); if (isEmpty(eligibleSorted)) { this.log('No rollback-eligible deployments are available for this environment.', 'error'); @@ -105,7 +107,7 @@ export default class Rollback extends BaseCommand { return; } - let rolledBack: any; + let rolledBack: { deploymentNumber: number; uid: string }; try { const { data } = await this.apolloClient.mutate({ mutation: rollbackDeploymentMutation, @@ -118,8 +120,9 @@ export default class Rollback extends BaseCommand { }, }); rolledBack = data?.deployment; - } catch (error: any) { - const code = error?.graphQLErrors?.[0]?.extensions?.exception?.name || error?.message; + } catch (error: unknown) { + const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; + const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; this.log(`Rollback failed. Please try again. (${code})`, 'error'); process.exit(1); } @@ -243,11 +246,11 @@ export default class Rollback extends BaseCommand { } /** - * @method getEligibleSorted - eligible deployments excluding current live, sorted by number desc + * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc * * @memberof Rollback */ - getEligibleSorted(environment: any, currentLiveUid?: string): any[] { + getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { const deployments = map(environment?.deployments?.edges, 'node'); const eligible = filter( deployments,