From 4cf08db490de1a9a8675dbaa851a426af723412e Mon Sep 17 00:00:00 2001 From: Oleksiy Kachynskyy <16646570+lesyk-lesyk@users.noreply.github.com> Date: Thu, 25 Apr 2024 14:42:45 +0300 Subject: [PATCH] feat: update push and push-status commands (#1481) * feat(bh): make 'project' and 'domain' required for push command (CMS) * chore: add OutputFormat type for 'format' parameter for push-status command * feat: add 'format' parameter for 'push-status' command * feat: add 'markdown' to OutputFormat type * feat: make domain not required for push-status * refactor: simplify `handlePushStatus` function * feat: add json format for push-status command * feat: remove `async` from printPushStatus * feat: improve printing json summary and exit with error * feat: add 'format' option to cli push command * feat: add push metadata to json output * feat: add return data for push and pushStatus commands * feat: update push status command * tests: add new unit tests for push status * refactor: remove json format for "push" and "push-status" commands * feat: add "ignore-deployment-failures" option * chore: replace "satisfies" with "as" * chore: add changeset * feat: add onTimeOutExceeded callback * Apply suggestions from code review Co-authored-by: Lorna Jane Mitchell * Apply suggestions from code review Co-authored-by: Andrew Tatomyr Co-authored-by: Roman Sainchuk * chore: update return type for commandHandler * tests: use expect.rejects instead of catching errors * feat: rename 'ignore-deployment-failures' to 'continue-on-deployment-failures' * feat: add onConditionNotMet callback * tests: add unit tests * chore: add comments * refactor: rename "wait" to "pause" and move to utils * refactor: change import type * refactor: add explicit array length check * fix: add correct wrapper type for argv params * feat: update types for push status API * chore: add "unit:watch" script to package.json * test: fix unit tests * tests: add tests for "onRetry" and "max-execution-time" parameters * chore: restore museum.yaml * feat: update API types for PushResponse * refactor: rename "continue-on-deployment-failures" to "continue-on-deploy-failures" * feat: update interface for `onRetry` function * feat: remove "instanceof Promise" check * refactor: reorder imports * feat: increase maxExecutionTime to 20 min --------- Co-authored-by: Lorna Jane Mitchell Co-authored-by: Andrew Tatomyr Co-authored-by: Roman Sainchuk --- .changeset/heavy-swans-sin.md | 5 + CONTRIBUTING.md | 2 + package.json | 1 + packages/cli/src/cms/api/types.ts | 31 +- .../commands/__tests__/push-status.test.ts | 520 ++++++++++++++++-- .../src/cms/commands/__tests__/push.test.ts | 42 +- .../src/cms/commands/__tests__/utils.test.ts | 62 +++ packages/cli/src/cms/commands/push-status.ts | 378 ++++++++----- packages/cli/src/cms/commands/push.ts | 26 +- packages/cli/src/cms/commands/utils.ts | 52 ++ packages/cli/src/index.ts | 15 +- packages/cli/src/wrapper.ts | 2 +- packages/core/src/format/format.ts | 3 +- packages/core/src/index.ts | 9 +- packages/core/src/utils.ts | 4 + 15 files changed, 953 insertions(+), 199 deletions(-) create mode 100644 .changeset/heavy-swans-sin.md create mode 100644 packages/cli/src/cms/commands/__tests__/utils.test.ts create mode 100644 packages/cli/src/cms/commands/utils.ts diff --git a/.changeset/heavy-swans-sin.md b/.changeset/heavy-swans-sin.md new file mode 100644 index 00000000..9374d7d6 --- /dev/null +++ b/.changeset/heavy-swans-sin.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": minor +--- + +Added return values for the `push` and `push-status` commands. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36c8815e..597d09a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,6 +166,8 @@ Run unit tests with this command: `npm run test`. Unit tests in the **cli** package are sensitive to top-level configuration file (**redocly.yaml**). To run a specific test, use this command: `npm run unit -- -t 'Test name'`. +To run tests in watch mode, run: `npm run unit:watch` +To run single file in watch mode, run: `npm run unit:watch -- ` To update snapshots, run `npm run unit -- -u`. To get coverage per package run `npm run coverage:cli` or `npm run coverage:core`. diff --git a/package.json b/package.json index e960a067..29e3fe69 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "npm run typecheck && npm run unit", "jest": "REDOCLY_TELEMETRY=off jest ./packages", "unit": "npm run jest -- --coverage --coverageReporters lcov text-summary", + "unit:watch": "REDOCLY_TELEMETRY=off jest --watch", "coverage:cli": "npm run jest -- --roots packages/cli/src --coverage", "coverage:core": "npm run jest -- --roots packages/core/src --coverage", "typecheck": "tsc --noEmit --skipLibCheck", diff --git a/packages/cli/src/cms/api/types.ts b/packages/cli/src/cms/api/types.ts index e49c5c02..4d8ec7f5 100644 --- a/packages/cli/src/cms/api/types.ts +++ b/packages/cli/src/cms/api/types.ts @@ -48,33 +48,42 @@ export type Remote = { export type PushResponse = { id: string; remoteId: string; + isMainBranch: boolean; + isOutdated: boolean; + hasChanges: boolean; + replace: boolean; + scoutJobId: string | null; + uploadedFiles: Array<{ path: string; mimeType: string }>; commit: { - message: string; branchName: string; - sha: string | null; - url: string | null; + message: string; createdAt: string | null; - namespace: string | null; - repository: string | null; + namespaceId: string | null; + repositoryId: string | null; + url: string | null; + sha: string | null; author: { name: string; email: string; image: string | null; }; + statuses: Array<{ + name: string; + description: string; + status: 'pending' | 'running' | 'success' | 'failed'; + url: string | null; + }>; }; remote: { commits: { - branchName: string; sha: string; + branchName: string; }[]; }; - hasChanges: boolean; - isOutdated: boolean; - isMainBranch: boolean; status: PushStatusResponse; }; -type DeploymentStatusResponse = { +export type DeploymentStatusResponse = { deploy: { url: string | null; status: DeploymentStatus; @@ -96,6 +105,4 @@ export type ScorecardItem = { export type PushStatusBase = 'pending' | 'success' | 'running' | 'failed'; -// export type BuildStatus = PushStatusBase | 'NOT_STARTED' | 'QUEUED'; - export type DeploymentStatus = 'skipped' | PushStatusBase; diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts index c15a4558..a916485a 100644 --- a/packages/cli/src/cms/commands/__tests__/push-status.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts @@ -1,14 +1,11 @@ import { handlePushStatus } from '../push-status'; import { PushResponse } from '../../api/types'; -import { exitWithError } from '../../../utils/miscellaneous'; const remotes = { getPush: jest.fn(), getRemotesList: jest.fn(), }; -jest.mock('../../../utils/miscellaneous'); - jest.mock('colorette', () => ({ green: (str: string) => str, yellow: (str: string) => str, @@ -25,28 +22,57 @@ jest.mock('../../api', () => ({ }), })); +jest.mock('@redocly/openapi-core', () => ({ + pause: jest.requireActual('@redocly/openapi-core').pause, +})); + describe('handlePushStatus()', () => { const mockConfig = { apis: {} } as any; - const pushResponseStub = { + const commitStub: PushResponse['commit'] = { + message: 'test-commit-message', + branchName: 'test-branch-name', + sha: null, + url: null, + createdAt: null, + namespaceId: null, + repositoryId: null, + author: { + name: 'test-author-name', + email: 'test-author-email', + image: null, + }, + statuses: [], + }; + + const pushResponseStub: PushResponse = { + id: 'test-push-id', + remoteId: 'test-remote-id', + replace: false, + scoutJobId: null, + uploadedFiles: [], + commit: commitStub, + remote: { commits: [] }, + isOutdated: false, + isMainBranch: false, hasChanges: true, status: { preview: { scorecard: [], deploy: { - url: 'https://test-url', + url: 'https://preview-test-url', status: 'success', }, }, production: { scorecard: [], deploy: { - url: 'https://test-url', + url: 'https://production-test-url', status: 'success', }, }, }, - } as unknown as PushResponse; + }; beforeEach(() => { jest.spyOn(process.stderr, 'write').mockImplementation(() => true); @@ -58,23 +84,27 @@ describe('handlePushStatus()', () => { }); it('should throw error if organization not provided', async () => { - await handlePushStatus( - { - domain: 'test-domain', - organization: '', - project: 'test-project', - pushId: 'test-push-id', - 'max-execution-time': 1000, - }, - mockConfig + await expect( + handlePushStatus( + { + domain: 'test-domain', + organization: '', + project: 'test-project', + pushId: 'test-push-id', + }, + mockConfig + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"No organization provided, please use --organization option or specify the 'organization' field in the config file."` ); - expect(exitWithError).toHaveBeenCalledWith( - "No organization provided, please use --organization option or specify the 'organization' field in the config file." + expect(process.stderr.write).toHaveBeenCalledWith( + `No organization provided, please use --organization option or specify the 'organization' field in the config file.` + + '\n\n' ); }); - it('should return success push status for preview-build', async () => { + it('should print success push status for preview-build', async () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValueOnce(pushResponseStub); @@ -84,17 +114,16 @@ describe('handlePushStatus()', () => { organization: 'test-org', project: 'test-project', pushId: 'test-push-id', - 'max-execution-time': 1000, }, mockConfig ); expect(process.stdout.write).toHaveBeenCalledTimes(1); expect(process.stdout.write).toHaveBeenCalledWith( - '🚀 Preview deploy success.\nPreview URL: https://test-url\n' + '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' ); }); - it('should return success push status for preview and production builds', async () => { + it('should print success push status for preview and production builds', async () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true }); @@ -104,46 +133,50 @@ describe('handlePushStatus()', () => { organization: 'test-org', project: 'test-project', pushId: 'test-push-id', - 'max-execution-time': 1000, }, mockConfig ); expect(process.stdout.write).toHaveBeenCalledTimes(2); expect(process.stdout.write).toHaveBeenCalledWith( - '🚀 Preview deploy success.\nPreview URL: https://test-url\n' + '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' ); expect(process.stdout.write).toHaveBeenCalledWith( - '🚀 Production deploy success.\nProduction URL: https://test-url\n' + '🚀 Production deploy success.\nProduction URL: https://production-test-url\n' ); }); - it('should return failed push status for preview build', async () => { + it('should print failed push status for preview build', async () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ isOutdated: false, hasChanges: true, status: { - preview: { deploy: { status: 'failed', url: 'https://test-url' }, scorecard: [] }, + preview: { deploy: { status: 'failed', url: 'https://preview-test-url' }, scorecard: [] }, }, }); - await handlePushStatus( - { - domain: 'test-domain', - organization: 'test-org', - project: 'test-project', - pushId: 'test-push-id', - 'max-execution-time': 1000, - }, - mockConfig - ); - expect(exitWithError).toHaveBeenCalledWith( - '❌ Preview deploy fail.\nPreview URL: https://test-url' + await expect( + handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + }, + mockConfig + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "❌ Preview deploy fail. + Preview URL: https://preview-test-url" + `); + + expect(process.stderr.write).toHaveBeenCalledWith( + '❌ Preview deploy fail.\nPreview URL: https://preview-test-url' + '\n\n' ); }); - it('should return success push status for preview build and print scorecards', async () => { + it('should print success push status for preview build and print scorecards', async () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValue({ @@ -151,7 +184,7 @@ describe('handlePushStatus()', () => { hasChanges: true, status: { preview: { - deploy: { status: 'success', url: 'https://test-url' }, + deploy: { status: 'success', url: 'https://preview-test-url' }, scorecard: [ { name: 'test-name', @@ -170,13 +203,12 @@ describe('handlePushStatus()', () => { organization: 'test-org', project: 'test-project', pushId: 'test-push-id', - 'max-execution-time': 1000, }, mockConfig ); expect(process.stdout.write).toHaveBeenCalledTimes(4); expect(process.stdout.write).toHaveBeenCalledWith( - '🚀 Preview deploy success.\nPreview URL: https://test-url\n' + '🚀 Preview deploy success.\nPreview URL: https://preview-test-url\n' ); expect(process.stdout.write).toHaveBeenCalledWith('\nScorecard:'); expect(process.stdout.write).toHaveBeenCalledWith( @@ -185,14 +217,18 @@ describe('handlePushStatus()', () => { expect(process.stdout.write).toHaveBeenCalledWith('\n'); }); - it('should display message if there is no changes', async () => { + it('should print message if there is no changes', async () => { process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; remotes.getPush.mockResolvedValueOnce({ isOutdated: false, hasChanges: false, status: { - preview: { deploy: { status: 'skipped', url: 'https://test-url' }, scorecard: [] }, + preview: { deploy: { status: 'skipped', url: 'https://preview-test-url' }, scorecard: [] }, + production: { + deploy: { status: 'skipped', url: null }, + scorecard: [], + }, }, }); @@ -203,10 +239,400 @@ describe('handlePushStatus()', () => { project: 'test-project', pushId: 'test-push-id', wait: true, - 'max-execution-time': 1000, }, mockConfig ); - expect(process.stderr.write).toHaveBeenCalledWith('Files not uploaded. Reason: no changes.\n'); + + expect(process.stderr.write).toHaveBeenCalledWith( + 'Files not added to your project. Reason: no changes.\n' + ); + }); + + describe('return value', () => { + it('should return preview deployment info', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: false }); + + const result = await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + }, + mockConfig + ); + + expect(result).toEqual({ + preview: { + deploy: { + status: 'success', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + }); + + it('should return preview and production deployment info', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true }); + + const result = await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + }, + mockConfig + ); + + expect(result).toEqual({ + preview: { + deploy: { + status: 'success', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: { + deploy: { + status: 'success', + url: 'https://production-test-url', + }, + scorecard: [], + }, + commit: commitStub, + }); + }); + }); + + describe('"wait" option', () => { + it('should wait for preview "success" deployment status', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'pending', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'running', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + const result = await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'retry-interval': 0.5, // 500 ms + wait: true, + }, + mockConfig + ); + + expect(result).toEqual({ + preview: { + deploy: { + status: 'success', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + }); + + it('should wait for production "success" status after preview "success" status', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + isMainBranch: true, + status: { + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + production: { + deploy: { status: 'pending', url: 'https://production-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + isMainBranch: true, + status: { + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + production: { + deploy: { status: 'running', url: 'https://production-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + isMainBranch: true, + status: { + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + production: { + deploy: { status: 'success', url: 'https://production-test-url' }, + scorecard: [], + }, + }, + }); + + const result = await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'retry-interval': 0.5, // 500 ms + wait: true, + }, + mockConfig + ); + + expect(result).toEqual({ + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + production: { + deploy: { status: 'success', url: 'https://production-test-url' }, + scorecard: [], + }, + commit: commitStub, + }); + }); + }); + + describe('"continue-on-deploy-failures" option', () => { + it('should throw error if option value is false', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'failed', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + await expect( + handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'continue-on-deploy-failures': false, + }, + mockConfig + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "❌ Preview deploy fail. + Preview URL: https://preview-test-url" + `); + }); + + it('should not throw error if option value is true', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'failed', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + await expect( + handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'continue-on-deploy-failures': true, + }, + mockConfig + ) + ).resolves.toStrictEqual({ + preview: { + deploy: { status: 'failed', url: 'https://preview-test-url' }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + }); + }); + + describe('"onRetry" callback', () => { + it('should be called when command retries request to API in wait mode for preview deploy', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'pending', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'running', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + remotes.getPush.mockResolvedValueOnce({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'success', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + const onRetrySpy = jest.fn(); + + const result = await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + wait: true, + 'retry-interval': 0.5, // 500 ms + onRetry: onRetrySpy, + }, + mockConfig + ); + + expect(onRetrySpy).toBeCalledTimes(2); + + // first retry + expect(onRetrySpy).toHaveBeenNthCalledWith(1, { + preview: { + deploy: { + status: 'pending', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + + // second retry + expect(onRetrySpy).toHaveBeenNthCalledWith(2, { + preview: { + deploy: { + status: 'running', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + + // final result + expect(result).toEqual({ + preview: { + deploy: { + status: 'success', + url: 'https://preview-test-url', + }, + scorecard: [], + }, + production: null, + commit: commitStub, + }); + }); + }); + + describe('"max-execution-time" option', () => { + it('should throw error in case "max-execution-time" was exceeded', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + // Stuck deployment simulation + remotes.getPush.mockResolvedValue({ + ...pushResponseStub, + status: { + preview: { + deploy: { status: 'pending', url: 'https://preview-test-url' }, + scorecard: [], + }, + }, + }); + + await expect( + handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'retry-interval': 2, // seconds + 'max-execution-time': 1, // seconds + wait: true, + }, + mockConfig + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "✗ Failed to get push status. Reason: Timeout exceeded + " + `); + }); }); }); diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts index ebdb1228..940db714 100644 --- a/packages/cli/src/cms/commands/__tests__/push.test.ts +++ b/packages/cli/src/cms/commands/__tests__/push.test.ts @@ -29,8 +29,8 @@ describe('handlePush()', () => { beforeEach(() => { remotes.getDefaultBranch.mockResolvedValueOnce('test-default-branch'); - remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id' }); - remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch' }); + remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id', mountPath: 'test-mount-path' }); + remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch', id: 'test-id' }); jest.spyOn(fs, 'createReadStream').mockReturnValue('stream' as any); @@ -118,6 +118,44 @@ describe('handlePush()', () => { ); }); + it('should return push id', async () => { + const mockConfig = { apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + fsStatSyncSpy.mockReturnValueOnce({ + isDirectory() { + return false; + }, + } as any); + + pathResolveSpy.mockImplementationOnce((p) => p); + pathRelativeSpy.mockImplementationOnce((_, p) => p); + pathDirnameSpy.mockImplementation((_: string) => '.'); + + const result = await handlePush( + { + domain: 'test-domain', + 'mount-path': 'test-mount-path', + organization: 'test-org', + project: 'test-project', + branch: 'test-branch', + namespace: 'test-namespace', + repository: 'test-repository', + 'commit-sha': 'test-commit-sha', + 'commit-url': 'test-commit-url', + 'default-branch': 'test-branch', + 'created-at': 'test-created-at', + author: 'TestAuthor ', + message: 'Test message', + files: ['test-file'], + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(result).toEqual({ pushId: 'test-id' }); + }); + it('should collect files from directory and preserve file structure', async () => { const mockConfig = { apis: {} } as any; process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; diff --git a/packages/cli/src/cms/commands/__tests__/utils.test.ts b/packages/cli/src/cms/commands/__tests__/utils.test.ts new file mode 100644 index 00000000..482e02e1 --- /dev/null +++ b/packages/cli/src/cms/commands/__tests__/utils.test.ts @@ -0,0 +1,62 @@ +import { retryUntilConditionMet } from '../utils'; + +jest.mock('@redocly/openapi-core', () => ({ + pause: jest.requireActual('@redocly/openapi-core').pause, +})); + +describe('retryUntilConditionMet()', () => { + it('should retry until condition meet and return result', async () => { + const operation = jest + .fn() + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'done' }); + + const data = await retryUntilConditionMet({ + operation, + condition: (result: any) => result?.status === 'done', + retryIntervalMs: 100, + retryTimeoutMs: 1000, + }); + + expect(data).toEqual({ status: 'done' }); + }); + + it('should throw error if condition not meet for desired timeout', async () => { + const operation = jest.fn().mockResolvedValue({ status: 'pending' }); + + await expect( + retryUntilConditionMet({ + operation, + condition: (result: any) => result?.status === 'done', + retryIntervalMs: 100, + retryTimeoutMs: 1000, + }) + ).rejects.toThrow('Timeout exceeded'); + }); + + it('should call "onConditionNotMet" and "onRetry" callbacks', async () => { + const operation = jest + .fn() + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'done' }); + + const onConditionNotMet = jest.fn(); + const onRetry = jest.fn(); + + const data = await retryUntilConditionMet({ + operation, + condition: (result: any) => result?.status === 'done', + retryIntervalMs: 100, + retryTimeoutMs: 1000, + onConditionNotMet, + onRetry, + }); + + expect(data).toEqual({ status: 'done' }); + + expect(onConditionNotMet).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts index 60c41cf4..b52d80d0 100644 --- a/packages/cli/src/cms/commands/push-status.ts +++ b/packages/cli/src/cms/commands/push-status.ts @@ -1,15 +1,20 @@ import * as colors from 'colorette'; -import { Config } from '@redocly/openapi-core'; +import type { Config, OutputFormat } from '@redocly/openapi-core'; + import { exitWithError, printExecutionTime } from '../../utils/miscellaneous'; import { Spinner } from '../../utils/spinner'; import { DeploymentError } from '../utils'; -import { yellow } from 'colorette'; import { ReuniteApiClient, getApiKeys, getDomain } from '../api'; import { capitalize } from '../../utils/js-utils'; +import type { + DeploymentStatus, + DeploymentStatusResponse, + PushResponse, + ScorecardItem, +} from '../api/types'; +import { retryUntilConditionMet } from './utils'; -import type { DeploymentStatus, PushResponse, ScorecardItem } from '../api/types'; - -const INTERVAL = 5000; +const RETRY_INTERVAL_MS = 5000; // 5 sec export type PushStatusOptions = { organization: string; @@ -17,12 +22,25 @@ export type PushStatusOptions = { pushId: string; domain?: string; config?: string; - format?: 'stylish' | 'json'; + format?: Extract; wait?: boolean; - 'max-execution-time': number; + 'max-execution-time'?: number; // in seconds + 'retry-interval'?: number; // in seconds + 'start-time'?: number; // in milliseconds + 'continue-on-deploy-failures'?: boolean; + onRetry?: (lasSummary: PushStatusSummary) => void; }; -export async function handlePushStatus(argv: PushStatusOptions, config: Config) { +export interface PushStatusSummary { + preview: DeploymentStatusResponse; + production: DeploymentStatusResponse | null; + commit: PushResponse['commit']; +} + +export async function handlePushStatus( + argv: PushStatusOptions, + config: Config +): Promise { const startedAt = performance.now(); const spinner = new Spinner(); @@ -31,123 +49,198 @@ export async function handlePushStatus(argv: PushStatusOptions, config: Config) const orgId = organization || config.organization; if (!orgId) { - return exitWithError( + exitWithError( `No organization provided, please use --organization option or specify the 'organization' field in the config file.` ); + return; } const domain = argv.domain || getDomain(); - - if (!domain) { - return exitWithError( - `No domain provided, please use --domain option or environment variable REDOCLY_DOMAIN.` - ); - } - - const maxExecutionTime = argv['max-execution-time'] || 600; + const maxExecutionTime = argv['max-execution-time'] || 1200; // 20 min + const retryIntervalMs = argv['retry-interval'] + ? argv['retry-interval'] * 1000 + : RETRY_INTERVAL_MS; + const startTime = argv['start-time'] || Date.now(); + const retryTimeoutMs = maxExecutionTime * 1000; + const continueOnDeployFailures = argv['continue-on-deploy-failures'] || false; try { const apiKey = getApiKeys(domain); const client = new ReuniteApiClient(domain, apiKey); - if (wait) { - const push = await waitForDeployment(client, 'preview'); + let pushResponse: PushResponse; - if (push.isMainBranch && push.status.preview.deploy.status === 'success') { - await waitForDeployment(client, 'production'); - } + pushResponse = await retryUntilConditionMet({ + operation: () => + client.remotes.getPush({ + organizationId: orgId, + projectId, + pushId, + }), + condition: wait + ? // Keep retrying if status is "pending" or "running" (returning false, so the operation will be retried) + (result) => !['pending', 'running'].includes(result.status['preview'].deploy.status) + : null, + onConditionNotMet: (lastResult) => { + displayDeploymentAndBuildStatus({ + status: lastResult.status['preview'].deploy.status, + url: lastResult.status['preview'].deploy.url, + spinner, + buildType: 'preview', + continueOnDeployFailures, + wait, + }); + }, + onRetry: (lastResult) => { + if (argv.onRetry) { + argv.onRetry({ + preview: lastResult.status.preview, + production: lastResult.isMainBranch ? lastResult.status.production : null, + commit: lastResult.commit, + }); + } + }, + startTime, + retryTimeoutMs, + retryIntervalMs, + }); - printPushStatusInfo(); - return; + printPushStatus({ + buildType: 'preview', + spinner, + wait, + push: pushResponse, + continueOnDeployFailures, + }); + printScorecard(pushResponse.status.preview.scorecard); + + const shouldWaitForProdDeployment = + pushResponse.isMainBranch && + (wait ? pushResponse.status.preview.deploy.status === 'success' : true); + + if (shouldWaitForProdDeployment) { + pushResponse = await retryUntilConditionMet({ + operation: () => + client.remotes.getPush({ + organizationId: orgId, + projectId, + pushId, + }), + condition: wait + ? // Keep retrying if status is "pending" or "running" (returning false, so the operation will be retried) + (result) => !['pending', 'running'].includes(result.status['production'].deploy.status) + : null, + onConditionNotMet: (lastResult) => { + displayDeploymentAndBuildStatus({ + status: lastResult.status['production'].deploy.status, + url: lastResult.status['production'].deploy.url, + spinner, + buildType: 'production', + continueOnDeployFailures, + wait, + }); + }, + onRetry: (lastResult) => { + if (argv.onRetry) { + argv.onRetry({ + preview: lastResult.status.preview, + production: lastResult.isMainBranch ? lastResult.status.production : null, + commit: lastResult.commit, + }); + } + }, + startTime, + retryTimeoutMs, + retryIntervalMs, + }); } - const pushPreview = await getAndPrintPushStatus(client, 'preview'); - printScorecard(pushPreview.status.preview.scorecard); - - if (pushPreview.isMainBranch) { - await getAndPrintPushStatus(client, 'production'); - printScorecard(pushPreview.status.production.scorecard); + if (pushResponse.isMainBranch) { + printPushStatus({ + buildType: 'production', + spinner, + wait, + push: pushResponse, + continueOnDeployFailures, + }); + printScorecard(pushResponse.status.production.scorecard); } + printPushStatusInfo({ orgId, projectId, pushId, startedAt }); - printPushStatusInfo(); + const summary: PushStatusSummary = { + preview: pushResponse.status.preview, + production: pushResponse.isMainBranch ? pushResponse.status.production : null, + commit: pushResponse.commit, + }; + + return summary; } catch (err) { + spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly. + const message = err instanceof DeploymentError ? err.message : `✗ Failed to get push status. Reason: ${err.message}\n`; exitWithError(message); - } - - function printPushStatusInfo() { - process.stderr.write( - `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow( - projectId - )} and pushID ${colors.yellow(pushId)}.\n` - ); - printExecutionTime('push-status', startedAt, 'Finished'); - } - - async function waitForDeployment( - client: ReuniteApiClient, - buildType: 'preview' | 'production' - ): Promise { - return new Promise((resolve, reject) => { - if (performance.now() - startedAt > maxExecutionTime * 1000) { - spinner.stop(); - reject(new Error(`Time limit exceeded.`)); - } - - getAndPrintPushStatus(client, buildType) - .then((push) => { - if (!['pending', 'running'].includes(push.status[buildType].deploy.status)) { - printScorecard(push.status[buildType].scorecard); - resolve(push); - return; - } - - setTimeout(async () => { - try { - const pushResponse = await waitForDeployment(client, buildType); - resolve(pushResponse); - } catch (e) { - reject(e); - } - }, INTERVAL); - }) - .catch(reject); - }); - } - - async function getAndPrintPushStatus( - client: ReuniteApiClient, - buildType: 'preview' | 'production' - ) { - const push = await client.remotes.getPush({ - organizationId: orgId!, - projectId, - pushId, - }); - - if (push.isOutdated || !push.hasChanges) { - process.stderr.write( - yellow(`Files not uploaded. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`) - ); - } else { - displayDeploymentAndBuildStatus({ - status: push.status[buildType].deploy.status, - previewUrl: push.status[buildType].deploy.url, - buildType, - spinner, - wait, - }); - } - - return push; + return; + } finally { + spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly. } } -function printScorecard(scorecard: ScorecardItem[]) { - if (!scorecard.length) { +function printPushStatusInfo({ + orgId, + projectId, + pushId, + startedAt, +}: { + orgId: string; + projectId: string; + pushId: string; + startedAt: number; +}) { + process.stderr.write( + `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow( + projectId + )} and pushID ${colors.yellow(pushId)}.\n` + ); + printExecutionTime('push-status', startedAt, 'Finished'); +} + +function printPushStatus({ + buildType, + spinner, + push, + continueOnDeployFailures, +}: { + buildType: 'preview' | 'production'; + spinner: Spinner; + wait?: boolean; + push?: PushResponse | null; + continueOnDeployFailures: boolean; +}) { + if (!push) { + return; + } + if (push.isOutdated || !push.hasChanges) { + process.stderr.write( + colors.yellow( + `Files not added to your project. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n` + ) + ); + } else { + displayDeploymentAndBuildStatus({ + status: push.status[buildType].deploy.status, + url: push.status[buildType].deploy.url, + buildType, + spinner, + continueOnDeployFailures, + }); + } +} + +function printScorecard(scorecard?: ScorecardItem[]) { + if (!scorecard || scorecard.length === 0) { return; } process.stdout.write(`\n${colors.magenta('Scorecard')}:`); @@ -163,42 +256,71 @@ function printScorecard(scorecard: ScorecardItem[]) { function displayDeploymentAndBuildStatus({ status, - previewUrl, + url, spinner, buildType, + continueOnDeployFailures, + wait, +}: { + status: DeploymentStatus; + url: string | null; + spinner: Spinner; + buildType: 'preview' | 'production'; + continueOnDeployFailures: boolean; + wait?: boolean; +}) { + const message = getMessage({ status, url, buildType, wait }); + + if (status === 'failed' && !continueOnDeployFailures) { + spinner.stop(); + throw new DeploymentError(message); + } + + if (wait && (status === 'pending' || status === 'running')) { + return spinner.start(message); + } + + spinner.stop(); + return process.stdout.write(message); +} + +function getMessage({ + status, + url, + buildType, wait, }: { status: DeploymentStatus; - previewUrl: string | null; - spinner: Spinner; + url: string | null; buildType: 'preview' | 'production'; wait?: boolean; -}) { +}): string { switch (status) { - case 'success': - spinner.stop(); - return process.stdout.write( - `${colors.green(`🚀 ${capitalize(buildType)} deploy success.`)}\n${colors.magenta( - `${capitalize(buildType)} URL` - )}: ${colors.cyan(previewUrl!)}\n` - ); - case 'failed': - spinner.stop(); - throw new DeploymentError( - `${colors.red(`❌ ${capitalize(buildType)} deploy fail.`)}\n${colors.magenta( - `${capitalize(buildType)} URL` - )}: ${colors.cyan(previewUrl!)}` - ); - case 'pending': - return wait - ? spinner.start(`${colors.yellow(`Pending ${buildType}`)}`) - : process.stdout.write(`Status: ${colors.yellow(`Pending ${buildType}`)}\n`); case 'skipped': - spinner.stop(); - return process.stdout.write(`${colors.yellow(`Skipped ${buildType}`)}\n`); - case 'running': - return wait - ? spinner.start(`${colors.yellow(`Running ${buildType}`)}`) - : process.stdout.write(`Status: ${colors.yellow(`Running ${buildType}`)}\n`); + return `${colors.yellow(`Skipped ${buildType}`)}\n`; + + case 'pending': { + const message = `${colors.yellow(`Pending ${buildType}`)}`; + return wait ? message : `Status: ${message}\n`; + } + case 'running': { + const message = `${colors.yellow(`Running ${buildType}`)}`; + return wait ? message : `Status: ${message}\n`; + } + case 'success': + return `${colors.green(`🚀 ${capitalize(buildType)} deploy success.`)}\n${colors.magenta( + `${capitalize(buildType)} URL` + )}: ${colors.cyan(url || 'No URL yet.')}\n`; + + case 'failed': + return `${colors.red(`❌ ${capitalize(buildType)} deploy fail.`)}\n${colors.magenta( + `${capitalize(buildType)} URL` + )}: ${colors.cyan(url || 'No URL yet.')}`; + + default: { + const message = `${colors.yellow(`No status yet for ${buildType} deploy`)}`; + + return wait ? message : `Status: ${message}\n`; + } } } diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts index 55dbea1a..f365dcee 100644 --- a/packages/cli/src/cms/commands/push.ts +++ b/packages/cli/src/cms/commands/push.ts @@ -1,9 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Config, slash } from '@redocly/openapi-core'; -import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous'; +import { slash } from '@redocly/openapi-core'; import { green, yellow } from 'colorette'; import pluralize = require('pluralize'); + +import type { OutputFormat, Config } from '@redocly/openapi-core'; + +import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous'; import { handlePushStatus } from './push-status'; import { ReuniteApiClient, getDomain, getApiKeys } from '../api'; @@ -29,13 +32,20 @@ export type PushOptions = { config?: string; 'wait-for-deployment'?: boolean; 'max-execution-time': number; + 'continue-on-deploy-failures'?: boolean; verbose?: boolean; + format?: Extract; }; type FileToUpload = { name: string; path: string }; -export async function handlePush(argv: PushOptions, config: Config) { - const startedAt = performance.now(); +export async function handlePush( + argv: PushOptions, + config: Config +): Promise<{ pushId: string } | void> { + const startedAt = performance.now(); // for printing execution time + const startTime = Date.now(); // for push-status command + const { organization, project: projectId, 'mount-path': mountPath, verbose } = argv; const orgId = organization || config.organization; @@ -111,8 +121,8 @@ export async function handlePush(argv: PushOptions, config: Config) { filesToUpload.forEach((f) => { process.stderr.write(green(`✓ ${f.name}\n`)); }); - process.stdout.write('\n'); + process.stdout.write('\n'); process.stdout.write(`Push ID: ${id}\n`); if (waitForDeployment) { @@ -126,6 +136,8 @@ export async function handlePush(argv: PushOptions, config: Config) { wait: true, domain, 'max-execution-time': maxExecutionTime, + 'start-time': startTime, + 'continue-on-deploy-failures': argv['continue-on-deploy-failures'], }, config ); @@ -139,6 +151,10 @@ export async function handlePush(argv: PushOptions, config: Config) { filesToUpload.length )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.` ); + + return { + pushId: id, + }; } catch (err) { const message = err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`; diff --git a/packages/cli/src/cms/commands/utils.ts b/packages/cli/src/cms/commands/utils.ts new file mode 100644 index 00000000..9646fcda --- /dev/null +++ b/packages/cli/src/cms/commands/utils.ts @@ -0,0 +1,52 @@ +import { pause } from '@redocly/openapi-core'; + +/** + * This function retries an operation until a condition is met or a timeout is exceeded. + * If the condition is not met within the timeout, an error is thrown. + * @operation The operation to retry. + * @condition The condition to check after each operation result. Return false to continue retrying. Return true to stop retrying. + * If not provided, the first result will be returned. + * @param onConditionNotMet Will be called with the last result right after checking condition and before timeout and retrying. + * @param onRetry Will be called right before retrying operation with the last result before retrying. + * @param startTime The start time of the operation. Default is the current time. + * @param retryTimeoutMs The maximum time to retry the operation. Default is 10 minutes. + * @param retryIntervalMs The interval between retries. Default is 5 seconds. + */ +export async function retryUntilConditionMet({ + operation, + condition, + onConditionNotMet, + onRetry, + startTime = Date.now(), + retryTimeoutMs = 600000, // 10 min + retryIntervalMs = 5000, // 5 sec +}: { + operation: () => Promise; + condition?: ((result: T) => boolean) | null; + onConditionNotMet?: (lastResult: T) => void; + onRetry?: (lastResult: T) => void | Promise; + startTime?: number; + retryTimeoutMs?: number; + retryIntervalMs?: number; +}): Promise { + async function attempt(): Promise { + const result = await operation(); + + if (!condition) { + return result; + } + + if (condition(result)) { + return result; + } else if (Date.now() - startTime > retryTimeoutMs) { + throw new Error('Timeout exceeded'); + } else { + onConditionNotMet?.(result); + await pause(retryIntervalMs); + await onRetry?.(result); + return attempt(); + } + } + + return attempt(); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e690a5e4..be81c3de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -204,9 +204,10 @@ yargs project: { description: 'Name of the project to push to.', type: 'string', + required: true, alias: 'p', }, - domain: { description: 'Specify a domain.', alias: 'd', type: 'string' }, + domain: { description: 'Specify a domain.', alias: 'd', type: 'string', required: false }, wait: { description: 'Wait for build to finish.', type: 'boolean', @@ -216,6 +217,11 @@ yargs description: 'Maximum execution time in seconds.', type: 'number', }, + 'continue-on-deploy-failures': { + description: 'Command does not fail even if the deployment fails.', + type: 'boolean', + default: false, + }, }), (argv) => { process.env.REDOCLY_CLI_COMMAND = 'push-status'; @@ -377,10 +383,15 @@ yargs type: 'boolean', default: false, }, + 'continue-on-deploy-failures': { + description: 'Command does not fail even if the deployment fails.', + type: 'boolean', + default: false, + }, }), (argv) => { process.env.REDOCLY_CLI_COMMAND = 'push'; - commandWrapper(commonPushHandler(argv))(argv as PushArguments); + commandWrapper(commonPushHandler(argv))(argv as Arguments); } ) .command( diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index eb21d388..696b1e02 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -11,7 +11,7 @@ import { lintConfigCallback } from './commands/lint'; import type { CommandOptions } from './types'; export function commandWrapper( - commandHandler?: (argv: T, config: Config, version: string) => Promise + commandHandler?: (argv: T, config: Config, version: string) => Promise ) { return async (argv: Arguments) => { let code: ExitCode = 2; diff --git a/packages/core/src/format/format.ts b/packages/core/src/format/format.ts index 2fefec8b..75d71b20 100644 --- a/packages/core/src/format/format.ts +++ b/packages/core/src/format/format.ts @@ -52,7 +52,8 @@ export type OutputFormat = | 'checkstyle' | 'codeclimate' | 'summary' - | 'github-actions'; + | 'github-actions' + | 'markdown'; export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals { let errors = 0; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfdf870d..b8369e20 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,11 @@ -export { BundleOutputFormat, readFileFromUrl, slash, doesYamlFileExist, isTruthy } from './utils'; +export { + BundleOutputFormat, + readFileFromUrl, + slash, + doesYamlFileExist, + isTruthy, + pause, +} from './utils'; export { Oas3_1Types } from './types/oas3_1'; export { Oas3Types } from './types/oas3'; export { Oas2Types } from './types/oas2'; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 33034e14..7f2dabae 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -271,6 +271,10 @@ export function nextTick() { }); } +export async function pause(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function getUpdatedFieldName(updatedField: string, updatedObject?: string) { return `${typeof updatedObject !== 'undefined' ? `${updatedObject}.` : ''}${updatedField}`; }