From f6f1bf0cffa55683f013a1f89e017d0760d6fc60 Mon Sep 17 00:00:00 2001 From: Roman Sainchuk Date: Wed, 8 Nov 2023 17:43:16 +0200 Subject: [PATCH] feat: add basic bh push command implementation --- package-lock.json | 22 +-- packages/cli/src/commands/push-bh.ts | 146 ++++++++++++++++++++ packages/cli/src/index.ts | 56 ++++++++ packages/cli/src/types.ts | 2 + packages/core/package.json | 1 + packages/core/src/index.ts | 2 +- packages/core/src/redocly/bh/api-client.ts | 150 +++++++++++++++++++++ packages/core/src/redocly/bh/api-keys.ts | 26 ++++ packages/core/src/redocly/bh/domains.ts | 11 ++ packages/core/src/redocly/bh/index.ts | 9 ++ packages/core/src/redocly/index.ts | 2 + 11 files changed, 410 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/push-bh.ts create mode 100644 packages/core/src/redocly/bh/api-client.ts create mode 100644 packages/core/src/redocly/bh/api-keys.ts create mode 100644 packages/core/src/redocly/bh/domains.ts create mode 100644 packages/core/src/redocly/bh/index.ts diff --git a/package-lock.json b/package-lock.json index f1bc130217..d229d669f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4510,8 +4510,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -5120,7 +5119,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5466,7 +5464,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6315,7 +6312,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -9147,7 +9143,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -9156,7 +9151,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12895,6 +12889,7 @@ "@redocly/ajv": "^8.11.0", "@types/node": "^14.11.8", "colorette": "^1.2.0", + "form-data": "^3.0.1", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "lodash.isequal": "^4.5.0", @@ -15560,6 +15555,7 @@ "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "colorette": "^1.2.0", + "form-data": "^3.0.1", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "lodash.isequal": "^4.5.0", @@ -16414,8 +16410,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "available-typed-arrays": { "version": "1.0.5", @@ -16847,7 +16842,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -17117,8 +17111,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "detect-indent": { "version": "6.1.0", @@ -17775,7 +17768,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -19864,14 +19856,12 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "requires": { "mime-db": "1.52.0" } diff --git a/packages/cli/src/commands/push-bh.ts b/packages/cli/src/commands/push-bh.ts new file mode 100644 index 0000000000..fee5b28833 --- /dev/null +++ b/packages/cli/src/commands/push-bh.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { BlueHarvest, Config } from '@redocly/openapi-core'; +import * as pluralize from 'pluralize'; +import { exitWithError, printExecutionTime } from '../utils'; +import { green } from 'colorette'; + +export type PushBhOptions = { + organization?: string; + project: string; + mountPath: string; + + branch: string; + author: string; + message: string; + files: string[]; + + domain?: string; + config?: string; +}; + +type FileToUpload = { path: string }; + +export async function handleBhPush(argv: PushBhOptions, config: Config) { + const startedAt = performance.now(); + const { organization, project: projectId, mountPath } = argv; + + const orgId = organization || config.organization; + + if (!orgId) { + return exitWithError( + `No organization provided, please use --organization option or specify the 'organization' field in the config file.` + ); + } + + const domain = argv.domain || BlueHarvest.getDomain(); + + if (!domain) { + return exitWithError( + `No domain provided, please use --domain option or environment variable REDOCLY_DOMAIN.` + ); + } + + try { + const author = parseCommitAuthor(argv.author); + const apiKey = BlueHarvest.getApiKeys(domain); + const filesToUpload = collectFilesToPush(argv.files); + + if (!filesToUpload.length) { + return printExecutionTime('push-bh', startedAt, `No files to upload`); + } + + const client = new BlueHarvest.ApiClient(domain, apiKey); + const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId); + const remote = await client.remotes.upsert(orgId, projectId, { + mountBranchName: projectDefaultBranch, + mountPath, + }); + + process.stdout.write( + `Uploading ${filesToUpload.length} ${pluralize('file', filesToUpload.length)}:\n\n` + ); + + const { branchName: filesBranch } = await client.remotes.push( + orgId, + projectId, + remote.id, + { + commit: { + message: argv.message, + author, + branchName: argv.branch, + }, + }, + filesToUpload.map((f) => ({ path: f.path, stream: fs.createReadStream(f.path) })) + ); + + filesToUpload.forEach((f) => { + process.stdout.write(green(`✓ ${path.relative(process.cwd(), f.path)}\n`)); + }); + + printExecutionTime( + 'push-bh', + startedAt, + `${pluralize( + 'file', + filesToUpload.length + )} uploaded to organization ${orgId}, project ${projectId}, branch ${filesBranch}` + ); + } catch (err) { + exitWithError(`✗ File upload failed. Reason: ${err.message}\n`); + } +} + +function parseCommitAuthor(author: string): { name: string; email: string } { + // Author Name + const reg = /^.+\s<[^<>]+>$/; + + if (!reg.test(author)) { + throw new Error('Invalid author format. Use "Author Name "'); + } + + const [name, email] = author.split('<'); + + return { + name: name.trim(), + email: email.replace('>', '').trim(), + }; +} + +function collectFilesToPush(files: string[]): FileToUpload[] { + const collectedFiles = new Set(); + + for (const file of files) { + if (fs.statSync(file).isDirectory()) { + const fileList = getFilesList(file, []); + fileList.forEach((f) => collectedFiles.add(f)); + } else { + collectedFiles.add(file); + } + } + + return Array.from(collectedFiles).map((f) => getFileEntry(f)); +} + +function getFileEntry(filename: string): FileToUpload { + return { + path: path.resolve(filename), + }; +} + +function getFilesList(dir: string, files: string[]): string[] { + const filesAndDirs = fs.readdirSync(dir); + + for (const name of filesAndDirs) { + const currentPath = path.join(dir, name); + + if (fs.statSync(currentPath).isDirectory()) { + files = getFilesList(currentPath, files); + } else { + files.push(currentPath); + } + } + + return files; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 37369c549a..8b92b65c1b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ import { handleStats } from './commands/stats'; import { handleSplit } from './commands/split'; import { handleJoin } from './commands/join'; import { handlePush, transformPush } from './commands/push'; +import { handleBhPush } from './commands/push-bh'; import { handleLint } from './commands/lint'; import { handleBundle } from './commands/bundle'; import { handleLogin } from './commands/login'; @@ -226,6 +227,61 @@ yargs commandWrapper(transformPush(handlePush))(argv); } ) + .command( + 'push-bh ', + 'Push files to the Redocly BlueHarvest.', + (yargs) => + yargs + .positional('files', { + type: 'string', + array: true, + required: true, + default: [], + description: 'List of files and folders (or glob) to upload', + }) + .option({ + organization: { + description: 'Name of the organization to push to.', + type: 'string', + alias: 'o', + }, + project: { + description: 'Name of the project to push to.', + type: 'string', + required: true, + alias: 'p', + }, + mountPath: { + description: 'The path files should be pushed to.', + type: 'string', + required: true, + alias: 'mp', + }, + branch: { + description: 'Branch name files are pushed from.', + type: 'string', + required: true, + alias: 'b', + }, + author: { + description: 'Author of the commit.', + type: 'string', + required: true, + alias: 'a', + }, + message: { + description: 'Commit messsage.', + type: 'string', + required: true, + alias: 'm', + }, + domain: { description: 'Specify a domain.', alias: 'd', type: 'string' }, + }), + (argv) => { + process.env.REDOCLY_CLI_COMMAND = 'push-bh'; + commandWrapper(handleBhPush)(argv); + } + ) .command( 'lint [apis...]', 'Lint definition.', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index c33f2d34dd..f833dc5e78 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -8,6 +8,7 @@ import type { StatsOptions } from './commands/stats'; import type { SplitOptions } from './commands/split'; import type { PreviewDocsOptions } from './commands/preview-docs'; import type { BuildDocsArgv } from './commands/build-docs/types'; +import type { PushBhOptions } from './commands/push-bh'; export type Totals = { errors: number; @@ -26,6 +27,7 @@ export type CommandOptions = | SplitOptions | JoinOptions | PushOptions + | PushBhOptions | LintOptions | BundleOptions | LoginOptions diff --git a/packages/core/package.json b/packages/core/package.json index 505b5a971a..0be9bd8e15 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ "lodash.isequal": "^4.5.0", "minimatch": "^5.0.1", "node-fetch": "^2.6.1", + "form-data": "^3.0.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfdf870db1..bcf2a2a96e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,7 +41,7 @@ export { ResolvedApi, } from './config'; -export { RedoclyClient, isRedoclyRegistryURL } from './redocly'; +export { RedoclyClient, isRedoclyRegistryURL, BlueHarvest } from './redocly'; export { Source, diff --git a/packages/core/src/redocly/bh/api-client.ts b/packages/core/src/redocly/bh/api-client.ts new file mode 100644 index 0000000000..272761b589 --- /dev/null +++ b/packages/core/src/redocly/bh/api-client.ts @@ -0,0 +1,150 @@ +import * as path from 'path'; +import fetch from 'node-fetch'; +import * as FormData from 'form-data'; + +import type { Response } from 'node-fetch'; +import type { ReadStream } from 'fs'; + +type ProjectSourceResponse = { + branchName: string; + contentPath: string; + isInternal: boolean; +}; + +type UpsertRemoteResponse = { + id: string; + type: 'CICD'; + mountPath: string; + mountBranchName: string; + organizationId: string; + projectId: string; +}; + +type PushResponse = { + branchName: string; + hasChanges: boolean; + commitSha: string; + outdated: boolean; +}; + +class RemotesApiClient { + constructor(private readonly domain: string, private readonly apiKey: string) {} + + private async getParsedResponse(response: Response): Promise { + const responseBody = await response.json(); + + if (response.ok) { + return responseBody as T; + } + + throw new Error(responseBody.title || response.statusText); + } + + async getDefaultBranch(organizationId: string, projectId: string) { + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + } + ); + + try { + const source = await this.getParsedResponse(response); + + return source.branchName; + } catch (err) { + throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`); + } + } + + async upsert( + organizationId: string, + projectId: string, + remote: { + mountPath: string; + mountBranchName: string; + } + ): Promise { + const response = await fetch( + `${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, + }), + } + ); + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`); + } + } + + async push( + organizationId: string, + projectId: string, + remoteId: string, + payload: { + commit: { + message: string; + branchName: string; + author: { + name: string; + email: string; + }; + }; + }, + files: { path: string; stream: ReadStream }[] + ): Promise { + const formData = new FormData(); + + formData.append('commit[message]', payload.commit.message); + formData.append('commit[author][name]', payload.commit.author.name); + formData.append('commit[author][email]', payload.commit.author.email); + formData.append('commit[branchName]', payload.commit.branchName); + + for (const file of files) { + const parsedFilePath = path.parse(file.path); + + formData.append(`files[${parsedFilePath.base}]`, file.stream, parsedFilePath.name); + } + + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes/${remoteId}/push`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + body: formData, + } + ); + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to push: ${err.message || 'Unknown error'}`); + } + } +} + +export class ApiClient { + remotes: RemotesApiClient; + + constructor(public domain: string, private readonly apiKey: string) { + this.remotes = new RemotesApiClient(this.domain, this.apiKey); + } +} diff --git a/packages/core/src/redocly/bh/api-keys.ts b/packages/core/src/redocly/bh/api-keys.ts new file mode 100644 index 0000000000..0d7f258367 --- /dev/null +++ b/packages/core/src/redocly/bh/api-keys.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path'; +import { homedir } from 'os'; +import { isNotEmptyObject } from '../../utils'; +import { existsSync, readFileSync } from 'fs'; +import { env } from '../../env'; + +const TOKEN_FILENAME = '.redocly-config.json'; + +function readCredentialsFile(credentialsPath: string) { + return existsSync(credentialsPath) ? JSON.parse(readFileSync(credentialsPath, 'utf-8')) : {}; +} + +export function getApiKeys(domain: string) { + const credentialsPath = resolve(homedir(), TOKEN_FILENAME); + const credentials = readCredentialsFile(credentialsPath); + + if (env.REDOCLY_AUTHORIZATION) { + return env.REDOCLY_AUTHORIZATION; + } + + if (isNotEmptyObject(credentials) && credentials[domain]) { + return credentials[domain]; + } + + throw new Error('No api key provided, please use environment variable REDOCLY_DOMAIN.'); +} diff --git a/packages/core/src/redocly/bh/domains.ts b/packages/core/src/redocly/bh/domains.ts new file mode 100644 index 0000000000..2fb0dad8c2 --- /dev/null +++ b/packages/core/src/redocly/bh/domains.ts @@ -0,0 +1,11 @@ +import { env } from '../../env'; + +const DEFAULT_DOMAIN = 'https://app.beta.redocly.com'; + +export function getDomain(): string { + if (env.REDOCLY_DOMAIN) { + return env.REDOCLY_DOMAIN; + } + + return DEFAULT_DOMAIN; +} diff --git a/packages/core/src/redocly/bh/index.ts b/packages/core/src/redocly/bh/index.ts new file mode 100644 index 0000000000..36dead007a --- /dev/null +++ b/packages/core/src/redocly/bh/index.ts @@ -0,0 +1,9 @@ +import * as client from './api-client'; +import * as domains from './domains'; +import * as apiKeys from './api-keys'; + +export namespace BlueHarvest { + export const ApiClient = client.ApiClient; + export const getDomain = domains.getDomain; + export const getApiKeys = apiKeys.getApiKeys; +} diff --git a/packages/core/src/redocly/index.ts b/packages/core/src/redocly/index.ts index b0571ff5be..66b33e5737 100644 --- a/packages/core/src/redocly/index.ts +++ b/packages/core/src/redocly/index.ts @@ -8,6 +8,8 @@ import { RegionalToken, RegionalTokenWithValidity } from './redocly-client-types import { isNotEmptyObject } from '../utils'; import { colorize } from '../logger'; +export * from './bh'; + import type { AccessTokens, Region } from '../config/types'; const TOKEN_FILENAME = '.redocly-config.json';