chore: send user-agent to Reunite (#1676)

This commit is contained in:
Roman Sainchuk
2024-09-05 16:18:10 +03:00
committed by GitHub
parent 8240c78388
commit 31edb19195
12 changed files with 329 additions and 151 deletions

View File

@@ -1,23 +1,53 @@
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
import fetchWithTimeout from '../utils/fetch-with-timeout'; import fetchWithTimeout from '../utils/fetch-with-timeout';
import nodeFetch from 'node-fetch'; import nodeFetch from 'node-fetch';
import { getProxyAgent } from '@redocly/openapi-core';
import { HttpsProxyAgent } from 'https-proxy-agent';
jest.mock('node-fetch'); jest.mock('node-fetch');
jest.mock('@redocly/openapi-core');
describe('fetchWithTimeout', () => { describe('fetchWithTimeout', () => {
beforeAll(() => {
// @ts-ignore
global.setTimeout = jest.fn();
global.clearTimeout = jest.fn();
});
beforeEach(() => {
(getProxyAgent as jest.Mock).mockReturnValueOnce(undefined);
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should call node-fetch with signal', async () => { it('should call node-fetch with signal', async () => {
// @ts-ignore await fetchWithTimeout('url', { timeout: 1000 });
global.setTimeout = jest.fn();
global.clearTimeout = jest.fn();
await fetchWithTimeout('url');
expect(global.setTimeout).toHaveBeenCalledTimes(1); expect(global.setTimeout).toHaveBeenCalledTimes(1);
expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal }); expect(nodeFetch).toHaveBeenCalledWith('url', {
signal: new AbortController().signal,
agent: undefined,
});
expect(global.clearTimeout).toHaveBeenCalledTimes(1); expect(global.clearTimeout).toHaveBeenCalledTimes(1);
}); });
it('should call node-fetch with proxy agent', async () => {
(getProxyAgent as jest.Mock).mockRestore();
const proxyAgent = new HttpsProxyAgent('http://localhost');
(getProxyAgent as jest.Mock).mockReturnValueOnce(proxyAgent);
await fetchWithTimeout('url');
expect(nodeFetch).toHaveBeenCalledWith('url', { agent: proxyAgent });
});
it('should call node-fetch without signal when timeout is not passed', async () => {
await fetchWithTimeout('url');
expect(global.setTimeout).not.toHaveBeenCalled();
expect(nodeFetch).toHaveBeenCalledWith('url', { agent: undefined });
expect(global.clearTimeout).not.toHaveBeenCalled();
});
}); });

View File

