Extract the scanning functionality from code-analyzer, move common code into a separate package (#34600)

Also add contributor command and auto tagging to release-post-generator (#34608)
This commit is contained in:
Sam Seay 2022-09-11 09:55:53 +12:00 committed by GitHub
parent 03b9032de8
commit 614d98ff60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 5116 additions and 4202 deletions

View File

@ -13,12 +13,12 @@ jobs:
run: | run: |
npm install -g pnpm@^6.24.2 npm install -g pnpm@^6.24.2
npm -g i @wordpress/env@5.1.0 npm -g i @wordpress/env@5.1.0
pnpm install --filter code-analyzer pnpm install --filter code-analyzer --filter cli-core
- name: Run analyzer - name: Run analyzer
id: run id: run
run: | run: |
version=$(./tools/code-analyzer/bin/dev major_minor "${{ github.head_ref || github.ref_name }}" "plugins/woocommerce/woocommerce.php") version=$(pnpm run analyzer --filter code-analyzer -- major-minor "${{ github.head_ref || github.ref_name }}" "plugins/woocommerce/woocommerce.php" | tail -n 1)
./tools/code-analyzer/bin/dev analyzer "$GITHUB_HEAD_REF" $version pnpm run analyzer --filter code-analyzer -- "$GITHUB_HEAD_REF" $version -o "github"
- name: Print results - name: Print results
id: results id: results
run: echo "::set-output name=results::${{ steps.run.outputs.templates }}${{ steps.run.outputs.wphooks }}${{ steps.run.outputs.schema }}${{ steps.run.outputs.database }}" run: echo "::set-output name=results::${{ steps.run.outputs.templates }}${{ steps.run.outputs.wphooks }}${{ steps.run.outputs.schema }}${{ steps.run.outputs.database }}"

File diff suppressed because it is too large Load Diff

View File

@ -7,5 +7,6 @@ packages:
- 'tools/create-extension' - 'tools/create-extension'
- 'tools/package-release' - 'tools/package-release'
- 'tools/cherry-pick' - 'tools/cherry-pick'
- 'tools/release-post-generator' - 'tools/release-posts'
- 'tools/cli-core'
- 'tools/version-bump' - 'tools/version-bump'

4
tools/cli-core/README.md Normal file
View File

@ -0,0 +1,4 @@
### CLI Core
This package contains utilities and libraries providing core functionality to
cli tools within `tools` so that this functionality can be easily reused.

View File

@ -0,0 +1,21 @@
{
"name": "cli-core",
"version": "0.0.1",
"description": "Core functionality for CLI tools/commands.",
"main": " ",
"scripts": {},
"author": "Automattic",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
"typescript": "^4.7.4"
},
"dependencies": {
"chalk": "^4.1.2",
"dotenv": "^10.0.0",
"ora": "^5.4.1",
"simple-git": "^3.10.0",
"ts-node": "^10.9.1",
"uuid": "^8.3.2"
}
}

232
tools/cli-core/src/git.ts Normal file
View File

@ -0,0 +1,232 @@
/**
* External dependencies
*/
import { execSync } from 'child_process';
import { join } from 'path';
import { tmpdir } from 'os';
import { mkdirSync } from 'fs';
import { simpleGit } from 'simple-git';
import { v4 } from 'uuid';
import { mkdir, rm } from 'fs/promises';
import { URL } from 'node:url';
/**
* Get filename from patch
*
* @param {string} str String to extract filename from.
* @return {string} formatted filename.
*/
export const getFilename = ( str: string ): string => {
return str.replace( /^a(.*)\s.*/, '$1' );
};
/**
* Get patches
*
* @param {string} content Patch content.
* @param {RegExp} regex Regex to find specific patches.
* @return {string[]} Array of patches.
*/
export const getPatches = ( content: string, regex: RegExp ): string[] => {
const patches = content.split( 'diff --git ' );
const changes: string[] = [];
for ( const p in patches ) {
const patch = patches[ p ];
const id = patch.match( regex );
if ( id ) {
changes.push( patch );
}
}
return changes;
};
/**
* Check if a string is a valid url.
*
* @param {string} maybeURL - the URL string to check
* @return {boolean} whether the string is a valid URL or not.
*/
const isUrl = ( maybeURL: string ) => {
try {
new URL( maybeURL );
return true;
} catch ( e ) {
return false;
}
};
/**
* Clone a git repository.
*
* @param {string} repoPath - the path (either URL or file path) to the repo to clone.
* @return {Promise<string>} the path to the cloned repo.
*/
export const cloneRepo = async ( repoPath: string ) => {
const folderPath = join( tmpdir(), 'code-analyzer-tmp', v4() );
mkdirSync( folderPath, { recursive: true } );
const git = simpleGit( { baseDir: folderPath } );
await git.clone( repoPath, folderPath );
// If this is a local clone then the simplest way to maintain remote settings is to copy git config across
if ( ! isUrl( repoPath ) ) {
execSync( `cp ${ repoPath }/.git/config ${ folderPath }/.git/config` );
}
// Update the repo.
await git.fetch();
return folderPath;
};
/**
* Do a minimal sparse checkout of a github repo.
*
* @param {string} githubRepoUrl - the URL to the repo to checkout.
* @param {string} path - the path to checkout to.
* @param {Array<string>} directories - the files or directories to checkout.
* @return {Promise<string>} the path to the cloned repo.
*/
export const sparseCheckoutRepo = async (
githubRepoUrl: string,
path: string,
directories: string[]
) => {
const folderPath = join( tmpdir(), path );
// clean up if it already exists.
await rm( folderPath, { recursive: true, force: true } );
await mkdir( folderPath, { recursive: true } );
const git = simpleGit( { baseDir: folderPath } );
await git.clone( githubRepoUrl, folderPath );
await git.raw( 'sparse-checkout', 'init', { '--cone': null } );
await git.raw( 'sparse-checkout', 'set', directories.join( ' ' ) );
return folderPath;
};
/**
* checkoutRef - checkout a ref in a git repo.
*
* @param {string} pathToRepo - the path to the repo to checkout a ref from.
* @param {string} ref - the ref to checkout.
* @return {Response<string>} - the simple-git response.
*/
export const checkoutRef = ( pathToRepo: string, ref: string ) => {
const git = simpleGit( { baseDir: pathToRepo } );
return git.checkout( ref );
};
/**
* Do a git diff of 2 commit hashes (or branches)
*
* @param {string} baseDir - baseDir that the repo is in
* @param {string} hashA - either a git commit hash or a git branch
* @param {string} hashB - either a git commit hash or a git branch
* @return {Promise<string>} - diff of the changes between the 2 hashes
*/
export const diffHashes = ( baseDir: string, hashA: string, hashB: string ) => {
const git = simpleGit( { baseDir } );
return git.diff( [ `${ hashA }..${ hashB }` ] );
};
/**
* Determines if a string is a commit hash or not.
*
* @param {string} ref - the ref to check
* @return {boolean} whether the ref is a commit hash or not.
*/
const refIsHash = ( ref: string ) => {
return /^[0-9a-f]{7,40}$/i.test( ref );
};
/**
* Get the commit hash for a ref (either branch or commit hash). If a validly
* formed hash is provided it is returned unmodified.
*
* @param {string} baseDir - the dir of the git repo to get the hash from.
* @param {string} ref - Either a commit hash or a branch name.
* @return {string} - the commit hash of the ref.
*/
export const getCommitHash = async ( baseDir: string, ref: string ) => {
const isHash = refIsHash( ref );
// check if its in history, if its not an error will be thrown
try {
await simpleGit( { baseDir } ).show( ref );
} catch ( e ) {
throw new Error(
`${ ref } is not a valid commit hash or branch name that exists in git history`
);
}
// If its not a hash we assume its a branch
if ( ! isHash ) {
return simpleGit( { baseDir } ).revparse( [ ref ] );
}
// Its a hash already
return ref;
};
/**
* generateDiff generates a diff for a given repo and 2 hashes or branch names.
*
* @param {string} tmpRepoPath - filepath to the repo to generate a diff from.
* @param {string} hashA - commit hash or branch name.
* @param {string} hashB - commit hash or branch name.
* @param {Function} onError - the handler to call when an error occurs.
*/
export const generateDiff = async (
tmpRepoPath: string,
hashA: string,
hashB: string,
onError: ( error: string ) => void
) => {
try {
const git = simpleGit( { baseDir: tmpRepoPath } );
const validBranches = [ hashA, hashB ].filter(
( hash ) => ! refIsHash( hash )
);
// checking out any branches will automatically track remote branches.
for ( const validBranch of validBranches ) {
// Note you can't do checkouts in parallel otherwise the git binary will crash
await git.checkout( [ validBranch ] );
}
// turn both hashes into commit hashes if they are not already.
const commitHashA = await getCommitHash( tmpRepoPath, hashA );
const commitHashB = await getCommitHash( tmpRepoPath, hashB );
const isRepo = await simpleGit( {
baseDir: tmpRepoPath,
} ).checkIsRepo();
if ( ! isRepo ) {
throw new Error( 'Not a git repository' );
}
const diff = await diffHashes( tmpRepoPath, commitHashA, commitHashB );
return diff;
} catch ( e ) {
if ( e instanceof Error ) {
onError(
`Unable to create diff. Check that git repo, base hash, and compare hash all exist.\n Error: ${ e.message }`
);
} else {
onError(
'Unable to create diff. Check that git repo, base hash, and compare hash all exist.'
);
}
return '';
}
};

View File

@ -0,0 +1,65 @@
/**
* External dependencies
*/
import ora, { Ora } from 'ora';
import chalk from 'chalk';
/**
* Internal dependencies
*/
import { getEnvVar } from './environment';
const LOGGING_LEVELS: Record< string, number > = {
verbose: 3,
warn: 2,
error: 1,
silent: 0,
};
const { log, error, warn } = console;
export class Logger {
private static lastSpinner: Ora | null;
private static get loggingLevel() {
return LOGGING_LEVELS[
getEnvVar( 'LOGGER_LEVEL' ) || 'warn'
] as number;
}
static error( message: string ) {
if ( Logger.loggingLevel >= LOGGING_LEVELS.error ) {
error( chalk.red( message ) );
process.exit( 1 );
}
}
static warn( message: string ) {
if ( Logger.loggingLevel >= LOGGING_LEVELS.warn ) {
warn( chalk.yellow( message ) );
}
}
static notice( message: string ) {
if ( Logger.loggingLevel > LOGGING_LEVELS.silent ) {
log( chalk.green( message ) );
}
}
static startTask( message: string ) {
if ( Logger.loggingLevel > LOGGING_LEVELS.silent ) {
const spinner = ora( chalk.green( `${ message }...` ) ).start();
Logger.lastSpinner = spinner;
}
}
static endTask() {
if (
Logger.loggingLevel > LOGGING_LEVELS.silent &&
Logger.lastSpinner
) {
Logger.lastSpinner.succeed(
`${ Logger.lastSpinner.text } complete.`
);
Logger.lastSpinner = null;
}
}
}

172
tools/cli-core/src/util.ts Normal file
View File

@ -0,0 +1,172 @@
/**
* External dependencies
*/
import { createServer, Server } from 'net';
import { join } from 'path';
import { writeFile } from 'fs/promises';
import { exec } from 'child_process';
import { promisify } from 'util';
export const execAsync = promisify( exec );
/**
* Format version string for regex.
*
* @param {string} rawVersion Raw version number.
* @return {string} version regex.
*/
export const getVersionRegex = ( rawVersion: string ): string => {
const version = rawVersion.replace( /\./g, '\\.' );
if ( rawVersion.endsWith( '.0' ) ) {
return version + '|' + version.slice( 0, -3 ) + '\\n';
}
return version;
};
/**
* Get filename from patch
*
* @param {string} str String to extract filename from.
* @return {string} formatted filename.
*/
export const getFilename = ( str: string ): string => {
return str.replace( /^a(.*)\s.*/, '$1' );
};
/**
* Get patches
*
* @param {string} content Patch content.
* @param {RegExp} regex Regex to find specific patches.
* @return {string[]} Array of patches.
*/
export const getPatches = ( content: string, regex: RegExp ): string[] => {
const patches = content.split( 'diff --git ' );
const changes: string[] = [];
for ( const p in patches ) {
const patch = patches[ p ];
const id = patch.match( regex );
if ( id ) {
changes.push( patch );
}
}
return changes;
};
/**
* Determine if the default port for wp-env is already taken. If so, see
* https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#2-check-the-port-number
* for alternatives.
*
* @return {Promise<boolean>} if the port is being currently used.
*/
export const isWPEnvPortTaken = () => {
return new Promise< boolean >( ( resolve, reject ) => {
const test: Server = createServer()
.once( 'error', ( err: { code: string } ) => {
return err.code === 'EADDRINUSE'
? resolve( true )
: reject( err );
} )
.once( 'listening', () => {
return test.once( 'close', () => resolve( false ) ).close();
} )
.listen( '8888' );
} );
};
/**
* Start wp-env.
*
* @param {string} tmpRepoPath - path to the temporary repo to start wp-env from.
* @param {Function} error - error print method.
* @return {boolean} if starting the container succeeded.
*/
export const startWPEnv = async (
tmpRepoPath: string,
error: ( s: string ) => void
) => {
try {
// Stop wp-env if its already running.
await execAsync( 'wp-env stop', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
} catch ( e ) {
// If an error is produced here, it means wp-env is not initialized and therefore not running already.
}
try {
if ( await isWPEnvPortTaken() ) {
throw new Error(
'Unable to start wp-env. Make sure port 8888 is available or specify port number WP_ENV_PORT in .wp-env.override.json'
);
}
await execAsync( 'wp-env start', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
return true;
} catch ( e ) {
let message = '';
if ( e instanceof Error ) {
message = e.message;
error( message );
}
return false;
}
};
/**
* Stop wp-env.
*
* @param {string} tmpRepoPath - path to the temporary repo to stop wp-env from.
* @param {Function} error - error print method.
* @return {boolean} if stopping the container succeeded.
*/
export const stopWPEnv = async (
tmpRepoPath: string,
error: ( s: string ) => void
): Promise< boolean > => {
try {
await execAsync( 'wp-env stop', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
return true;
} catch ( e ) {
let message = '';
if ( e instanceof Error ) {
message = e.message;
error( message );
}
return false;
}
};
/**
* Generate a JSON file with the data passed.
*
* @param filePath - path to the file to be created.
* @param data - data to be written to the file.
* @return {Promise<void>} - promise that resolves when the file is written.
*/
export const generateJSONFile = ( filePath: string, data: unknown ) => {
const json = JSON.stringify(
data,
function replacer( _, value ) {
if ( value instanceof Map ) {
return Array.from( value.entries() );
}
return value;
},
2
);
return writeFile( filePath, json );
};

View File

@ -8,22 +8,21 @@
Currently there are just 2 commands: Currently there are just 2 commands:
1. `analyzer`. Analyzer serves 2 roles currently, as a linter for PRs to check if introduced hook/template/db changes have associated changelog entries and also to provide file output of changes between 1. `lint`. Analyzer is used as a linter for PRs to check if hook/template/db changes were introduced. It produces output either directly on CI or via GH actions `set-output`.
WooCommerce versions for the purpose of automating release processes (such as generating release posts.)
Here is an example `analyzer` command: Here is an example `analyzer` command:
`./bin/dev analyzer release/6.8 "6.8.0" -b=release/6.7` `pnpm run analyzer -- lint "release/6.8" "6.8.0" -b release/6.7`
In this command we compare the `release/6.7` and `release/6.8` branches to find differences, and we're looking for changes introduced since `6.8.0` (using the `@since` tag). In this command we compare the `release/6.7` and `release/6.8` branches to find differences, and we're looking for changes introduced since `6.8.0` (using the `@since` tag).
To find out more about the other arguments to the command you can run `./bin/dev analyzer --help` To find out more about the other arguments to the command you can run `pnpm run analyzer -- --help`
2. `major_minor`. This simple CLI tool gives you the latest `.0` major/minor released version of a plugin's mainfile based on Woo release conventions. 2. `major-minor`. This simple CLI tool gives you the latest `.0` major/minor released version of a plugin's mainfile based on Woo release conventions.
Here is an example `major_minor` command: Here is an example `major-minor` command:
`./bin/dev major_minor release/6.8 "plugins/woocommerce/woocommerce.php"` `pnpm run analyzer major-minor -- "release/6.8" "plugins/woocommerce/woocommerce.php"`
In this command we checkout the branch `release/6.8` and check the version of the woocommerce.php mainfile located at the path passed. Note that at the time of In this command we checkout the branch `release/6.8` and check the version of the woocommerce.php mainfile located at the path passed. Note that at the time of
writing the main file in this particular branch reports `6.8.1` so the output of this command is `6.8.0`. writing the main file in this particular branch reports `6.8.1` so the output of this command is `6.8.0`.

View File

@ -1,17 +0,0 @@
#!/usr/bin/env node
const oclif = require('@oclif/core')
const path = require('path')
const project = path.join(__dirname, '..', 'tsconfig.json')
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development'
require('ts-node').register({project})
// In dev mode, always show stack traces
oclif.settings.debug = true;
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle)

View File

@ -1,3 +0,0 @@
@echo off
node "%~dp0\dev" %*

View File

@ -1,5 +0,0 @@
#!/usr/bin/env node
const oclif = require('@oclif/core')
oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle'))

View File

@ -1,3 +0,0 @@
@echo off
node "%~dp0\run" %*

View File

@ -1,61 +1,32 @@
{ {
"name": "code-analyzer", "name": "code-analyzer",
"version": "0.0.0", "version": "0.0.1",
"description": "A tool to analyze code changes in WooCommerce Monorepo.", "description": "A tool to analyze code changes in WooCommerce Monorepo.",
"author": "Automattic", "author": "Automattic",
"bin": {
"code-analyzer": "./bin/run"
},
"homepage": "https://github.com/woocommerce/woocommerce", "homepage": "https://github.com/woocommerce/woocommerce",
"license": "GPLv2", "license": "GPLv2",
"main": "dist/index.js",
"repository": "woocommerce/woocommerce", "repository": "woocommerce/woocommerce",
"files": [
"/bin",
"/dist",
"/npm-shrinkwrap.json",
"/oclif.manifest.json"
],
"dependencies": { "dependencies": {
"@oclif/core": "^1", "@commander-js/extra-typings": "^0.1.0",
"@oclif/plugin-help": "^5", "@tsconfig/node16": "^1.0.3",
"@oclif/plugin-plugins": "^2.0.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"cli-core": "workspace:*",
"commander": "^9.4.0",
"dotenv": "^10.0.0",
"simple-git": "^3.10.0", "simple-git": "^3.10.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^16.9.4", "@types/node": "^16.9.4",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"globby": "^11",
"oclif": "^2",
"shx": "^0.3.3",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.4.3" "typescript": "^4.4.3"
}, },
"oclif": {
"bin": "code-analyzer",
"dirname": "code-analyzer",
"commands": "./dist/commands",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-plugins"
],
"topicSeparator": " ",
"topics": {
"analyzer": {
"description": "Analyzes code changes in the monorepo."
}
}
},
"scripts": { "scripts": {
"turbo:build": "shx rm -rf dist && tsc -b",
"build": "pnpm -w exec turbo run turbo:build --filter=$npm_package_name -- --",
"lint": "eslint . --ext .ts --config .eslintrc", "lint": "eslint . --ext .ts --config .eslintrc",
"postpack": "shx rm -f oclif.manifest.json",
"posttest": "pnpm lint", "posttest": "pnpm lint",
"prepack": "pnpm build && oclif manifest" "analyzer": "node -r ts-node/register ./src/commands/analyzer/index.ts"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"

View File

@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import { Logger } from 'cli-core/src/logger';
import { join } from 'path';
/**
* Internal dependencies
*/
import { scanForChanges } from '../../lib/scan-changes';
import {
printDatabaseUpdates,
printHookResults,
printSchemaChange,
printTemplateResults,
} from '../../print';
const program = new Command()
.command( 'lint' )
.argument(
'<compare>',
'GitHub branch/tag/commit hash to compare against the base branch/tag/commit hash.'
)
.argument(
'<sinceVersion>',
'Specify the version used to determine which changes are included (version listed in @since code doc).'
)
.option(
'-b, --base <base>',
'GitHub base branch/tag/commit hash.',
'trunk'
)
.option(
'-s, --source <source>',
'Git repo url or local path to a git repo.',
join( process.cwd(), '../../' )
)
.option(
'-o, --outputStyle <outputStyle>',
'Output style for the results. Options: github, cli. Github output will use ::set-output to set the results as an output variable.',
'cli'
)
.option(
'-ss, --skipSchemaCheck',
'Skip the schema check, enable this if you are not analyzing WooCommerce'
)
.action( async ( compare, sinceVersion, options ) => {
const { skipSchemaCheck = false, source, base, outputStyle } = options;
const changes = await scanForChanges(
compare,
sinceVersion,
skipSchemaCheck,
source,
base
);
if ( changes.templates.size ) {
printTemplateResults(
Array.from( changes.templates.values() ),
outputStyle,
'TEMPLATES',
Logger.notice
);
}
if ( changes.hooks.size ) {
printHookResults(
Array.from( changes.hooks.values() ),
outputStyle,
'HOOKS',
Logger.notice
);
}
if ( changes.schema.filter( ( s ) => ! s.areEqual ).length ) {
printSchemaChange(
changes.schema,
sinceVersion,
outputStyle,
Logger.notice
);
}
if ( changes.db ) {
printDatabaseUpdates( changes.db, outputStyle, Logger.notice );
}
} );
program.parse( process.argv );

View File

@ -0,0 +1,78 @@
/**
* External dependencies
*/
import { join } from 'path';
import { readFile } from 'fs/promises';
import simpleGit from 'simple-git';
import { cloneRepo } from 'cli-core/src/git';
import { Logger } from 'cli-core/src/logger';
import { Command } from '@commander-js/extra-typings';
/**
* Get plugin data
*
* @param {string} tmpRepoPath - Path to repo.
* @param {string} pathToMainFile - Path to plugin's main PHP file.
* @param {string} hashOrBranch - Hash or branch to checkout.
* @return {Promise<string>} - Promise containing version as string.
*/
const getPluginData = async (
tmpRepoPath: string,
pathToMainFile: string,
hashOrBranch: string
): Promise< string | void > => {
const git = simpleGit( { baseDir: tmpRepoPath } );
await git.checkout( [ hashOrBranch ] );
const mainFile = join( tmpRepoPath, pathToMainFile );
Logger.startTask( `Getting version from ${ pathToMainFile }` );
const content = await readFile( mainFile, 'utf-8' );
const rawVer = content.match( /^\s+\*\s+Version:\s+(.*)/m );
if ( rawVer && rawVer.length > 1 ) {
const version = rawVer[ 1 ].replace( /\-.*/, '' );
Logger.endTask();
const [ major, minor ] = version.split( '.' );
return `${ major }.${ minor }.0`;
}
Logger.error(
'Failed to find plugin version! Make sure the file contains a version in the format `Version: ...`'
);
};
const program = new Command()
.command( 'major-minor' )
.argument( '<branch>', 'GitHub branch to use to determine version.' )
.argument( '<pathToMainFile>', "Path to plugin's main PHP file." )
.option(
'-s, --source <source>',
'Git repo url or local path to a git repo.',
join( process.cwd(), '../../' )
)
.action( async ( branch, pathToMainFile, options ) => {
const { source } = options;
Logger.startTask( `Making a temporary clone of '${ branch }'` );
const tmpRepoPath = await cloneRepo( source );
Logger.endTask();
const version = await getPluginData(
tmpRepoPath,
pathToMainFile,
branch
);
if ( version ) {
Logger.notice( version );
} else {
Logger.error( 'Failed to get version' );
}
} );
program.parse( process.argv );

View File

@ -1,445 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { CliUx, Command, Flags } from '@oclif/core'; import { program } from '@commander-js/extra-typings';
import { join } from 'path'; import dotenv from 'dotenv';
import { rmSync } from 'fs';
/** dotenv.config();
* Internal dependencies
*/
import {
printTemplateResults,
printHookResults,
printSchemaChange,
printDatabaseUpdates,
} from '../../print';
import {
getVersionRegex,
getFilename,
getPatches,
getHookName,
areSchemasEqual,
getHookDescription,
getHookChangeType,
generateJSONFile,
} from '../../utils';
import { cloneRepo, generateDiff, generateSchemaDiff } from '../../git';
import { execSync } from 'child_process';
import { OutputFlags } from '@oclif/core/lib/interfaces';
/** program
* Analyzer class .name( 'analyzer' )
*/ .version( '0.0.1' )
export default class Analyzer extends Command { .command( 'lint', 'Lint changes', { isDefault: true } )
/** .command( 'major-minor', 'Determine major/minor version of a plugin' );
* CLI description
*/
static description = 'Analyze code changes in WooCommerce Monorepo.';
/** program.parse( process.argv );
* CLI arguments
*/
static args = [
{
name: 'compare',
description:
'GitHub branch or commit hash to compare against the base branch/commit.',
required: true,
},
{
name: 'sinceVersion',
description:
'Specify the version used to determine which changes are included (version listed in @since code doc).',
required: true,
},
];
/**
* CLI flags.
*/
static flags = {
base: Flags.string( {
char: 'b',
description: 'GitHub base branch or commit hash.',
default: 'trunk',
} ),
output: Flags.string( {
char: 'o',
description: 'Output styling.',
options: [ 'console', 'github' ],
default: 'console',
} ),
source: Flags.string( {
char: 's',
description: 'Git repo url or local path to a git repo.',
default: process.cwd(),
} ),
file: Flags.string( {
char: 'f',
description: 'Filename for change description JSON.',
default: 'changes.json',
} ),
plugin: Flags.string( {
char: 'p',
description: 'Plugin to check for',
options: [ 'core', 'admin', 'beta' ],
default: 'core',
} ),
'is-woocommerce': Flags.boolean( {
char: 'w',
description:
'Analyzing WooCommerce? (Will scan for DB schema changes).',
default: true,
} ),
};
/**
* This method is called to execute the command
*/
async run(): Promise< void > {
const { args, flags } = await this.parse( Analyzer );
const { compare, sinceVersion } = args;
const { base } = flags;
CliUx.ux.action.start(
`Making a temporary clone of '${ flags.source }'`
);
const tmpRepoPath = await cloneRepo( flags.source );
CliUx.ux.action.stop();
CliUx.ux.action.start(
`Comparing '${ flags.base }' with '${ args.compare }'`
);
const diff = await generateDiff(
tmpRepoPath,
flags.base,
compare,
this.error
);
CliUx.ux.action.stop();
// Run schema diffs only in the monorepo.
if ( flags[ 'is-woocommerce' ] ) {
const pluginPath = join( tmpRepoPath, 'plugins/woocommerce' );
const build = () => {
CliUx.ux.action.start( 'Building WooCommerce' );
// Note doing the minimal work to get a DB scan to work, avoiding full build for speed.
execSync( 'composer install', { cwd: pluginPath, stdio: [] } );
execSync(
'pnpm run build:feature-config --filter=woocommerce',
{
cwd: pluginPath,
}
);
CliUx.ux.action.stop();
};
CliUx.ux.action.start(
`Comparing WooCommerce DB schemas of '${ base }' and '${ compare }'`
);
const schemaDiff = await generateSchemaDiff(
tmpRepoPath,
compare,
base,
build,
( e: string ): void => this.error( e )
);
CliUx.ux.action.stop();
await this.scanChanges( diff, sinceVersion, flags, schemaDiff );
} else {
await this.scanChanges( diff, sinceVersion, flags );
}
// Clean up the temporary repo.
CliUx.ux.action.start( 'Cleaning up temporary files' );
rmSync( tmpRepoPath, { force: true, recursive: true } );
CliUx.ux.action.stop();
}
/**
* Scan patches for changes in templates, hooks and database schema
*
* @param {string} content Patch content.
* @param {string} version Current product version.
* @param {string} output Output style.
* @param {string} changesFileName Name of a file to output change information to.
* @param {boolean} schemaEquality if schemas are equal between branches.
*/
private async scanChanges(
content: string,
version: string,
flags: OutputFlags< typeof Analyzer[ 'flags' ] >,
schemaDiff: {
[ key: string ]: {
description: string;
base: string;
compare: string;
method: string;
areEqual: boolean;
};
} | void
) {
const { output, file } = flags;
const templates = this.scanTemplates( content, version );
const hooks = this.scanHooks( content, version, output );
const databaseUpdates = this.scanDatabases( content );
let schemaDiffResult = {};
CliUx.ux.action.start(
`Generating a list of changes since ${ version }.`
);
if ( templates.size ) {
printTemplateResults(
templates,
output,
'TEMPLATE CHANGES',
( s: string ): void => this.log( s )
);
} else {
this.log( 'No template changes found' );
}
if ( hooks.size ) {
printHookResults( hooks, output, 'HOOKS', ( s: string ): void =>
this.log( s )
);
} else {
this.log( 'No new hooks found' );
}
if ( ! areSchemasEqual( schemaDiff ) ) {
schemaDiffResult = printSchemaChange(
schemaDiff,
version,
output,
( s: string ): void => this.log( s )
);
} else {
this.log( 'No new schema changes found' );
}
if ( databaseUpdates ) {
printDatabaseUpdates(
databaseUpdates,
output,
( s: string ): void => this.log( s )
);
} else {
this.log( 'No database updates found' );
}
await generateJSONFile( join( process.cwd(), file ), {
templates: Object.fromEntries( templates.entries() ),
hooks: Object.fromEntries( hooks.entries() ),
db: databaseUpdates || {},
schema: schemaDiffResult || {},
} );
CliUx.ux.action.stop();
}
/**
* Scan patches for changes in the database
*
* @param {string} content Patch content.
* @param {string} version Current product version.
* @param {string} output Output style.
* @return {object|null}
*/
private scanDatabases(
content: string
): { updateFunctionName: string; updateFunctionVersion: string } | null {
CliUx.ux.action.start( 'Scanning database changes' );
const matchPatches = /^a\/(.+).php/g;
const patches = getPatches( content, matchPatches );
const databaseUpdatePatch = patches.find( ( patch ) => {
const lines = patch.split( '\n' );
const filepath = getFilename( lines[ 0 ] );
return filepath.includes( 'class-wc-install.php' );
} );
if ( ! databaseUpdatePatch ) {
return null;
}
const updateFunctionRegex =
/\+{1,2}\s*'(\d.\d.\d)' => array\(\n\+{1,2}\s*'(.*)',\n\+{1,2}\s*\),/m;
const match = databaseUpdatePatch.match( updateFunctionRegex );
if ( ! match ) {
return null;
}
const updateFunctionVersion = match[ 1 ];
const updateFunctionName = match[ 2 ];
CliUx.ux.action.stop();
return { updateFunctionName, updateFunctionVersion };
}
/**
* Scan patches for changes in templates
*
* @param {string} content Patch content.
* @param {string} version Current product version.
* @return {Promise<Map<string, string[]>>} Promise.
*/
private scanTemplates(
content: string,
version: string
): Map< string, string[] > {
CliUx.ux.action.start(
`Scanning for template changes since ${ version }.`
);
const report: Map< string, string[] > = new Map< string, string[] >();
if ( ! content.match( /diff --git a\/(.+)\/templates\/(.+)/g ) ) {
CliUx.ux.action.stop();
return report;
}
const matchPatches = /^a\/(.+)\/templates\/(.+)/g;
const title = 'Template change detected';
const patches = getPatches( content, matchPatches );
const matchVersion = `^(\\+.+\\*.+)(@version)\\s+(${ version.replace(
/\./g,
'\\.'
) }).*`;
const versionRegex = new RegExp( matchVersion, 'g' );
for ( const p in patches ) {
const patch = patches[ p ];
const lines = patch.split( '\n' );
const filepath = getFilename( lines[ 0 ] );
let code = 'warning';
let message = 'This template may require a version bump!';
for ( const l in lines ) {
const line = lines[ l ];
if ( line.match( versionRegex ) ) {
code = 'notice';
message = 'Version bump found';
}
}
if ( code === 'notice' && report.get( filepath ) ) {
report.set( filepath, [ code, title, message ] );
} else if ( ! report.get( filepath ) ) {
report.set( filepath, [ code, title, message ] );
}
}
CliUx.ux.action.stop();
return report;
}
/**
* Scan patches for hooks
*
* @param {string} content Patch content.
* @param {string} version Current product version.
* @param {string} output Output style.
* @return {Promise<Map<string, Map<string, string[]>>>} Promise.
*/
private scanHooks(
content: string,
version: string,
output: string
): Map< string, Map< string, string[] > > {
CliUx.ux.action.start( `Scanning for new hooks since ${ version }.` );
const report: Map< string, Map< string, string[] > > = new Map<
string,
Map< string, string[] >
>();
if ( ! content.match( /diff --git a\/(.+).php/g ) ) {
CliUx.ux.action.stop();
return report;
}
const matchPatches = /^a\/(.+).php/g;
const patches = getPatches( content, matchPatches );
const verRegEx = getVersionRegex( version );
const matchHooks = `\(.*?)@since\\s+(${ verRegEx })(.*?)(apply_filters|do_action)\\((\\s+)?(\\'|\\")(.*?)(\\'|\\")`;
const newRegEx = new RegExp( matchHooks, 'gs' );
for ( const p in patches ) {
const patch = patches[ p ];
// Separate patches into bits beginning with a comment. If a bit does not have an action, disregard.
const patchWithHook = patch.split( '/**' ).find( ( s ) => {
return (
s.includes( 'apply_filters' ) || s.includes( 'do_action' )
);
} );
if ( ! patchWithHook ) {
continue;
}
const results = patchWithHook.match( newRegEx );
const hooksList: Map< string, string[] > = new Map<
string,
string[]
>();
if ( ! results ) {
continue;
}
const lines = patch.split( '\n' );
const filepath = getFilename( lines[ 0 ] );
for ( const raw of results ) {
// Extract hook name and type.
const hookName = raw.match(
/(.*)(do_action|apply_filters)\(\s+'(.*)'/
);
if ( ! hookName ) {
continue;
}
const name = getHookName( hookName[ 3 ] );
const description = getHookDescription( raw, name ) || '';
if ( ! description ) {
this.error(
`Hook ${ name } has no description. Please add a description.`,
{ exit: false }
);
}
const kind =
hookName[ 2 ] === 'do_action' ? 'action' : 'filter';
const CLIMessage = `**${ name }** introduced in ${ version }`;
const GithubMessage = `\\'${ name }\\' introduced in ${ version }`;
const message =
output === 'github' ? GithubMessage : CLIMessage;
const hookChangeType = getHookChangeType( raw );
const title = `${ hookChangeType } ${ kind } found`;
if ( ! hookName[ 2 ].startsWith( '-' ) ) {
hooksList.set( name, [
'NOTICE',
title,
message,
description,
] );
}
}
report.set( filepath, hooksList );
}
CliUx.ux.action.stop();
return report;
}
}

View File

@ -1,110 +0,0 @@
/**
* External dependencies
*/
import { CliUx, Command, Flags } from '@oclif/core';
import { join } from 'path';
import { readFileSync, rmSync } from 'fs';
import simpleGit from 'simple-git';
/**
* Internal dependencies
*/
import { cloneRepo } from '../../git';
/**
* MajorMinor command class
*/
export default class MajorMinor extends Command {
/**
* CLI description
*/
static description = 'Determine major/minor version of a plugin';
/**
* CLI arguments
*/
static args = [
{
name: 'branch',
description: 'GitHub branch to use to determine version',
required: true,
},
{
name: 'pathToMainFile',
description: "Path to plugin's main PHP file",
required: true,
},
];
/**
* CLI flags.
*/
static flags = {
source: Flags.string( {
char: 's',
description: 'Git repo url or local path to a git repo.',
default: process.cwd(),
} ),
};
/**
* This method is called to execute the command
*/
async run(): Promise< void > {
const { args, flags } = await this.parse( MajorMinor );
const { source } = flags;
const { branch, pathToMainFile } = args;
CliUx.ux.action.start( `Making a temporary clone of '${ branch }'` );
const tmpRepoPath = await cloneRepo( source );
const version = await this.getPluginData(
tmpRepoPath,
pathToMainFile,
branch
);
// Clean up the temporary repo.
rmSync( tmpRepoPath, { force: true, recursive: true } );
this.log( version );
CliUx.ux.action.stop();
}
/**
* Get plugin data
*
* @param {string} tmpRepoPath - Path to repo.
* @param {string} pathToMainFile - Path to plugin's main PHP file.
* @param {string} hashOrBranch - Hash or branch to checkout.
* @return {Promise<string>} - Promise containing version as string.
*/
private async getPluginData(
tmpRepoPath: string,
pathToMainFile: string,
hashOrBranch: string
): Promise< string > {
const git = simpleGit( { baseDir: tmpRepoPath } );
await git.checkout( [ hashOrBranch ] );
const mainFile = join( tmpRepoPath, pathToMainFile );
CliUx.ux.action.start( `Getting version from ${ pathToMainFile }` );
const content = readFileSync( mainFile ).toString();
const rawVer = content.match( /^\s+\*\s+Version:\s+(.*)/m );
if ( ! rawVer ) {
this.error(
'Failed to find plugin version! Make sure the file contains a version in the format `Version: ...`'
);
}
const version = rawVer[ 1 ].replace( /\-.*/, '' );
CliUx.ux.action.stop();
const [ major, minor ] = version.split( '.' );
return `${ major }.${ minor }.0`;
}
}

View File

@ -1,207 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { CliUx } from '@oclif/core';
import { execSync } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os';
import { mkdirSync } from 'fs';
import { simpleGit } from 'simple-git'; import { simpleGit } from 'simple-git';
import { v4 } from 'uuid'; import { execAsync, startWPEnv, stopWPEnv } from 'cli-core/src/util';
import { mkdir, rm } from 'fs/promises';
/** export type SchemaDump = {
* Internal dependencies schema: string;
*/ OrdersTableDataStore: string;
import { startWPEnv, stopWPEnv } from './utils';
/**
* Check if a string is a valid url.
*
* @param {string} maybeURL - the URL string to check
* @return {boolean} whether the string is a valid URL or not.
*/
const isUrl = ( maybeURL: string ) => {
try {
new URL( maybeURL );
return true;
} catch ( e ) {
return false;
}
}; };
/**
* Clone a git repository.
*
* @param {string} repoPath - the path (either URL or file path) to the repo to clone.
* @return {Promise<string>} the path to the cloned repo.
*/
export const cloneRepo = async ( repoPath: string ) => {
const folderPath = join( tmpdir(), 'code-analyzer-tmp', v4() );
mkdirSync( folderPath, { recursive: true } );
const git = simpleGit( { baseDir: folderPath } );
await git.clone( repoPath, folderPath );
// If this is a local clone then the simplest way to maintain remote settings is to copy git config across
if ( ! isUrl( repoPath ) ) {
execSync( `cp ${ repoPath }/.git/config ${ folderPath }/.git/config` );
}
// Update the repo.
await git.fetch();
return folderPath;
};
/**
* Do a minimal sparse checkout of a github repo.
*
* @param {string} githubRepoUrl - the URL to the repo to checkout.
* @param {string} path - the path to checkout to.
* @param {Array<string>} directories - the files or directories to checkout.
* @return {Promise<string>} the path to the cloned repo.
*/
export const sparseCheckoutRepo = async (
githubRepoUrl: string,
path: string,
directories: string[]
) => {
const folderPath = join( tmpdir(), path );
// clean up if it already exists.
await rm( folderPath, { recursive: true, force: true } );
await mkdir( folderPath, { recursive: true } );
const git = simpleGit( { baseDir: folderPath } );
await git.clone( githubRepoUrl, folderPath );
await git.raw( 'sparse-checkout', 'init', { '--cone': null } );
await git.raw( 'sparse-checkout', 'set', directories.join( ' ' ) );
return folderPath;
};
/**
* checkoutRef - checkout a ref in a git repo.
*
* @param {string} pathToRepo - the path to the repo to checkout a ref from.
* @param {string} ref - the ref to checkout.
* @return {Response<string>} - the simple-git response.
*/
export const checkoutRef = ( pathToRepo: string, ref: string ) => {
const git = simpleGit( { baseDir: pathToRepo } );
return git.checkout( ref );
};
/**
* Do a git diff of 2 commit hashes (or branches)
*
* @param {string} baseDir - baseDir that the repo is in
* @param {string} hashA - either a git commit hash or a git branch
* @param {string} hashB - either a git commit hash or a git branch
* @return {Promise<string>} - diff of the changes between the 2 hashes
*/
export const diffHashes = ( baseDir: string, hashA: string, hashB: string ) => {
const git = simpleGit( { baseDir } );
return git.diff( [ `${ hashA }..${ hashB }` ] );
};
/**
* Determines if a string is a commit hash or not.
*
* @param {string} ref - the ref to check
* @return {boolean} whether the ref is a commit hash or not.
*/
const refIsHash = ( ref: string ) => {
return /^[0-9a-f]{7,40}$/i.test( ref );
};
/**
* Get the commit hash for a ref (either branch or commit hash). If a validly
* formed hash is provided it is returned unmodified.
*
* @param {string} baseDir - the dir of the git repo to get the hash from.
* @param {string} ref - Either a commit hash or a branch name.
* @return {string} - the commit hash of the ref.
*/
export const getCommitHash = async ( baseDir: string, ref: string ) => {
const isHash = refIsHash( ref );
// check if its in history, if its not an error will be thrown
try {
await simpleGit( { baseDir } ).show( ref );
} catch ( e ) {
throw new Error(
`${ ref } is not a valid commit hash or branch name that exists in git history`
);
}
// If its not a hash we assume its a branch
if ( ! isHash ) {
return simpleGit( { baseDir } ).revparse( [ ref ] );
}
// Its a hash already
return ref;
};
/**
* generateDiff generates a diff for a given repo and 2 hashes or branch names.
*
* @param {string} tmpRepoPath - filepath to the repo to generate a diff from.
* @param {string} hashA - commit hash or branch name.
* @param {string} hashB - commit hash or branch name.
* @param {Function} onError - the handler to call when an error occurs.
*/
export const generateDiff = async (
tmpRepoPath: string,
hashA: string,
hashB: string,
onError: ( error: string ) => void
) => {
try {
const git = simpleGit( { baseDir: tmpRepoPath } );
const validBranches = [ hashA, hashB ].filter(
( hash ) => ! refIsHash( hash )
);
// checking out any branches will automatically track remote branches.
for ( const validBranch of validBranches ) {
// Note you can't do checkouts in parallel otherwise the git binary will crash
await git.checkout( [ validBranch ] );
}
// turn both hashes into commit hashes if they are not already.
const commitHashA = await getCommitHash( tmpRepoPath, hashA );
const commitHashB = await getCommitHash( tmpRepoPath, hashB );
const isRepo = await simpleGit( {
baseDir: tmpRepoPath,
} ).checkIsRepo();
if ( ! isRepo ) {
throw new Error( 'Not a git repository' );
}
const diff = await diffHashes( tmpRepoPath, commitHashA, commitHashB );
return diff;
} catch ( e ) {
if ( e instanceof Error ) {
onError(
`Unable to create diff. Check that git repo, base hash, and compare hash all exist.\n Error: ${ e.message }`
);
} else {
onError(
'Unable to create diff. Check that git repo, base hash, and compare hash all exist.'
);
}
return '';
}
};
/** /**
* Get all schema strings found in WooCommerce. * Get all schema strings found in WooCommerce.
* *
@ -212,17 +20,14 @@ export const generateDiff = async (
export const getSchema = async ( export const getSchema = async (
tmpRepoPath: string, tmpRepoPath: string,
error: ( s: string ) => void error: ( s: string ) => void
): Promise< { ): Promise< SchemaDump | void > => {
schema: string;
OrdersTableDataStore: string;
} | void > => {
try { try {
const pluginPath = join( tmpRepoPath, 'plugins/woocommerce' ); const pluginPath = join( tmpRepoPath, 'plugins/woocommerce' );
const getSchemaPath = const getSchemaPath =
'wp-content/plugins/woocommerce/bin/wc-get-schema.php'; 'wp-content/plugins/woocommerce/bin/wc-get-schema.php';
// Get the WooCommerce schema from wp cli // Get the WooCommerce schema from wp cli
const schema = execSync( const schemaOutput = await execAsync(
`wp-env run cli "wp eval-file '${ getSchemaPath }'"`, `wp-env run cli "wp eval-file '${ getSchemaPath }'"`,
{ {
cwd: pluginPath, cwd: pluginPath,
@ -231,7 +36,7 @@ export const getSchema = async (
); );
// Get the OrdersTableDataStore schema. // Get the OrdersTableDataStore schema.
const OrdersTableDataStore = execSync( const ordersTableOutput = await execAsync(
'wp-env run cli "wp eval \'echo (new Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore)->get_database_schema();\'"', 'wp-env run cli "wp eval \'echo (new Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore)->get_database_schema();\'"',
{ {
cwd: pluginPath, cwd: pluginPath,
@ -240,8 +45,8 @@ export const getSchema = async (
); );
return { return {
schema, schema: schemaOutput.stdout,
OrdersTableDataStore, OrdersTableDataStore: ordersTableOutput.stdout,
}; };
} catch ( e ) { } catch ( e ) {
if ( e instanceof Error ) { if ( e instanceof Error ) {
@ -250,41 +55,40 @@ export const getSchema = async (
} }
}; };
export type SchemaDiff = {
name: string;
description: string;
base: string;
compare: string;
method: string;
areEqual: boolean;
};
/** /**
* Generate a schema for each branch being compared. * Generate a schema for each branch being compared.
* *
* @param {string} tmpRepoPath Path to repository used to generate schema diff. * @param {string} tmpRepoPath Path to repository used to generate schema diff.
* @param {string} compare Branch/commit hash to compare against the base. * @param {string} compare Branch/commit hash to compare against the base.
* @param {string} base Base branch/commit hash. * @param {string} base Base branch/commit hash.
* @param build Build to perform between checkouts. * @param {Function} build Build to perform between checkouts.
* @param {Function} error error print method. * @param {Function} error error print method.
* @return {Object|void} diff object. * @return {Promise<SchemaDiff[]|null>} diff object.
*/ */
export const generateSchemaDiff = async ( export const generateSchemaDiff = async (
tmpRepoPath: string, tmpRepoPath: string,
compare: string, compare: string,
base: string, base: string,
build: () => void, build: () => Promise< void > | void,
error: ( s: string ) => void error: ( s: string ) => void
): Promise< { ): Promise< SchemaDiff[] | null > => {
[ key: string ]: {
description: string;
base: string;
compare: string;
method: string;
areEqual: boolean;
};
} | void > => {
const git = simpleGit( { baseDir: tmpRepoPath } ); const git = simpleGit( { baseDir: tmpRepoPath } );
// Be sure the wp-env engine is started. // Be sure the wp-env engine is started.
await startWPEnv( tmpRepoPath, error ); await startWPEnv( tmpRepoPath, error );
CliUx.ux.action.start( `Gathering schema from ${ base }` );
// Force checkout because sometimes a build will generate a lockfile change. // Force checkout because sometimes a build will generate a lockfile change.
await git.checkout( base, [ '--force' ] ); await git.checkout( base, [ '--force' ] );
build(); await build();
const baseSchema = await getSchema( const baseSchema = await getSchema(
tmpRepoPath, tmpRepoPath,
( errorMessage: string ) => { ( errorMessage: string ) => {
@ -293,13 +97,10 @@ export const generateSchemaDiff = async (
); );
} }
); );
CliUx.ux.action.stop();
CliUx.ux.action.start( `Gathering schema from ${ compare }` );
// Force checkout because sometimes a build will generate a lockfile change. // Force checkout because sometimes a build will generate a lockfile change.
await git.checkout( compare, [ '--force' ] ); await git.checkout( compare, [ '--force' ] );
build(); await build();
const compareSchema = await getSchema( const compareSchema = await getSchema(
tmpRepoPath, tmpRepoPath,
( errorMessage: string ) => { ( errorMessage: string ) => {
@ -308,22 +109,24 @@ export const generateSchemaDiff = async (
); );
} }
); );
CliUx.ux.action.stop();
stopWPEnv( tmpRepoPath, error ); stopWPEnv( tmpRepoPath, error );
if ( ! baseSchema || ! compareSchema ) { if ( ! baseSchema || ! compareSchema ) {
return; return null;
} }
return {
schema: { return [
{
name: 'schema',
description: 'WooCommerce Base Schema', description: 'WooCommerce Base Schema',
base: baseSchema.schema, base: baseSchema.schema,
compare: compareSchema.schema, compare: compareSchema.schema,
method: 'WC_Install->get_schema', method: 'WC_Install->get_schema',
areEqual: baseSchema.schema === compareSchema.schema, areEqual: baseSchema.schema === compareSchema.schema,
}, },
OrdersTableDataStore: { {
name: 'OrdersTableDataStore',
description: 'OrdersTableDataStore Schema', description: 'OrdersTableDataStore Schema',
base: baseSchema.OrdersTableDataStore, base: baseSchema.OrdersTableDataStore,
compare: compareSchema.OrdersTableDataStore, compare: compareSchema.OrdersTableDataStore,
@ -332,5 +135,5 @@ export const generateSchemaDiff = async (
baseSchema.OrdersTableDataStore === baseSchema.OrdersTableDataStore ===
compareSchema.OrdersTableDataStore, compareSchema.OrdersTableDataStore,
}, },
}; ];
}; };

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { getFilename, getPatches } from 'cli-core/src/git';
export const scanForDBChanges = ( content: string ) => {
const matchPatches = /^a\/(.+).php/g;
const patches = getPatches( content, matchPatches );
const databaseUpdatePatch = patches.find( ( patch ) => {
const lines = patch.split( '\n' );
const filepath = getFilename( lines[ 0 ] );
return filepath.includes( 'class-wc-install.php' );
} );
if ( ! databaseUpdatePatch ) {
return null;
}
const updateFunctionRegex =
/\+{1,2}\s*'(\d.\d.\d)' => array\(\n\+{1,2}\s*'(.*)',\n\+{1,2}\s*\),/m;
const match = databaseUpdatePatch.match( updateFunctionRegex );
if ( ! match ) {
return null;
}
const updateFunctionVersion = match[ 1 ];
const updateFunctionName = match[ 2 ];
return { updateFunctionName, updateFunctionVersion };
};

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { getFilename, getPatches } from 'cli-core/src/util';
/**
* Internal dependencies
*/
import {
getHookChangeType,
getHookDescription,
getHookName,
getVersionRegex,
} from '../utils';
export type HookChangeDescription = {
filePath: string;
name: string;
description: string;
hookType: string;
changeType: 'new' | 'updated';
version: string;
};
export const scanForHookChanges = ( content: string, version: string ) => {
const changes: Map< string, HookChangeDescription > = new Map();
if ( ! content.match( /diff --git a\/(.+).php/g ) ) {
return changes;
}
const matchPatches = /^a\/(.+).php/g;
const patches = getPatches( content, matchPatches );
const verRegEx = getVersionRegex( version );
const matchHooks = `\(.*?)@since\\s+(${ verRegEx })(.*?)(apply_filters|do_action)\\((\\s+)?(\\'|\\")(.*?)(\\'|\\")`;
const newRegEx = new RegExp( matchHooks, 'gs' );
for ( const p in patches ) {
const patch = patches[ p ];
// Separate patches into bits beginning with a comment. If a bit does not have an action, disregard.
const patchWithHook = patch.split( '/**' ).find( ( s ) => {
return s.includes( 'apply_filters' ) || s.includes( 'do_action' );
} );
if ( ! patchWithHook ) {
continue;
}
const results = patchWithHook.match( newRegEx );
if ( ! results ) {
continue;
}
const lines = patch.split( '\n' );
const filePath = getFilename( lines[ 0 ] );
for ( const raw of results ) {
// Extract hook name and type.
const hookName = raw.match(
/(.*)(do_action|apply_filters)\(\s+'(.*)'/
);
if ( ! hookName ) {
continue;
}
const name = getHookName( hookName[ 3 ] );
const description = getHookDescription( raw, name ) || '';
const hookType =
hookName[ 2 ] === 'do_action' ? 'action' : 'filter';
const changeType = getHookChangeType( raw );
if ( ! hookName[ 2 ].startsWith( '-' ) ) {
changes.set( filePath, {
filePath,
name,
hookType,
description,
changeType,
version,
} );
}
}
}
return changes;
};

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { Logger } from 'cli-core/src/logger';
import { join } from 'path';
import { cloneRepo, generateDiff } from 'cli-core/src/git';
/**
* Internal dependencies
*/
import { execAsync } from '../utils';
import { scanForDBChanges } from './db-changes';
import { scanForHookChanges } from './hook-changes';
import { scanForTemplateChanges } from './template-changes';
import { SchemaDiff, generateSchemaDiff } from '../git';
export const scanForChanges = async (
compareVersion: string,
sinceVersion: string,
skipSchemaCheck: boolean,
source: string,
base: string
) => {
Logger.startTask( `Making temporary clone of ${ source }...` );
const tmpRepoPath = await cloneRepo( source );
Logger.endTask();
Logger.notice(
`Temporary clone of ${ source } created at ${ tmpRepoPath }`
);
const diff = await generateDiff(
tmpRepoPath,
base,
compareVersion,
Logger.error
);
const pluginPath = join( tmpRepoPath, 'plugins/woocommerce' );
Logger.startTask( 'Detecting hook changes...' );
const hookChanges = scanForHookChanges( diff, sinceVersion );
Logger.endTask();
Logger.startTask( 'Detecting template changes...' );
const templateChanges = scanForTemplateChanges( diff, sinceVersion );
Logger.endTask();
Logger.startTask( 'Detecting DB changes...' );
const dbChanges = scanForDBChanges( diff );
Logger.endTask();
let schemaChanges: SchemaDiff[] = [];
if ( ! skipSchemaCheck ) {
const build = async () => {
// Note doing the minimal work to get a DB scan to work, avoiding full build for speed.
await execAsync( 'composer install', { cwd: pluginPath } );
await execAsync(
'pnpm run build:feature-config --filter=woocommerce',
{
cwd: pluginPath,
}
);
};
Logger.startTask( 'Generating schema diff...' );
const schemaDiff = await generateSchemaDiff(
tmpRepoPath,
compareVersion,
base,
build,
Logger.error
);
schemaChanges = schemaDiff || [];
Logger.endTask();
}
return {
hooks: hookChanges,
templates: templateChanges,
schema: schemaChanges,
db: dbChanges,
};
};

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { getFilename, getPatches } from 'cli-core/src/git';
export type TemplateChangeDescription = {
filePath: string;
code: string;
// We could probably move message out into linter later
message: string;
};
export const scanForTemplateChanges = ( content: string, version: string ) => {
const changes: Map< string, TemplateChangeDescription > = new Map();
if ( ! content.match( /diff --git a\/(.+)\/templates\/(.+)\.php/g ) ) {
return changes;
}
const matchPatches = /^a\/(.+)\/templates\/(.+)/g;
const patches = getPatches( content, matchPatches );
const matchVersion = `^(\\+.+\\*.+)(@version)\\s+(${ version.replace(
/\./g,
'\\.'
) }).*`;
const versionRegex = new RegExp( matchVersion, 'g' );
for ( const p in patches ) {
const patch = patches[ p ];
const lines = patch.split( '\n' );
const filePath = getFilename( lines[ 0 ] );
let code = 'warning';
let message = 'This template may require a version bump!';
for ( const l in lines ) {
const line = lines[ l ];
if ( line.match( versionRegex ) ) {
code = 'notice';
message = 'Version bump found';
}
}
changes.set( filePath, { code, message, filePath } );
}
return changes;
};

View File

@ -1,3 +1,10 @@
/**
* Internal dependencies
*/
import { SchemaDiff } from './git';
import { HookChangeDescription } from './lib/hook-changes';
import { TemplateChangeDescription } from './lib/template-changes';
/** /**
* Print template results * Print template results
* *
@ -7,32 +14,29 @@
* @param {Function} log print method. * @param {Function} log print method.
*/ */
export const printTemplateResults = ( export const printTemplateResults = (
data: Map< string, string[] >, data: TemplateChangeDescription[],
output: string, output: string,
title: string, title: string,
log: ( s: string ) => void log: ( s: string ) => void
): void => { ): void => {
//[code,title,message]
if ( output === 'github' ) { if ( output === 'github' ) {
let opt = '\\n\\n### Template changes:'; let opt = '\\n\\n### Template changes:';
for ( const [ key, value ] of data ) { for ( const { filePath, code, message } of data ) {
opt += `\\n* **file:** ${ key }`; opt += `\\n* **file:** ${ filePath }`;
opt += `\\n * ${ value[ 0 ].toUpperCase() }: ${ value[ 2 ] }`; opt += `\\n * ${ code.toUpperCase() }: ${ message }`;
log( log(
`::${ value[ 0 ] } file=${ key },line=1,title=${ value[ 1 ] }::${ value[ 2 ] }` `::${ code } file=${ filePath },line=1,title=${ title }::${ message }`
); );
} }
log( `::set-output name=templates::${ opt }` ); log( `::set-output name=templates::${ opt }` );
} else { } else {
log( `\n## ${ title }:` ); log( `\n## ${ title }:` );
for ( const [ key, value ] of data ) { for ( const { filePath, code, message } of data ) {
log( 'FILE: ' + key ); log( 'FILE: ' + filePath );
log( '---------------------------------------------------' ); log( '---------------------------------------------------' );
log( log( ` ${ code.toUpperCase() } | ${ title } | ${ message }` );
` ${ value[ 0 ].toUpperCase() } | ${ value[ 1 ] } | ${
value[ 2 ]
}`
);
log( '---------------------------------------------------' ); log( '---------------------------------------------------' );
} }
} }
@ -41,54 +45,69 @@ export const printTemplateResults = (
/** /**
* Print hook results * Print hook results
* *
* @param {Map} data Raw data. * @param {Map} data Raw data.
* @param {string} output Output style. * @param {string} output Output style.
* @param {string} title Section title. * @param {string} sectionTitle Section title.
* @param {Function} log print method. * @param {Function} log print method.
*/ */
export const printHookResults = ( export const printHookResults = (
data: Map< string, Map< string, string[] > >, data: HookChangeDescription[],
output: string, output: string,
title: string, sectionTitle: string,
log: ( s: string ) => void log: ( s: string ) => void
): void => { ) => {
// [
// 'NOTICE',
// title,
// message,
// description,
// ]
if ( output === 'github' ) { if ( output === 'github' ) {
let opt = '\\n\\n### New hooks:'; let opt = '\\n\\n### New hooks:';
for ( const [ key, value ] of data ) { for ( const {
if ( value.size ) { filePath,
opt += `\\n* **file:** ${ key }`; name,
for ( const [ k, v ] of value ) { version,
opt += `\\n * ${ v[ 0 ].toUpperCase() } - ${ v[ 2 ] }: ${ description,
v[ 3 ] hookType,
}`; changeType,
log( } of data ) {
`::${ v[ 0 ] } file=${ key },line=1,title=${ v[ 1 ] } - ${ k }::${ v[ 2 ] }` opt += `\\n* **file:** ${ filePath }`;
);
} const cliMessage = `**${ name }** introduced in ${ version }`;
} const ghMessage = `\\'${ name }\\' introduced in ${ version }`;
const message = output === 'github' ? ghMessage : cliMessage;
const title = `${ changeType } ${ hookType } found`;
opt += `\\n * NOTICE - ${ message }: ${ description }`;
log(
`::NOTICE file=${ filePath },line=1,title=${ title } - ${ name }::${ message }`
);
} }
log( `::set-output name=wphooks::${ opt }` ); log( `::set-output name=wphooks::${ opt }` );
} else { } else {
log( `\n## ${ title }:` ); log( `\n## ${ sectionTitle }:` );
log( '---------------------------------------------------' ); log( '---------------------------------------------------' );
for ( const [ key, value ] of data ) { for ( const {
if ( value.size ) { filePath,
log( 'FILE: ' + key ); name,
log( '---------------------------------------------------' ); version,
for ( const [ k, v ] of value ) { description,
log( `HOOK: ${ k }: ${ v[ 3 ] }` ); hookType,
log( changeType,
'---------------------------------------------------' } of data ) {
); const cliMessage = `**${ name }** introduced in ${ version }`;
log( const ghMessage = `\\'${ name }\\' introduced in ${ version }`;
` ${ v[ 0 ].toUpperCase() } | ${ v[ 1 ] } | ${ v[ 2 ] }` const message = output === 'github' ? ghMessage : cliMessage;
); const title = `${ changeType } ${ hookType } found`;
log(
'---------------------------------------------------' log( 'FILE: ' + filePath );
); log( '---------------------------------------------------' );
} log( `HOOK: ${ name }: ${ description }` );
} log( '---------------------------------------------------' );
log( `NOTICE | ${ title } | ${ message }` );
log( '---------------------------------------------------' );
} }
} }
}; };
@ -96,34 +115,22 @@ export const printHookResults = (
/** /**
* Print Schema change results. * Print Schema change results.
* *
* @param {Object} schemaDiff Schema diff object * @param {Object} schemaDiffs Schema diff object
* @param {string} version Version change was introduced. * @param {string} version Version change was introduced.
* @param {string} output Output style. * @param {string} output Output style.
* @param {Function} log Print method. * @param {Function} log Print method.
*/ */
export const printSchemaChange = ( export const printSchemaChange = (
schemaDiff: { schemaDiffs: SchemaDiff[],
[ key: string ]: {
description: string;
base: string;
compare: string;
method: string;
areEqual: boolean;
};
} | void,
version: string, version: string,
output: string, output: string,
log: ( s: string ) => void log: ( s: string ) => void
): Record< string, string > => { ) => {
const diff: Record< string, string > = {};
if ( ! schemaDiff ) {
return diff;
}
if ( output === 'github' ) { if ( output === 'github' ) {
let githubCommentContent = '\\n\\n### New schema changes:'; let githubCommentContent = '\\n\\n### New schema changes:';
Object.keys( schemaDiff ).forEach( ( key ) => { schemaDiffs.forEach( ( schemaDiff ) => {
if ( ! schemaDiff[ key ].areEqual ) { if ( ! schemaDiff.areEqual ) {
githubCommentContent += `\\n* **Schema:** ${ schemaDiff[ key ].method } introduced in ${ version }`; githubCommentContent += `\\n* **Schema:** ${ schemaDiff.method } introduced in ${ version }`;
} }
} ); } );
@ -131,18 +138,15 @@ export const printSchemaChange = (
} else { } else {
log( '\n## SCHEMA CHANGES' ); log( '\n## SCHEMA CHANGES' );
log( '---------------------------------------------------' ); log( '---------------------------------------------------' );
schemaDiffs.forEach( ( schemaDiff ) => {
Object.keys( schemaDiff ).forEach( ( key ) => { if ( ! schemaDiff.areEqual ) {
if ( ! schemaDiff[ key ].areEqual ) {
log( log(
` NOTICE | Schema changes detected in ${ schemaDiff[ key ].method } as of ${ version }` ` NOTICE | Schema changes detected in ${ schemaDiff.method } as of ${ version }`
); );
log( '---------------------------------------------------' ); log( '---------------------------------------------------' );
diff[ key ] = schemaDiff[ key ].method;
} }
} ); } );
} }
return diff;
}; };
/** /**

View File

@ -1,10 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { createServer, Server } from 'net'; import { exec } from 'child_process';
import { execSync } from 'child_process'; import { promisify } from 'util';
import { join } from 'path';
import { writeFile } from 'fs/promises'; /**
* Internal dependencies
*/
import { SchemaDiff } from './git';
export const execAsync = promisify( exec );
/** /**
* Format version string for regex. * Format version string for regex.
@ -22,39 +27,6 @@ export const getVersionRegex = ( rawVersion: string ): string => {
return version; return version;
}; };
/**
* Get filename from patch
*
* @param {string} str String to extract filename from.
* @return {string} formatted filename.
*/
export const getFilename = ( str: string ): string => {
return str.replace( /^a(.*)\s.*/, '$1' );
};
/**
* Get patches
*
* @param {string} content Patch content.
* @param {RegExp} regex Regex to find specific patches.
* @return {string[]} Array of patches.
*/
export const getPatches = ( content: string, regex: RegExp ): string[] => {
const patches = content.split( 'diff --git ' );
const changes: string[] = [];
for ( const p in patches ) {
const patch = patches[ p ];
const id = patch.match( regex );
if ( id ) {
changes.push( patch );
}
}
return changes;
};
/** /**
* Get hook name. * Get hook name.
* *
@ -72,121 +44,15 @@ export const getHookName = ( name: string ): string => {
/** /**
* Determine if schema diff object contains schemas that are equal. * Determine if schema diff object contains schemas that are equal.
* *
* @param {Object} schemaDiff * @param {Array<SchemaDiff>} schemaDiffs
* @return {boolean|void} If the schema diff describes schemas that are equal. * @return {boolean|void} If the schema diff describes schemas that are equal.
*/ */
export const areSchemasEqual = ( export const areSchemasEqual = ( schemaDiffs: SchemaDiff[] ): boolean => {
schemaDiff: { return ! schemaDiffs.some( ( s ) => ! s.areEqual );
[ key: string ]: {
description: string;
base: string;
compare: string;
areEqual: boolean;
};
} | void
): boolean => {
if ( ! schemaDiff ) {
return false;
}
return ! Object.keys( schemaDiff ).some(
( d: string ) => schemaDiff[ d ].areEqual === false
);
}; };
/** /**
* Determine if the default port for wp-env is already taken. If so, see * Extract hook description from a raw diff.
* https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#2-check-the-port-number
* for alternatives.
*
* @return {Promise<boolean>} if the port is being currently used.
*/
export const isWPEnvPortTaken = () => {
return new Promise< boolean >( ( resolve, reject ) => {
const test: Server = createServer()
.once( 'error', ( err: { code: string } ) => {
return err.code === 'EADDRINUSE'
? resolve( true )
: reject( err );
} )
.once( 'listening', () => {
return test.once( 'close', () => resolve( false ) ).close();
} )
.listen( '8888' );
} );
};
/**
* Start wp-env.
*
* @param {string} tmpRepoPath - path to the temporary repo to start wp-env from.
* @param {Function} error - error print method.
* @return {boolean} if starting the container succeeded.
*/
export const startWPEnv = async (
tmpRepoPath: string,
error: ( s: string ) => void
) => {
try {
// Stop wp-env if its already running.
execSync( 'wp-env stop', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
} catch ( e ) {
// If an error is produced here, it means wp-env is not initialized and therefore not running already.
}
try {
if ( await isWPEnvPortTaken() ) {
throw new Error(
'Unable to start wp-env. Make sure port 8888 is available or specify port number WP_ENV_PORT in .wp-env.override.json'
);
}
execSync( 'wp-env start', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
return true;
} catch ( e ) {
let message = '';
if ( e instanceof Error ) {
message = e.message;
error( message );
}
return false;
}
};
/**
* Stop wp-env.
*
* @param {string} tmpRepoPath - path to the temporary repo to stop wp-env from.
* @param {Function} error - error print method.
* @return {boolean} if stopping the container succeeded.
*/
export const stopWPEnv = (
tmpRepoPath: string,
error: ( s: string ) => void
): boolean => {
try {
execSync( 'wp-env stop', {
cwd: join( tmpRepoPath, 'plugins/woocommerce' ),
encoding: 'utf-8',
} );
return true;
} catch ( e ) {
let message = '';
if ( e instanceof Error ) {
message = e.message;
error( message );
}
return false;
}
};
/**
* Extrace hook description from a raw diff.
* *
* @param {string} diff raw diff. * @param {string} diff raw diff.
* @param {string} name hook name. * @param {string} name hook name.
@ -229,23 +95,9 @@ export const getHookDescription = (
* @param {string} diff raw diff. * @param {string} diff raw diff.
* @return {'Updated' | 'New'} change type. * @return {'Updated' | 'New'} change type.
*/ */
export const getHookChangeType = ( diff: string ): 'Updated' | 'New' => { export const getHookChangeType = ( diff: string ) => {
const sincesRegex = /@since/g; const sincesRegex = /@since/g;
const sinces = diff.match( sincesRegex ) || []; const sinces = diff.match( sincesRegex ) || [];
// If there is more than one 'since' in the diff, it means that line was updated meaning the hook already exists. // If there is more than one 'since' in the diff, it means that line was updated meaning the hook already exists.
return sinces.length > 1 ? 'Updated' : 'New'; return sinces.length > 1 ? 'updated' : 'new';
};
export const generateJSONFile = ( filePath: string, data: unknown ) => {
const json = JSON.stringify(
data,
function replacer( key, value ) {
if ( value instanceof Map ) {
return Array.from( value.entries() );
}
return value;
},
2
);
return writeFile( filePath, json );
}; };

View File

@ -1,17 +1,10 @@
{ {
"compilerOptions": { "extends": "@tsconfig/node16/tsconfig.json",
"declaration": true, "ts-node": {
"importHelpers": true, "transpileOnly": true,
"module": "commonjs", "files": true,
"outDir": "dist", },
"rootDir": "src", "include": [
"strict": true,
"target": "es2019",
"typeRoots": [
"./node_modules/@types"
],
},
"include": [
"src/**/*" "src/**/*"
] ]
} }

View File

@ -11,8 +11,9 @@ don't have access to a wc.com auth token.
1. Make sure `pnpm i` has been run in the monorepo. 1. Make sure `pnpm i` has been run in the monorepo.
2. Make sure you have added a `.env` file with the env variables set. WCCOM_TOKEN is optional if you're using `--outputOnly`, but 2. Make sure you have added a `.env` file with the env variables set. WCCOM_TOKEN is optional if you're using `--outputOnly`, but
the `GITHUB_ACCESS_TOKEN` is required. If you need help generating a token see [the docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). To silence all CLI output, set `LOGGER_LEVEL` to `"silent"`. the `GITHUB_ACCESS_TOKEN` is required. If you need help generating a token see [the docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). To silence all CLI output, set `LOGGER_LEVEL` to `"silent"`.
3. Run the tool via the npm script, e.g. `pnpm run release "6.8.0" --outputOnly` 3. Note that the env file should live at the same path that you're running the command from.
4. For more help on individual options, run the help `pnpm run release --help` 4. Run the tool via the npm script, e.g. `pnpm run release-post -- "6.8.0" --outputOnly`
5. For more help on individual options, run the help `pnpm run release-post -- --help`
### Publishing Draft Posts ### Publishing Draft Posts
This tool will publish draft posts to `https://developer.woocommerce.com` for you if you omit the `--outputOnly` flag. There is some minimal first time setup for this though: This tool will publish draft posts to `https://developer.woocommerce.com` for you if you omit the `--outputOnly` flag. There is some minimal first time setup for this though:
@ -32,6 +33,14 @@ app list and click "manage app".
4. Take note of the `client secret` and the `client id`. 4. Take note of the `client secret` and the `client id`.
5. In your `.env` file add the client secret to the `WPCOM_OAUTH_CLIENT_SECRET` variable and the client id to the `WPCOM_OAUTH_CLIENT_ID` variable. 5. In your `.env` file add the client secret to the `WPCOM_OAUTH_CLIENT_SECRET` variable and the client id to the `WPCOM_OAUTH_CLIENT_ID` variable.
### Generating Just a Contributors List
If you don't have a final release yet you can generate an HTML contributors list that you can copy
paste into a blank post.
To do that simply run `pnpm run release-post contributors -- "<currentVersion>" "<previousVersion>"`
### Advanced ### Advanced
If you can't run anything on your localhost port 3000 you may want to override the redirect uri for oauth. If you can't run anything on your localhost port 3000 you may want to override the redirect uri for oauth.

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { program } from '@commander-js/extra-typings';
import dotenv from 'dotenv';
dotenv.config();
program
.name( 'release-post' )
.version( '0.0.1' )
.command( 'release', 'Generate release post', { isDefault: true } )
.command(
'contributors',
'Generate a list of contributors for a release post'
);
program.parse( process.argv );

View File

@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import { Logger } from 'cli-core/src/logger';
import { writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
/**
* Internal dependencies
*/
import { generateContributors } from '../../lib/contributors';
import { renderTemplate } from '../../lib/render-template';
// Define the contributors command
const program = new Command()
.command( 'contributors' )
.description( 'CLI to automate generation of a release post.' )
.argument(
'<currentVersion>',
'The version of the plugin to generate a post for, please use the tag version from Github.'
)
.argument(
'--previousVersion <previousVersion>',
'If you would like to compare against a version other than last minor you can provide a tag version from Github.'
)
.action( async ( currentVersion, previousVersion ) => {
Logger.startTask( 'Generating contributors list...' );
const contributors = await generateContributors(
currentVersion,
previousVersion.toString()
);
Logger.endTask();
const html = await renderTemplate( 'contributors.ejs', {
contributors,
} );
const tmpFile = join(
tmpdir(),
`contributors-${ currentVersion }.html`
);
await writeFile( tmpFile, html );
Logger.notice( `Contributors HTML generated at ${ tmpFile }` );
} );
program.parse( process.argv );

View File

@ -1,30 +1,31 @@
/** /**
* External dependencies * External dependencies
*/ */
import Analyzer from 'code-analyzer/src/commands/analyzer'; import { scanForChanges } from 'code-analyzer/src/lib/scan-changes';
import semver from 'semver'; import semver from 'semver';
import { promises } from 'fs';
import { writeFile } from 'fs/promises'; import { writeFile } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { Logger } from 'cli-core/src/logger';
import { Command } from '@commander-js/extra-typings';
import dotenv from 'dotenv';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { program } from '../program'; import { renderTemplate } from '../../lib/render-template';
import { renderTemplate } from '../lib/render-template'; import { createWpComDraftPost } from '../../lib/draft-post';
import { processChanges } from '../lib/process-changes'; import { generateContributors } from '../../lib/contributors';
import { createWpComDraftPost } from '../lib/draft-post';
import { generateContributors } from '../lib/contributors';
import { Logger } from '../lib/logger';
const DEVELOPER_WOOCOMMERCE_SITE_ID = '96396764'; const DEVELOPER_WOOCOMMERCE_SITE_ID = '96396764';
const VERSION_VALIDATION_REGEX = const VERSION_VALIDATION_REGEX =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/; /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/;
dotenv.config();
// Define the release post command // Define the release post command
program const program = new Command()
.command( 'release' ) .command( 'release' )
.description( 'CLI to automate generation of a release post.' ) .description( 'CLI to automate generation of a release post.' )
.argument( .argument(
@ -36,7 +37,14 @@ program
'--previousVersion <previousVersion>', '--previousVersion <previousVersion>',
'If you would like to compare against a version other than last minor you can provide a tag version from Github.' 'If you would like to compare against a version other than last minor you can provide a tag version from Github.'
) )
.option(
'--tags <tags>',
'Comma separated list of tags to add to the post.',
'Releases,WooCommerce Core'
)
.action( async ( currentVersion, options ) => { .action( async ( currentVersion, options ) => {
const tags = options.tags.split( ',' ).map( ( tag ) => tag.trim() );
const previousVersion = options.previousVersion const previousVersion = options.previousVersion
? semver.parse( options.previousVersion ) ? semver.parse( options.previousVersion )
: semver.parse( currentVersion ); : semver.parse( currentVersion );
@ -61,24 +69,17 @@ program
); );
} }
// generates a `changes.json` file in the current directory. const changes = await scanForChanges(
await Analyzer.run( [
currentVersion, currentVersion,
currentVersion, currentVersion,
'-s', false,
'https://github.com/woocommerce/woocommerce.git', 'https://github.com/woocommerce/woocommerce.git',
'-b', previousVersion.toString()
previousVersion.toString(),
] );
const changes = JSON.parse(
await promises.readFile(
process.cwd() + '/changes.json',
'utf8'
)
); );
const changeset = processChanges( changes ); const schemaChanges = changes.schema.filter(
( s ) => ! s.areEqual
);
Logger.startTask( 'Finding contributors' ); Logger.startTask( 'Finding contributors' );
const title = `WooCommerce ${ currentVersion } Released`; const title = `WooCommerce ${ currentVersion } Released`;
@ -91,7 +92,10 @@ program
const html = await renderTemplate( 'release.ejs', { const html = await renderTemplate( 'release.ejs', {
contributors, contributors,
title, title,
changes: changeset, changes: {
...changes,
schema: schemaChanges,
},
displayVersion: currentVersion, displayVersion: currentVersion,
} ); } );
@ -113,7 +117,8 @@ program
const { URL } = await createWpComDraftPost( const { URL } = await createWpComDraftPost(
DEVELOPER_WOOCOMMERCE_SITE_ID, DEVELOPER_WOOCOMMERCE_SITE_ID,
title, title,
html html,
tags
); );
Logger.notice( `Published draft release post at ${ URL }` ); Logger.notice( `Published draft release post at ${ URL }` );
@ -130,3 +135,5 @@ program
); );
} }
} ); } );
program.parse( process.argv );

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { checkoutRef, sparseCheckoutRepo } from 'code-analyzer/src/git'; import { checkoutRef, sparseCheckoutRepo } from 'cli-core/src/git';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import semver from 'semver'; import semver from 'semver';

View File

@ -2,13 +2,13 @@
* External dependencies * External dependencies
*/ */
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { Logger } from 'cli-core/src/logger';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { getWordpressComAuthToken } from './oauth-helper'; import { getWordpressComAuthToken } from './oauth-helper';
import { getEnvVar } from './environment'; import { getEnvVar } from './environment';
import { Logger } from './logger';
/** /**
* Create a draft of a post on wordpress.com * Create a draft of a post on wordpress.com
@ -21,7 +21,8 @@ import { Logger } from './logger';
export const createWpComDraftPost = async ( export const createWpComDraftPost = async (
siteId: string, siteId: string,
postTitle: string, postTitle: string,
postContent: string postContent: string,
tags: string[]
) => { ) => {
const clientId = getEnvVar( 'WPCOM_OAUTH_CLIENT_ID', true ); const clientId = getEnvVar( 'WPCOM_OAUTH_CLIENT_ID', true );
const clientSecret = getEnvVar( 'WPCOM_OAUTH_CLIENT_SECRET', true ); const clientSecret = getEnvVar( 'WPCOM_OAUTH_CLIENT_SECRET', true );
@ -56,6 +57,7 @@ export const createWpComDraftPost = async (
title: postTitle, title: postTitle,
content: postContent, content: postContent,
status: 'draft', status: 'draft',
tags,
} ), } ),
} }
); );

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { Logger } from 'cli-core/src/logger';
export const getEnvVar = ( varName: string, isRequired = false ) => {
const value = process.env[ varName ];
if ( value === undefined && isRequired ) {
Logger.error(
`You need to provide a value for ${ varName } in your environment either via an environment variable or the .env file.`
);
}
return value || '';
};

View File

@ -4,9 +4,9 @@
"description": "Automate release post generation for Wordpress plugins", "description": "Automate release post generation for Wordpress plugins",
"main": " ", "main": " ",
"scripts": { "scripts": {
"release": "ts-node ./index.ts release --" "release-post": "node -r ts-node/register ./commands/release-post/index.ts"
}, },
"author": "", "author": "Automattic",
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",
"devDependencies": { "devDependencies": {
"@tsconfig/node16": "^1.0.3", "@tsconfig/node16": "^1.0.3",
@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@commander-js/extra-typings": "^0.1.0", "@commander-js/extra-typings": "^0.1.0",
"@octokit/rest": "^19.0.4", "@octokit/rest": "^19.0.4",
"chalk": "^4.1.2", "cli-core": "workspace:*",
"code-analyzer": "workspace:*", "code-analyzer": "workspace:*",
"commander": "9.4.0", "commander": "9.4.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
@ -26,7 +26,6 @@
"lodash.shuffle": "^4.2.0", "lodash.shuffle": "^4.2.0",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"open": "^8.4.0", "open": "^8.4.0",
"ora": "^5.4.1",
"semver": "^7.3.2", "semver": "^7.3.2",
"ts-node": "^10.9.1" "ts-node": "^10.9.1"
} }

View File

@ -15,11 +15,13 @@
<th><strong>Class Name</strong></th> <th><strong>Class Name</strong></th>
<th><strong>Code Ref</strong></th> <th><strong>Code Ref</strong></th>
</tr> </tr>
<% changes.schema.forEach(({className, codeRef}) => { %> <% changes.schema.forEach(({areEqual, name, method}) => { %>
<tr> <% if (!areEqual) { %>
<td><%= className %></td> <tr>
<td><%= codeRef %></td> <td><%= name %></td>
</tr> <td><code><%= method %></code></td>
</tr>
<% } %>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
@ -27,7 +29,7 @@
<!-- /wp:table --> <!-- /wp:table -->
<% } %> <% } %>
<% if (changes.db.length) { %> <% if (changes.db) { %>
<!-- wp:heading --> <!-- wp:heading -->
<h3 id="db-updates">DB Updates</h3> <h3 id="db-updates">DB Updates</h3>
<!-- /wp:heading --> <!-- /wp:heading -->
@ -41,11 +43,9 @@
<strong>Function Name</strong> <strong>Function Name</strong>
</th> </th>
</tr> </tr>
<% changes.db.forEach(({functionName}) => { %> <tr>
<tr> <td><code><%= changes.db.functionName %></code></td>
<td><%= functionName %></td> </tr>
</tr>
<% }) %>
</tbody> </tbody>
</table> </table>
</figure> </figure>

View File

@ -2,7 +2,7 @@
<h2 id="actions-and-filters">Actions and Filters</h2> <h2 id="actions-and-filters">Actions and Filters</h2>
<!-- /wp:heading --> <!-- /wp:heading -->
<% if (changes.hooks.length) { %> <% if (changes.hooks.size) { %>
<!-- wp:table --> <!-- wp:table -->
<figure class="wp-block-table"> <figure class="wp-block-table">
<table> <table>
@ -11,7 +11,7 @@
<th><strong>Filter</strong></th> <th><strong>Filter</strong></th>
<th><strong>Description</strong></th> <th><strong>Description</strong></th>
</tr> </tr>
<% changes.hooks.forEach(({name, description}) => { %> <% changes.hooks.forEach(({ name, description }) => { %>
<tr> <tr>
<td><%= name %></td> <td><%= name %></td>
<td><%= description %></td> <td><%= description %></td>

View File

@ -2,7 +2,7 @@
<h2 id="template-changes">Template Changes</h2> <h2 id="template-changes">Template Changes</h2>
<!-- /wp:heading --> <!-- /wp:heading -->
<% if (changes.templates.length) { %> <% if (changes.templates.size) { %>
<!-- wp:table --> <!-- wp:table -->
<figure class="wp-block-table"> <figure class="wp-block-table">
<table> <table>

View File

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/node16/tsconfig.json",
"ts-node": {
"transpileOnly": true,
"files": true,
}
}