mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
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 <github@lornajane.net> * Apply suggestions from code review Co-authored-by: Andrew Tatomyr <andrew.tatomyr@redocly.com> Co-authored-by: Roman Sainchuk <albuman32@gmail.com> * 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 <github@lornajane.net> Co-authored-by: Andrew Tatomyr <andrew.tatomyr@redocly.com> Co-authored-by: Roman Sainchuk <albuman32@gmail.com>
This commit is contained in:
committed by
GitHub
parent
89af9c6eb2
commit
4cf08db490
5
.changeset/heavy-swans-sin.md
Normal file
5
.changeset/heavy-swans-sin.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@redocly/cli": minor
|
||||
---
|
||||
|
||||
Added return values for the `push` and `push-status` commands.
|
||||
@@ -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 -- <path/to/your/file.test.ts>`
|
||||
To update snapshots, run `npm run unit -- -u`.
|
||||
|
||||
To get coverage per package run `npm run coverage:cli` or `npm run coverage:core`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <test-author@mail.com>',
|
||||
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';
|
||||
|
||||
62
packages/cli/src/cms/commands/__tests__/utils.test.ts
Normal file
62
packages/cli/src/cms/commands/__tests__/utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<OutputFormat, 'stylish'>;
|
||||
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<PushStatusSummary | undefined> {
|
||||
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<PushResponse> {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutputFormat, 'stylish'>;
|
||||
};
|
||||
|
||||
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}`;
|
||||
|
||||
52
packages/cli/src/cms/commands/utils.ts
Normal file
52
packages/cli/src/cms/commands/utils.ts
Normal file
@@ -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<T>({
|
||||
operation,
|
||||
condition,
|
||||
onConditionNotMet,
|
||||
onRetry,
|
||||
startTime = Date.now(),
|
||||
retryTimeoutMs = 600000, // 10 min
|
||||
retryIntervalMs = 5000, // 5 sec
|
||||
}: {
|
||||
operation: () => Promise<T>;
|
||||
condition?: ((result: T) => boolean) | null;
|
||||
onConditionNotMet?: (lastResult: T) => void;
|
||||
onRetry?: (lastResult: T) => void | Promise<void>;
|
||||
startTime?: number;
|
||||
retryTimeoutMs?: number;
|
||||
retryIntervalMs?: number;
|
||||
}): Promise<T> {
|
||||
async function attempt(): Promise<T> {
|
||||
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();
|
||||
}
|
||||
@@ -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<PushArguments>);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
|
||||
@@ -11,7 +11,7 @@ import { lintConfigCallback } from './commands/lint';
|
||||
import type { CommandOptions } from './types';
|
||||
|
||||
export function commandWrapper<T extends CommandOptions>(
|
||||
commandHandler?: (argv: T, config: Config, version: string) => Promise<void>
|
||||
commandHandler?: (argv: T, config: Config, version: string) => Promise<unknown>
|
||||
) {
|
||||
return async (argv: Arguments<T>) => {
|
||||
let code: ExitCode = 2;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -271,6 +271,10 @@ export function nextTick() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function pause(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getUpdatedFieldName(updatedField: string, updatedObject?: string) {
|
||||
return `${typeof updatedObject !== 'undefined' ? `${updatedObject}.` : ''}${updatedField}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user