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:
Oleksiy Kachynskyy
2024-04-25 14:42:45 +03:00
committed by GitHub
parent 89af9c6eb2
commit 4cf08db490
15 changed files with 953 additions and 199 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/cli": minor
---
Added return values for the `push` and `push-status` commands.

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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(
await expect(
handlePushStatus(
{
domain: 'test-domain',
organization: '',
project: 'test-project',
pushId: 'test-push-id',
'max-execution-time': 1000,
},
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(
await expect(
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'
)
).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
"
`);
});
});
});

View File

@@ -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';

View 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);
});
});

View File

@@ -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,
});
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,
});
}
printPushStatusInfo();
return;
if (pushResponse.isMainBranch) {
printPushStatus({
buildType: 'production',
spinner,
wait,
push: pushResponse,
continueOnDeployFailures,
});
printScorecard(pushResponse.status.production.scorecard);
}
printPushStatusInfo({ orgId, projectId, pushId, startedAt });
const pushPreview = await getAndPrintPushStatus(client, 'preview');
printScorecard(pushPreview.status.preview.scorecard);
const summary: PushStatusSummary = {
preview: pushResponse.status.preview,
production: pushResponse.isMainBranch ? pushResponse.status.production : null,
commit: pushResponse.commit,
};
if (pushPreview.isMainBranch) {
await getAndPrintPushStatus(client, 'production');
printScorecard(pushPreview.status.production.scorecard);
}
printPushStatusInfo();
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);
return;
} finally {
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
}
}
function printPushStatusInfo() {
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');
}
}
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);
function printPushStatus({
buildType,
spinner,
push,
continueOnDeployFailures,
}: {
buildType: 'preview' | 'production';
spinner: Spinner;
wait?: boolean;
push?: PushResponse | null;
continueOnDeployFailures: boolean;
}) {
if (!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`)
colors.yellow(
`Files not added to your project. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`
)
);
} else {
displayDeploymentAndBuildStatus({
status: push.status[buildType].deploy.status,
previewUrl: push.status[buildType].deploy.url,
url: push.status[buildType].deploy.url,
buildType,
spinner,
wait,
continueOnDeployFailures,
});
}
return push;
}
}
function printScorecard(scorecard: ScorecardItem[]) {
if (!scorecard.length) {
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`;
}
}
}

View File

@@ -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}`;

View 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();
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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}`;
}