@@ -1,7 +1,7 @@
import fetch, { Response } from 'node-fetch'; import fetch, { Response } from 'node-fetch';
import * as FormData from 'form-data'; import * as FormData from 'form-data';
import { ReuniteApiClient, PushPayload } from '../api-client'; import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
jest.mock('node-fetch', () => ({ jest.mock('node-fetch', () => ({
default: jest.fn(), default: jest.fn(),
@@ -16,12 +16,15 @@ describe('ApiClient', () => {
const testDomain = 'test-domain.com'; const testDomain = 'test-domain.com';
const testOrg = 'test-org'; const testOrg = 'test-org';
const testProject = 'test-project'; const testProject = 'test-project';
const version = '1.0.0';
const command = 'push';
const expectedUserAgent = `redocly-cli/${version} ${command}`;
describe('getDefaultBranch()', () => { describe('getDefaultBranch()', () => {
let apiClient: ReuniteApiClient; let apiClient: ReuniteApiClient;
beforeEach(() => { beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken); apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
}); });
it('should get default project branch', async () => { it('should get default project branch', async () => {
@@ -41,6 +44,7 @@ describe('ApiClient', () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${testToken}`, Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
}, },
signal: expect.any(Object), signal: expect.any(Object),
} }
@@ -62,7 +66,7 @@ describe('ApiClient', () => {
}); });
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
new Error('Failed to fetch default branch: Project source not found') new ReuniteApiError('Failed to fetch default branch. Project source not found.', 404)
); );
}); });
@@ -76,7 +80,7 @@ describe('ApiClient', () => {
}); });
await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
new Error('Failed to fetch default branch: Not found') new ReuniteApiError('Failed to fetch default branch. Not found.', 404)
); );
}); });
}); });
@@ -89,7 +93,7 @@ describe('ApiClient', () => {
let apiClient: ReuniteApiClient; let apiClient: ReuniteApiClient;
beforeEach(() => { beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken); apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
}); });
it('should upsert remote', async () => { it('should upsert remote', async () => {
@@ -116,6 +120,7 @@ describe('ApiClient', () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${testToken}`, Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
}, },
body: JSON.stringify({ body: JSON.stringify({
mountPath: remotePayload.mountPath, mountPath: remotePayload.mountPath,
@@ -144,8 +149,9 @@ describe('ApiClient', () => {
}); });
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
new Error( new ReuniteApiError(
'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs' 'Failed to upsert remote. Not allowed to mount remote outside of project content path: /docs.',
403
) )
); );
}); });
@@ -153,6 +159,7 @@ describe('ApiClient', () => {
it('should throw statusText error if response is not ok', async () => { it('should throw statusText error if response is not ok', async () => {
mockFetchResponse({ mockFetchResponse({
ok: false, ok: false,
status: 404,
statusText: 'Not found', statusText: 'Not found',
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
unknownField: 'unknown-error', unknownField: 'unknown-error',
@@ -160,7 +167,7 @@ describe('ApiClient', () => {
}); });
await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
new Error('Failed to upsert remote: Not found') new ReuniteApiError('Failed to upsert remote. Not found.', 404)
); );
}); });
}); });
@@ -200,7 +207,7 @@ describe('ApiClient', () => {
let apiClient: ReuniteApiClient; let apiClient: ReuniteApiClient;
beforeEach(() => { beforeEach(() => {
apiClient = new ReuniteApiClient(testDomain, testToken); apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
}); });
it('should push to remote', async () => { it('should push to remote', async () => {
@@ -234,6 +241,7 @@ describe('ApiClient', () => {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${testToken}`, Authorization: `Bearer ${testToken}`,
'user-agent': expectedUserAgent,
}, },
}) })
); );
@@ -258,12 +266,13 @@ describe('ApiClient', () => {
await expect( await expect(
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
).rejects.toThrow(new Error('Failed to push: Cannot push to remote')); ).rejects.toThrow(new ReuniteApiError('Failed to push. Cannot push to remote.', 403));
}); });
it('should throw statusText error if response is not ok', async () => { it('should throw statusText error if response is not ok', async () => {
mockFetchResponse({ mockFetchResponse({
ok: false, ok: false,
status: 404,
statusText: 'Not found', statusText: 'Not found',
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
unknownField: 'unknown-error', unknownField: 'unknown-error',
@@ -272,7 +281,7 @@ describe('ApiClient', () => {
await expect( await expect(
apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
).rejects.toThrow(new Error('Failed to push: Not found')); ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
}); });
}); });
}); });

View File

@@ -1,7 +1,8 @@
import fetch from 'node-fetch';
import * as FormData from 'form-data'; import * as FormData from 'form-data';
import { getProxyAgent } from '@redocly/openapi-core'; import fetchWithTimeout, {
import fetchWithTimeout from '../../utils/fetch-with-timeout'; type FetchWithTimeoutOptions,
DEFAULT_FETCH_TIMEOUT,
} from '../../utils/fetch-with-timeout';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { ReadStream } from 'fs'; import type { ReadStream } from 'fs';
@@ -12,41 +13,76 @@ import type {
UpsertRemoteResponse, UpsertRemoteResponse,
} from './types'; } from './types';
class RemotesApiClient { export class ReuniteApiError extends Error {
constructor(private readonly domain: string, private readonly apiKey: string) {} constructor(message: string, public status: number) {
super(message);
}
}
private async getParsedResponse<T>(response: Response): Promise<T> { class ReuniteBaseApiClient {
constructor(protected version: string, protected command: string) {}
protected async getParsedResponse<T>(response: Response): Promise<T> {
const responseBody = await response.json(); const responseBody = await response.json();
if (response.ok) { if (response.ok) {
return responseBody as T; return responseBody as T;
} }
throw new Error(responseBody.title || response.statusText); throw new ReuniteApiError(
`${responseBody.title || response.statusText || 'Unknown error'}.`,
response.status
);
}
protected request(url: string, options: FetchWithTimeoutOptions) {
const headers = {
...options.headers,
'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
};
return fetchWithTimeout(url, {
...options,
headers,
});
}
}
class RemotesApiClient extends ReuniteBaseApiClient {
constructor(
private readonly domain: string,
private readonly apiKey: string,
version: string,
command: string
) {
super(version, command);
} }
async getDefaultBranch(organizationId: string, projectId: string) { async getDefaultBranch(organizationId: string, projectId: string) {
const response = await fetchWithTimeout(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response) {
throw new Error(`Failed to get default branch.`);
}
try { try {
const response = await this.request(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
{
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
const source = await this.getParsedResponse<ProjectSourceResponse>(response); const source = await this.getParsedResponse<ProjectSourceResponse>(response);
return source.branchName; return source.branchName;
} catch (err) { } catch (err) {
throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`); const message = `Failed to fetch default branch. ${err.message}`;
if (err instanceof ReuniteApiError) {
throw new ReuniteApiError(message, err.status);
}
throw new Error(message);
} }
} }
@@ -58,31 +94,34 @@ class RemotesApiClient {
mountBranchName: string; mountBranchName: string;
} }
): Promise<UpsertRemoteResponse> { ): Promise<UpsertRemoteResponse> {
const response = await fetchWithTimeout(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
mountPath: remote.mountPath,
mountBranchName: remote.mountBranchName,
type: 'CICD',
autoMerge: true,
}),
}
);
if (!response) {
throw new Error(`Failed to upsert.`);
}
try { try {
const response = await this.request(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
{
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
mountPath: remote.mountPath,
mountBranchName: remote.mountBranchName,
type: 'CICD',
autoMerge: true,
}),
}
);
return await this.getParsedResponse<UpsertRemoteResponse>(response); return await this.getParsedResponse<UpsertRemoteResponse>(response);
} catch (err) { } catch (err) {
throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`); const message = `Failed to upsert remote. ${err.message}`;
if (err instanceof ReuniteApiError) {
throw new ReuniteApiError(message, err.status);
}
throw new Error(message);
} }
} }
@@ -110,46 +149,61 @@ class RemotesApiClient {
} }
payload.isMainBranch && formData.append('isMainBranch', 'true'); payload.isMainBranch && formData.append('isMainBranch', 'true');
const response = await fetch(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
body: formData,
agent: getProxyAgent(),
}
);
try { try {
const response = await this.request(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
body: formData,
}
);
return await this.getParsedResponse<PushResponse>(response); return await this.getParsedResponse<PushResponse>(response);
} catch (err) { } catch (err) {
throw new Error(`Failed to push: ${err.message || 'Unknown error'}`); const message = `Failed to push. ${err.message}`;
if (err instanceof ReuniteApiError) {
throw new ReuniteApiError(message, err.status);
}
throw new Error(message);
} }
} }
async getRemotesList(organizationId: string, projectId: string, mountPath: string) { async getRemotesList({
const response = await fetchWithTimeout( organizationId,
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`, projectId,
{ mountPath,
method: 'GET', }: {
headers: { organizationId: string;
'Content-Type': 'application/json', projectId: string;
Authorization: `Bearer ${this.apiKey}`, mountPath: string;
}, }) {
}
);
if (!response) {
throw new Error(`Failed to get remotes list.`);
}
try { try {
const response = await this.request(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
{
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
return await this.getParsedResponse<ListRemotesResponse>(response); return await this.getParsedResponse<ListRemotesResponse>(response);
} catch (err) { } catch (err) {
throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`); const message = `Failed to get remote list. ${err.message}`;
if (err instanceof ReuniteApiError) {
throw new ReuniteApiError(message, err.status);
}
throw new Error(message);
} }
} }
@@ -162,25 +216,28 @@ class RemotesApiClient {
projectId: string; projectId: string;
pushId: string; pushId: string;
}) { }) {
const response = await fetchWithTimeout(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response) {
throw new Error(`Failed to get push status.`);
}
try { try {
const response = await this.request(
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
{
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
}
);
return await this.getParsedResponse<PushResponse>(response); return await this.getParsedResponse<PushResponse>(response);
} catch (err) { } catch (err) {
throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`); const message = `Failed to get push status. ${err.message}`;
if (err instanceof ReuniteApiError) {
throw new ReuniteApiError(message, err.status);
}
throw new Error(message);
} }
} }
} }
@@ -188,8 +245,18 @@ class RemotesApiClient {
export class ReuniteApiClient { export class ReuniteApiClient {
remotes: RemotesApiClient; remotes: RemotesApiClient;
constructor(public domain: string, private readonly apiKey: string) { constructor({
this.remotes = new RemotesApiClient(this.domain, this.apiKey); domain,
apiKey,
version,
command,
}: {
domain: string;
apiKey: string;
version: string;
command: 'push' | 'push-status';
}) {
this.remotes = new RemotesApiClient(domain, apiKey, version, command);
} }
} }

View File

@@ -644,7 +644,7 @@ describe('handlePushStatus()', () => {
version: 'cli-version', version: 'cli-version',
}) })
).rejects.toThrowErrorMatchingInlineSnapshot(` ).rejects.toThrowErrorMatchingInlineSnapshot(`
"✗ Failed to get push status. Reason: Timeout exceeded "✗ Failed to get push status. Reason: Timeout exceeded.
" "
`); `);
}); });

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { handlePush } from '../push'; import { handlePush } from '../push';
import { ReuniteApiClient } from '../../api'; import { ReuniteApiClient, ReuniteApiError } from '../../api';
const remotes = { const remotes = {
push: jest.fn(), push: jest.fn(),
@@ -332,6 +332,53 @@ describe('handlePush()', () => {
version: 'cli-version', version: 'cli-version',
}); });
expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key'); expect(ReuniteApiClient).toBeCalledWith({
domain: 'test-domain-from-env',
apiKey: 'test-api-key',
version: 'cli-version',
command: 'push',
});
});
it('should print error message', async () => {
const mockConfig = { apis: {} } as any;
process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
remotes.push.mockRestore();
remotes.push.mockRejectedValueOnce(new ReuniteApiError('Deprecated.', 412));
fsStatSyncSpy.mockReturnValueOnce({
isDirectory() {
return false;
},
} as any);
pathResolveSpy.mockImplementationOnce((p) => p);
pathRelativeSpy.mockImplementationOnce((_, p) => p);
pathDirnameSpy.mockImplementation((_: string) => '.');
expect(
handlePush({
argv: {
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,
},
config: mockConfig,
version: 'cli-version',
})
).rejects.toThrow('✗ File upload failed. Reason: Deprecated.');
}); });
}); });

