UNPKG

@salesforce/plugin-release-management

Version:
477 lines 21.9 kB
/* * Copyright (c) 2020, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import fs from 'node:fs'; import { exec as execSync } from 'node:child_process'; import { promisify } from 'node:util'; import chalk from 'chalk'; import semver from 'semver'; import got from 'got'; import { diff } from 'just-diff'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages, SfError } from '@salesforce/core'; import { env, parseJson } from '@salesforce/kit'; import { Octokit } from '@octokit/core'; import { paginateRest } from '@octokit/plugin-paginate-rest'; import { ensureString, get, isNumber } from '@salesforce/ts-types'; const MyOctokit = Octokit.plugin(paginateRest); const exec = promisify(execSync); Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-release-management', 'cli.artifacts.compare'); async function getOwnerAndRepo(plugin) { const result = await exec(`npm view ${plugin} repository.url --json`); try { const [owner, repo] = (result.stdout.includes('"') ? // it returned json (a string in quotes ex: "git+https://github.com/salesforcecli/plugin-org.git") JSON.parse(result.stdout.trim()) : // it returned non-json (just the string) https://github.com/npm/cli/issues/7537 result.stdout) .replace('git+https://github.com/', '') .replace('.git', '') .trim() .split('/'); return { owner, repo }; } catch (e) { throw SfError.create({ message: `Error getting owner and repo for ${plugin}`, cause: e, data: result }); } } async function getNpmVersions(plugin) { const versions = await exec(`npm view ${plugin} versions --json`); return semver.rsort(JSON.parse(versions.stdout)); } function verifyCurrentIsNewer(current, previous) { if (!current || !previous) return; if (semver.lt(current, previous)) { throw new Error(messages.getMessage('error.InvalidVersions', [current, previous])); } } export class SnapshotComparator { current; previous; constructor(current, previous) { this.current = current; this.previous = previous; } getChanges() { const commandAdditions = this.getCommandAdditions(); const commandRemovals = this.getCommandRemovals(); const commands = this.current .map((cmd) => cmd.command) .map((cmd) => { const aliasAdditions = this.getAliasAdditions(cmd); const aliasRemovals = this.getAliasRemovals(cmd); const flagAdditions = this.getFlagAdditions(cmd); const flagRemovals = this.getFlagRemovals(cmd); return { command: cmd, aliasAdditions, aliasRemovals, flagAdditions, flagRemovals, hasChanges: Boolean(aliasAdditions.length || aliasRemovals.length || flagAdditions.length || flagRemovals.length), hasBreakingChanges: Boolean(aliasRemovals.length || flagRemovals.length), }; }); const hasRemovals = commands.find((cmd) => cmd.aliasRemovals.length > 0 || cmd.flagRemovals.length > 0); const hasAdditions = commands.find((cmd) => cmd.aliasAdditions.length > 0 || cmd.flagAdditions.length > 0); const hasChanges = Boolean(commandAdditions.length ?? commandRemovals.length ?? hasRemovals ?? hasAdditions); const hasBreakingChanges = Boolean(commandRemovals.length || hasRemovals); return { commandAdditions: this.getCommandAdditions(), commandRemovals: this.getCommandRemovals(), commands, hasChanges, hasBreakingChanges, }; } getCommandAdditions() { return this.current .filter((cmd) => !this.previous.find((snapshot) => snapshot.command === cmd.command)) .map((cmd) => cmd.command); } getCommandRemovals() { return this.previous .filter((cmd) => !this.current.find((snapshot) => snapshot.command === cmd.command)) .map((cmd) => cmd.command); } getFlagAdditions(cmd) { const current = this.current.find((snapshot) => snapshot.command === cmd); const previous = this.previous.find((snapshot) => snapshot.command === cmd); if (!current || !previous) { return []; } return current.flags.filter((flag) => !previous.flags.includes(flag)); } getFlagRemovals(cmd) { const current = this.current.find((snapshot) => snapshot.command === cmd); const previous = this.previous.find((snapshot) => snapshot.command === cmd); if (!current || !previous) { return []; } return previous.flags.filter((flag) => !current.flags.includes(flag)); } getAliasAdditions(cmd) { const current = this.current.find((snapshot) => snapshot.command === cmd); const previous = this.previous.find((snapshot) => snapshot.command === cmd); if (!current || !previous) { return []; } return current.alias.filter((alias) => !previous.alias.includes(alias)); } getAliasRemovals(cmd) { const current = this.current.find((snapshot) => snapshot.command === cmd); const previous = this.previous.find((snapshot) => snapshot.command === cmd); if (!current || !previous) { return []; } return previous.alias.filter((alias) => !current.alias.includes(alias)); } } export class SchemaComparator { current; previous; constructor(current, previous) { this.current = current; this.previous = previous; } static makeReadable(current, previous, changes) { const humanReadableChanges = {}; for (const change of changes) { const lastPathElement = change.path[change.path.length - 1]; if (SchemaComparator.isMeaningless(lastPathElement)) continue; const objPath = change.path.join('.'); const existing = get(previous, objPath); const latest = get(current, objPath); const [commandId] = objPath.split('.definitions'); const readablePath = objPath.replace(`${commandId}.`, ''); if (!humanReadableChanges[commandId]) { humanReadableChanges[commandId] = []; } const lastElementIsNum = isNumber(lastPathElement); const basePath = lastElementIsNum ? readablePath.replace(`.${lastPathElement}`, '') : readablePath; switch (change.op) { case 'replace': humanReadableChanges[commandId].push(`❌ ${chalk.underline(readablePath)} was ${chalk.red.bold('changed')} from ${chalk.cyan(existing)} to ${chalk.cyan(latest)}`); break; case 'add': humanReadableChanges[commandId].push(lastElementIsNum ? `- Array item at ${chalk.underline(basePath)} was ${chalk.cyan('added')} to current schema` : `- ${chalk.underline(readablePath)} was ${chalk.cyan('added')} to current schema`); break; case 'remove': humanReadableChanges[commandId].push(lastElementIsNum ? `❌ Array item at ${chalk.underline(basePath)} was ${chalk.red.bold('not found')} in current schema` : `❌ ${chalk.underline(readablePath)} was ${chalk.red.bold('not found')} in current schema`); break; default: break; } } return humanReadableChanges; } static hasBreakingChange(changes) { return changes.some((change) => change.op === 'remove' || change.op === 'replace'); } static isMeaningless(n) { const meaninglessKeys = ['$comment', '__computed']; return meaninglessKeys.includes(n); } getChanges() { return diff(this.previous, this.current); } } export default class ArtifactsTest extends SfCommand { static summary = messages.getMessage('summary'); static examples = messages.getMessages('examples'); static flags = { plugin: Flags.string({ char: 'p', multiple: true, summary: messages.getMessage('flags.plugin.summary'), }), previous: Flags.string({ char: 'r', summary: messages.getMessage('flags.previous.summary'), }), current: Flags.string({ char: 'c', summary: messages.getMessage('flags.current.summary'), }), }; octokit; currentPlugins; previousPlugins; flags; packageJson; versions; current; previous; async run() { const { flags } = await this.parse(ArtifactsTest); this.flags = flags; const auth = ensureString(env.getString('GH_TOKEN') ?? env.getString('GITHUB_TOKEN'), 'The GH_TOKEN env var is required.'); this.octokit = new MyOctokit({ auth }); const fileData = await fs.promises.readFile('package.json', 'utf8'); this.packageJson = parseJson(fileData); if (!['@salesforce/cli', 'sfdx-cli'].includes(this.packageJson.name)) { throw messages.createError('error.InvalidRepo'); } this.versions = await getNpmVersions(this.packageJson.name); this.resolveVersions(); verifyCurrentIsNewer(this.current, this.previous); this.currentPlugins = await this.getCurrentPlugins(); this.previousPlugins = await this.getPluginsForVersion(this.previous); const promises = Object.keys(this.currentPlugins).map(async (plugin) => { const { owner, repo } = await getOwnerAndRepo(plugin); const { current, previous } = await this.getVersions(plugin, owner, repo); const currentSnapshot = await this.getSnapshot(owner, repo, current); const previousSnapshot = await this.getSnapshot(owner, repo, previous); const currentSchemas = await this.getSchemas(owner, repo, current); const previousSchemas = await this.getSchemas(owner, repo, previous); const schemaChanges = new SchemaComparator(currentSchemas, previousSchemas).getChanges(); return { [plugin]: { current: { version: current, snapshot: currentSnapshot, schemas: currentSchemas, }, previous: { version: previous, snapshot: previousSnapshot, schemas: previousSchemas, }, snapshotChanges: new SnapshotComparator(currentSnapshot, previousSnapshot).getChanges(), schemaChanges, }, }; }); const results = (await Promise.all(promises)).reduce((acc, result) => ({ ...acc, ...result }), {}); const summary = this.showResults(results); this.styledHeader('Summary'); for (const [plugin, logs] of Object.entries(summary)) { if (logs.length === 0) this.log('✅', plugin, chalk.dim(`(${this.previousPlugins[plugin]} -> ${this.currentPlugins[plugin]})`)); else { this.log(); this.log('❌', plugin, chalk.dim(`(${this.previousPlugins[plugin]} -> ${this.currentPlugins[plugin]})`)); for (const log of logs) this.log(' -', log); this.log(); } } this.log(); const removedPlugins = this.showRemovedPlugins(); this.log(); this.showAddedPlugins(); this.log(); const hasBreakingSnapshotChanges = Object.values(results).some((result) => result.snapshotChanges.hasBreakingChanges); const hasBreakingSchemaChanges = Object.values(results).some((result) => SchemaComparator.hasBreakingChange(result.schemaChanges)); if (hasBreakingSnapshotChanges || hasBreakingSchemaChanges || removedPlugins.length > 0) { throw messages.createError('error.BreakingChanges'); } return results; } showResults(results) { const summary = {}; for (const [plugin, result] of Object.entries(results)) { summary[plugin] = []; this.styledHeader(plugin); this.log('Current:', result.current.version); this.log('Previous:', result.previous.version); this.log(); this.log(chalk.underline.cyan('Snapshot Changes')); if (result.snapshotChanges.commandAdditions.length) { this.log(chalk.dim('New Commands:'), result.snapshotChanges.commandAdditions); } if (result.snapshotChanges.commandRemovals.length) { summary[plugin].push(`Removed commands: ${result.snapshotChanges.commandRemovals.join(', ')}`); this.log(chalk.red('❌ Removed Commands:'), result.snapshotChanges.commandRemovals); } for (const cmd of result.snapshotChanges.commands) { this.log(cmd.command, !cmd.hasChanges ? chalk.dim('No Changes') : ''); if (cmd.flagAdditions.length) this.log(chalk.dim(' Flag Additions:'), cmd.flagAdditions); if (cmd.flagRemovals.length) { summary[plugin].push(`${cmd.command} flag removals: ${cmd.flagRemovals.join(', ')}`); this.log(chalk.red(' ❌ Flag Removals:'), cmd.flagRemovals); } if (cmd.aliasAdditions.length) this.log(chalk.dim(' Alias Additions:'), cmd.aliasAdditions); if (cmd.aliasRemovals.length) { summary[plugin].push(`${cmd.command} alias removals: ${cmd.aliasRemovals.join(', ')}`); this.log(chalk.red(' ❌ Alias Removals:'), cmd.aliasRemovals); } } this.log(); this.log(chalk.underline.cyan('Schema Changes')); const humanReadableChanges = SchemaComparator.makeReadable(result.current.schemas, result.previous.schemas, result.schemaChanges); if (Object.keys(humanReadableChanges).length === 0) { this.log(chalk.dim('No changes have been detected.')); } for (const [commandId, readableChanges] of Object.entries(humanReadableChanges)) { this.log(); this.log(commandId); for (const change of readableChanges) { if (change.startsWith('❌')) summary[plugin].push(change.replace('❌', '')); this.log(` ${change}`); } } this.log(); } return summary; } showRemovedPlugins() { const removedPlugins = Object.keys(this.previousPlugins).filter((p) => !this.currentPlugins[p]); if (removedPlugins.length > 0) { this.styledHeader(chalk.red('Removed Plugins')); for (const plugin of removedPlugins) { this.log(plugin); } } return removedPlugins; } showAddedPlugins() { const addedPlugins = Object.keys(this.currentPlugins).filter((p) => !this.previousPlugins[p]); if (addedPlugins.length > 0) { this.styledHeader(chalk.green('Added Plugins')); for (const plugin of addedPlugins) { this.log(plugin); } } return addedPlugins; } resolveVersions() { this.current = this.flags.current ?? this.packageJson.version; this.previous = ensureString(this.flags.previous ?? this.versions.find((version) => semver.lt(version, this.current)), 'previous version not found'); this.log('Current Version:', this.current); this.log('Previous Version:', this.previous); if (this.flags.current && !this.versions.includes(this.flags.current)) { throw messages.createError('error.VersionNotFound', [this.flags.current]); } if (this.flags.previous && !this.versions.includes(this.flags.previous)) { throw messages.createError('error.VersionNotFound', [this.flags.previous]); } } async getCurrentPlugins() { return this.flags.current ? this.getPluginsForVersion(this.current) : this.filterPlugins(this.packageJson); } async getPluginsForVersion(version) { if (!this.packageJson.repository) { throw new SfError('the package json does not have a repository field'); } const [owner, repo] = this.packageJson.repository.split('/'); const response = await this.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path: 'package.json', accept: 'application/vnd.github.json', ref: version, }); // @ts-expect-error octokit doesn't have a type for this // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const pJson = JSON.parse(Buffer.from(response.data.content ?? '', 'base64').toString()); return this.filterPlugins(pJson); } filterPlugins(packageJson) { const pluginNames = [...(packageJson.oclif?.plugins ?? []), ...Object.keys(packageJson.oclif?.jitPlugins ?? {})]; const filtered = (this.flags.plugin ? pluginNames.filter((plugin) => this.flags.plugin?.includes(plugin)) : pluginNames).filter((plugin) => !plugin.startsWith('@oclif')); return filtered.reduce((acc, plugin) => ({ ...acc, [plugin]: packageJson.dependencies[plugin] ?? packageJson.oclif?.jitPlugins?.[plugin], }), {}); } async getVersions(plugin, owner, repo) { const tags = await this.getTags(owner, repo); const current = this.currentPlugins[plugin] ? tags.includes(this.currentPlugins[plugin]) ? this.currentPlugins[plugin] : `v${this.currentPlugins[plugin]}` : null; const previous = this.previousPlugins[plugin] ? tags.includes(this.previousPlugins[plugin]) ? this.previousPlugins[plugin] : `v${this.previousPlugins[plugin]}` : null; if (current?.includes('^') ?? current?.includes('~')) { throw messages.createError('error.VersionNotPinned', [plugin]); } return { current, previous }; } async getSchemas(owner, repo, ref) { if (!ref) return {}; try { const schemas = await this.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path: 'schemas', accept: 'application/vnd.github.json', ref, }); const schemaFiles = schemas.data.filter((f) => f.type === 'file'); const hasHookFiles = schemas.data.find((f) => f.name === 'hooks' && f.type === 'dir'); if (hasHookFiles) { const hooks = await this.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path: 'schemas/hooks', accept: 'application/vnd.github.json', ref, }); schemaFiles.push(...hooks.data.filter((f) => f.type === 'file')); } const files = schemaFiles.reduce((acc, file) => ({ ...acc, [file.name]: file.download_url }), {}); const promises = Object.entries(files).map(async ([name, url]) => { const contents = await got.get(url, { followRedirect: true, responseType: 'json' }); return { [name.replace(/-/g, ':').replace(/__/g, '-').replace('.json', '')]: contents.body }; }); return (await Promise.all(promises)).reduce((acc, result) => ({ ...acc, ...result }), {}); } catch { this.warn(`No schemas found for ${owner}/${repo}@${ref}`); return {}; } } async getSnapshot(owner, repo, ref) { if (!ref) return []; try { const response = await this.octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner, repo, path: 'command-snapshot.json', accept: 'application/vnd.github.json', ref, }); // @ts-expect-error octokit doesn't have a type for this // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return JSON.parse(Buffer.from(response.data.content ?? '', 'base64').toString()) .map(ensureAliases) .map(ensureFlags); } catch { this.warn(`No command-snapshot.json found for ${owner}/${repo}@${ref}`); return []; } } async getTags(owner, repo) { const response = await this.octokit.paginate('GET /repos/{owner}/{repo}/tags', { owner, repo, // eslint-disable-next-line camelcase per_page: 100, }); return response.map((tag) => tag.name); } } const ensureAliases = (snapshot) => snapshot.alias ? snapshot : { ...snapshot, alias: [] }; const ensureFlags = (snapshot) => snapshot.flags ? snapshot : { ...snapshot, flags: [] }; //# sourceMappingURL=compare.js.map