View File

@@ -32,7 +32,7 @@ describe('retryUntilConditionMet()', () => {
retryIntervalMs: 100, retryIntervalMs: 100,
retryTimeoutMs: 1000, retryTimeoutMs: 1000,
}) })
).rejects.toThrow('Timeout exceeded'); ).rejects.toThrow('Timeout exceeded.');
}); });
it('should call "onConditionNotMet" and "onRetry" callbacks', async () => { it('should call "onConditionNotMet" and "onRetry" callbacks', async () => {

View File

@@ -4,7 +4,7 @@ import { Spinner } from '../../utils/spinner';
import { DeploymentError } from '../utils'; import { DeploymentError } from '../utils';
import { ReuniteApiClient, getApiKeys, getDomain } from '../api'; import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
import { capitalize } from '../../utils/js-utils'; import { capitalize } from '../../utils/js-utils';
import { retryUntilConditionMet } from './utils'; import { handleReuniteError, retryUntilConditionMet } from './utils';
import type { OutputFormat } from '@redocly/openapi-core'; import type { OutputFormat } from '@redocly/openapi-core';
import type { CommandArgs } from '../../wrapper'; import type { CommandArgs } from '../../wrapper';
@@ -41,7 +41,8 @@ export interface PushStatusSummary {
export async function handlePushStatus({ export async function handlePushStatus({
argv, argv,
config, config,
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | undefined> { version,
}: CommandArgs<PushStatusOptions>): Promise<PushStatusSummary | void> {
const startedAt = performance.now(); const startedAt = performance.now();
const spinner = new Spinner(); const spinner = new Spinner();
@@ -67,7 +68,7 @@ export async function handlePushStatus({
try { try {
const apiKey = getApiKeys(domain); const apiKey = getApiKeys(domain);
const client = new ReuniteApiClient(domain, apiKey); const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
let pushResponse: PushResponse; let pushResponse: PushResponse;
@@ -178,12 +179,7 @@ export async function handlePushStatus({
} catch (err) { } catch (err) {
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly. spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
const message = handleReuniteError('✗ Failed to get push status.', err);
err instanceof DeploymentError
? err.message
: `✗ Failed to get push status. Reason: ${err.message}\n`;
exitWithError(message);
return;
} finally { } finally {
spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly. spinner.stop(); // Spinner can block process exit, so we need to stop it explicitly.
} }

View File

@@ -3,9 +3,10 @@ import * as path from 'path';
import { slash } from '@redocly/openapi-core'; import { slash } from '@redocly/openapi-core';
import { pluralize } from '@redocly/openapi-core/lib/utils'; import { pluralize } from '@redocly/openapi-core/lib/utils';
import { green, yellow } from 'colorette'; import { green, yellow } from 'colorette';
import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous'; import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
import { handlePushStatus } from './push-status'; import { handlePushStatus } from './push-status';
import { ReuniteApiClient, getDomain, getApiKeys } from '../api'; import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
import { handleReuniteError } from './utils';
import type { OutputFormat } from '@redocly/openapi-core'; import type { OutputFormat } from '@redocly/openapi-core';
import type { CommandArgs } from '../../wrapper'; import type { CommandArgs } from '../../wrapper';
@@ -52,7 +53,7 @@ export async function handlePush({
const orgId = organization || config.organization; const orgId = organization || config.organization;
if (!argv.message || !argv.author || !argv.branch) { if (!argv.message || !argv.author || !argv.branch) {
exitWithError('Error: message, author and branch are required for push to the CMS.'); exitWithError('Error: message, author and branch are required for push to the Reunite.');
} }
if (!orgId) { if (!orgId) {
@@ -85,7 +86,7 @@ export async function handlePush({
return printExecutionTime('push', startedAt, `No files to upload`); return printExecutionTime('push', startedAt, `No files to upload`);
} }
const client = new ReuniteApiClient(domain, apiKey); const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId); const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
const remote = await client.remotes.upsert(orgId, projectId, { const remote = await client.remotes.upsert(orgId, projectId, {
mountBranchName: projectDefaultBranch, mountBranchName: projectDefaultBranch,
@@ -158,9 +159,7 @@ export async function handlePush({
pushId: id, pushId: id,
}; };
} catch (err) { } catch (err) {
const message = handleReuniteError('✗ File upload failed.', err);
err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
exitWithError(message);
} }
} }

View File

@@ -1,4 +1,8 @@
import { pause } from '@redocly/openapi-core'; import { pause } from '@redocly/openapi-core';
import { DeploymentError } from '../utils';
import { exitWithError } from '../../utils/miscellaneous';
import type { ReuniteApiError } from '../api';
/** /**
* This function retries an operation until a condition is met or a timeout is exceeded. * This function retries an operation until a condition is met or a timeout is exceeded.
@@ -39,7 +43,7 @@ export async function retryUntilConditionMet<T>({
if (condition(result)) { if (condition(result)) {
return result; return result;
} else if (Date.now() - startTime > retryTimeoutMs) { } else if (Date.now() - startTime > retryTimeoutMs) {
throw new Error('Timeout exceeded'); throw new Error('Timeout exceeded.');
} else { } else {
onConditionNotMet?.(result); onConditionNotMet?.(result);
await pause(retryIntervalMs); await pause(retryIntervalMs);
@@ -50,3 +54,13 @@ export async function retryUntilConditionMet<T>({
return attempt(); return attempt();
} }
export function handleReuniteError(
message: string,
error: ReuniteApiError | DeploymentError | Error
) {
const errorMessage =
error instanceof DeploymentError ? error.message : `${message} Reason: ${error.message}\n`;
return exitWithError(errorMessage);
}

View File

@@ -1,24 +1,33 @@
import nodeFetch from 'node-fetch'; import nodeFetch, { type RequestInit } from 'node-fetch';
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
import { getProxyAgent } from '@redocly/openapi-core'; import { getProxyAgent } from '@redocly/openapi-core';
const TIMEOUT = 3000; export const DEFAULT_FETCH_TIMEOUT = 3000;
export default async (url: string, options = {}) => { export type FetchWithTimeoutOptions = RequestInit & {
try { timeout?: number;
const controller = new AbortController(); };
const timeout = setTimeout(() => {
controller.abort();
}, TIMEOUT);
const res = await nodeFetch(url, { export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => {
signal: controller.signal, if (!timeout) {
return nodeFetch(url, {
...options, ...options,
agent: getProxyAgent(), agent: getProxyAgent(),
}); });
clearTimeout(timeout);
return res;
} catch (e) {
return;
} }
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
const res = await nodeFetch(url, {
signal: controller.signal,
...options,
agent: getProxyAgent(),
});
clearTimeout(timeoutId);
return res;
}; };

View File

@@ -22,7 +22,7 @@ import { deprecatedRefDocsSchema } from '@redocly/config/lib/reference-docs-conf
import { outputExtensions } from '../types'; import { outputExtensions } from '../types';
import { version } from './update-version-notifier'; import { version } from './update-version-notifier';
import { DESTINATION_REGEX } from '../commands/push'; import { DESTINATION_REGEX } from '../commands/push';
import fetch from './fetch-with-timeout'; import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
import type { Arguments } from 'yargs'; import type { Arguments } from 'yargs';
import type { import type {
@@ -569,6 +569,7 @@ export async function sendTelemetry(
spec_full_version, spec_full_version,
}; };
await fetch(`https://api.redocly.com/registry/telemetry/cli`, { await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
timeout: DEFAULT_FETCH_TIMEOUT,
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',

View File

@@ -2,7 +2,7 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs'; import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
import { compare } from 'semver'; import { compare } from 'semver';
import fetch from './fetch-with-timeout'; import fetch, { DEFAULT_FETCH_TIMEOUT } from './fetch-with-timeout';
import { cyan, green, yellow } from 'colorette'; import { cyan, green, yellow } from 'colorette';
import { cleanColors } from './miscellaneous'; import { cleanColors } from './miscellaneous';
@@ -34,10 +34,16 @@ const isNewVersionAvailable = (current: string, latest: string) => compare(curre
const getLatestVersion = async (packageName: string): Promise<string | undefined> => { const getLatestVersion = async (packageName: string): Promise<string | undefined> => {
const latestUrl = `http://registry.npmjs.org/${packageName}/latest`; const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
const response = await fetch(latestUrl);
if (!response) return; try {
const info = await response.json(); const response = await fetch(latestUrl, { timeout: DEFAULT_FETCH_TIMEOUT });
return info.version; const info = await response.json();
return info.version;
} catch {
// Do nothing
return;
}
}; };
export const cacheLatestVersion = () => { export const cacheLatestVersion = () => {