diff --git a/.github/workflows/pr-highlight-changes.yml b/.github/workflows/pr-highlight-changes.yml new file mode 100644 index 00000000000..66049b5b728 --- /dev/null +++ b/.github/workflows/pr-highlight-changes.yml @@ -0,0 +1,88 @@ +name: Highlight templates and hooks changes +on: pull_request +jobs: + analyze: + name: Check pull request changes to highlight + runs-on: ubuntu-20.04 + outputs: + results: ${{ steps.results.outputs.results }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Install prerequisites + run: | + npm install -g pnpm@^6.24.2 + pnpm install + - name: Run analyzer + id: run + run: ./tools/code-analyzer/bin/dev analyzer "$GITHUB_HEAD_REF" -o github + - name: Print results + id: results + run: echo "::set-output name=results::${{ steps.run.outputs.templates }}${{ steps.run.outputs.wphooks }}" + comment: + name: Add comment to hightlight changes + needs: analyze + runs-on: ubuntu-20.04 + steps: + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: woocommercebot + - name: Add comment + if: ${{ needs.analyze.outputs.results && (steps.find-comment.outputs.comment-id == '') }} + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '## New hook or template changes in this PR${{ needs.analyze.outputs.results }}' + }) + - name: Update comment + if: ${{ needs.analyze.outputs.results && steps.find-comment.outputs.comment-id }} + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + script: | + github.rest.issues.updateComment({ + comment_id: ${{ steps.find-comment.outputs.comment-id }}, + owner: context.repo.owner, + repo: context.repo.repo, + body: '## New hook or template changes in this PR${{ needs.analyze.outputs.results }}' + }) + - name: Delete comment + if: ${{ !needs.analyze.outputs.results && steps.find-comment.outputs.comment-id }} + uses: izhangzhihao/delete-comment@master + with: + github_token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + delete_user_name: woocommercebot + issue_number: ${{ github.event.number }} + - name: Add label + if: ${{ needs.analyze.outputs.results }} + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['release: highlight'] + }) + - name: Remove label + if: ${{ !needs.analyze.outputs.results }} + continue-on-error: true + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + script: | + github.rest.issues.removeLabel({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + name: ['release: highlight'] + }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053d3888eef..56dcab3b1cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1643,6 +1643,33 @@ importers: sass: 1.45.0 stylelint: 13.8.0 + tools/code-analyzer: + specifiers: + '@oclif/core': ^1 + '@oclif/plugin-help': ^5 + '@oclif/plugin-plugins': ^2.0.1 + '@types/node': ^16.9.4 + eslint: ^7.32.0 + globby: ^11 + oclif: ^2 + shx: ^0.3.3 + ts-node: ^10.2.1 + tslib: ^2.3.1 + typescript: ^4.4.3 + dependencies: + '@oclif/core': 1.3.4 + '@oclif/plugin-help': 5.1.11 + '@oclif/plugin-plugins': 2.1.0 + devDependencies: + '@types/node': 16.10.3 + eslint: 7.32.0 + globby: 11.1.0 + oclif: 2.4.5 + shx: 0.3.4 + ts-node: 10.5.0_506ca6ef959d35afcce359030b1bc9ff + tslib: 2.3.1 + typescript: 4.6.2 + tools/monorepo-merge: specifiers: '@oclif/core': ^1 @@ -1802,7 +1829,7 @@ packages: /@babel/code-frame/7.12.11: resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} dependencies: - '@babel/highlight': 7.16.0 + '@babel/highlight': 7.16.10 dev: true /@babel/code-frame/7.16.0: @@ -8234,7 +8261,7 @@ packages: ignore: 4.0.6 import-fresh: 3.3.0 js-yaml: 3.14.1 - minimatch: 3.0.4 + minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -8332,7 +8359,7 @@ packages: dependencies: '@humanwhocodes/object-schema': 1.2.1 debug: 4.3.4 - minimatch: 3.0.4 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color dev: true @@ -9931,11 +9958,11 @@ packages: chalk: 4.1.2 clean-stack: 3.0.1 cli-progress: 3.10.0 - debug: 4.3.3_supports-color@8.1.1 + debug: 4.3.4_supports-color@8.1.1 ejs: 3.1.6 fs-extra: 9.1.0 get-package-type: 0.1.0 - globby: 11.0.4 + globby: 11.1.0 hyperlinker: 1.0.0 indent-string: 4.0.0 is-wsl: 2.2.0 @@ -9979,7 +10006,7 @@ packages: '@oclif/color': 1.0.1 '@oclif/core': 1.3.4 chalk: 4.1.2 - debug: 4.3.3 + debug: 4.3.4 fs-extra: 9.1.0 http-call: 5.3.0 load-json-file: 5.3.0 @@ -9997,7 +10024,7 @@ packages: dependencies: '@oclif/core': 1.3.4 chalk: 4.1.2 - debug: 4.3.3 + debug: 4.3.4 fs-extra: 9.1.0 http-call: 5.3.0 lodash: 4.17.21 @@ -13617,7 +13644,7 @@ packages: resolution: {integrity: sha512-ayJ0iOCDNHnKpKTgBG6Q6JOnHTj9zFta+3j2b8Ejza0e4cvRyMn0ZoLEmbPrTHe5YYRlDYPvPWVdV4cTaRyH7g==} dependencies: '@types/expect': 1.20.4 - '@types/node': 16.10.3 + '@types/node': 17.0.21 dev: true /@types/webpack-env/1.16.3: @@ -13686,7 +13713,6 @@ packages: re-resizable: 4.11.0 transitivePeerDependencies: - react - - react-dom dev: true /@types/wordpress__compose/4.0.1: @@ -16678,7 +16704,7 @@ packages: resolution: {integrity: sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==} engines: {node: '>= 8.0.0'} dependencies: - debug: 4.3.3 + debug: 4.3.4 depd: 1.1.2 humanize-ms: 1.2.1 transitivePeerDependencies: @@ -20530,18 +20556,6 @@ packages: dependencies: ms: 2.1.2 - /debug/4.3.3_supports-color@8.1.1: - resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - supports-color: 8.1.1 - /debug/4.3.3_supports-color@9.2.2: resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} engines: {node: '>=6.0'} @@ -20566,6 +20580,18 @@ packages: dependencies: ms: 2.1.2 + /debug/4.3.4_supports-color@8.1.1: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + /debuglog/1.0.1: resolution: {integrity: sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=} dev: true @@ -22432,7 +22458,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.3 + debug: 4.3.4 doctrine: 3.0.0 enquirer: 2.3.6 escape-string-regexp: 4.0.0 @@ -22455,7 +22481,7 @@ packages: json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.0.4 + minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.1 progress: 2.0.3 @@ -22463,7 +22489,7 @@ packages: semver: 7.3.5 strip-ansi: 6.0.1 strip-json-comments: 3.1.1 - table: 6.7.3 + table: 6.8.0 text-table: 0.2.0 v8-compile-cache: 2.3.0 transitivePeerDependencies: @@ -23282,7 +23308,7 @@ packages: /filelist/1.0.2: resolution: {integrity: sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==} dependencies: - minimatch: 3.0.4 + minimatch: 3.1.2 /fileset/2.0.3: resolution: {integrity: sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA=} @@ -24341,9 +24367,9 @@ packages: '@types/glob': 7.2.0 array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.2.7 + fast-glob: 3.2.11 glob: 7.2.0 - ignore: 5.1.9 + ignore: 5.2.0 merge2: 1.4.1 slash: 3.0.0 dev: true @@ -25155,7 +25181,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: content-type: 1.0.4 - debug: 4.3.3 + debug: 4.3.4 is-retry-allowed: 1.2.0 is-stream: 2.0.1 parse-json: 4.0.0 @@ -25220,7 +25246,7 @@ packages: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 transitivePeerDependencies: - supports-color dev: true @@ -25687,7 +25713,7 @@ packages: mute-stream: 0.0.8 ora: 5.4.1 run-async: 2.4.1 - rxjs: 7.5.4 + rxjs: 7.5.5 string-width: 4.2.3 strip-ansi: 6.0.1 through: 2.3.8 @@ -26571,7 +26597,7 @@ packages: async: 0.9.2 chalk: 2.4.2 filelist: 1.0.2 - minimatch: 3.0.4 + minimatch: 3.1.2 /jest-allure/0.1.3: resolution: {integrity: sha512-EkO3LmkPx/a4VDg81JKtdy6kFXI0D1rHRIJ5Sa9MZaGtFYpgiCJFa6XwTgIySaVN+3+QBbIt1558CEkIW7FKFA==} @@ -29172,7 +29198,7 @@ packages: resolution: {integrity: sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==} engines: {node: '>=6'} dependencies: - graceful-fs: 4.2.8 + graceful-fs: 4.2.9 parse-json: 4.0.0 pify: 4.0.1 strip-bom: 3.0.0 @@ -29497,6 +29523,7 @@ packages: /lru-cache/7.4.0: resolution: {integrity: sha512-YOfuyWa/Ee+PXbDm40j9WXyJrzQUynVbgn4Km643UYcWNcrSfRkKL0WaiUcxcIbkXcVTgNpDqSnPXntWXT75cw==} engines: {node: '>=12'} + deprecated: Please update to latest patch version to fix memory leak https://github.com/isaacs/node-lru-cache/issues/227 dev: true /lru/3.1.0: @@ -29807,7 +29834,7 @@ packages: commondir: 1.0.1 deep-extend: 0.6.0 ejs: 3.1.6 - globby: 11.0.4 + globby: 11.1.0 isbinaryfile: 4.0.8 mem-fs: 2.2.1 minimatch: 3.1.2 @@ -31084,7 +31111,7 @@ packages: '@oclif/plugin-warn-if-update-available': 2.0.4 aws-sdk: 2.1079.0 concurrently: 7.0.0 - debug: 4.3.3 + debug: 4.3.4 find-yarn-workspace-root: 2.0.0 fs-extra: 8.1.0 github-slugger: 1.4.0 @@ -31380,7 +31407,7 @@ packages: resolution: {integrity: sha512-UJKdSzgd3KOnXXAtqN5+/eeHcvTn1hBkesEmElVgvO/NAYcxAvmjzIGmnNd3Tb/gRAvMBdNRFD4qAWdHxY6QXg==} engines: {node: '>=12.10.0'} dependencies: - debug: 4.3.3 + debug: 4.3.4 p-queue: 6.6.2 transitivePeerDependencies: - supports-color @@ -33424,7 +33451,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: chalk: 2.4.2 - debug: 4.3.3 + debug: 4.3.4 execa: 0.10.0 fs-extra: 6.0.1 get-stream: 5.2.0 @@ -35284,12 +35311,6 @@ packages: dependencies: tslib: 1.14.1 - /rxjs/7.5.4: - resolution: {integrity: sha512-h5M3Hk78r6wAheJF0a5YahB1yRQKCsZ4MsGdZ5O9ETbVtjPcScGfrMmoOq7EBsCRzd4BDkvDJ7ogP8Sz5tTFiQ==} - dependencies: - tslib: 2.3.1 - dev: true - /rxjs/7.5.5: resolution: {integrity: sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==} dependencies: @@ -35870,7 +35891,7 @@ packages: engines: {node: '>= 10'} dependencies: agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 socks: 2.6.2 transitivePeerDependencies: - supports-color @@ -37720,6 +37741,37 @@ packages: webpack: 5.70.0 dev: true + /ts-node/10.5.0_506ca6ef959d35afcce359030b1bc9ff: + resolution: {integrity: sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.7.0 + '@tsconfig/node10': 1.0.8 + '@tsconfig/node12': 1.0.9 + '@tsconfig/node14': 1.0.1 + '@tsconfig/node16': 1.0.2 + '@types/node': 16.10.3 + acorn: 8.7.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.6.2 + v8-compile-cache-lib: 3.0.0 + yn: 3.1.1 + dev: true + /ts-node/10.5.0_e0d88945dfc7787883e9c330c9196a96: resolution: {integrity: sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==} hasBin: true @@ -37740,7 +37792,7 @@ packages: '@tsconfig/node14': 1.0.1 '@tsconfig/node16': 1.0.2 '@types/node': 16.10.3 - acorn: 8.5.0 + acorn: 8.7.0 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 @@ -39823,13 +39875,13 @@ packages: cli-table: 0.3.11 commander: 7.1.0 dateformat: 4.6.3 - debug: 4.3.3 + debug: 4.3.4 diff: 5.0.0 error: 10.4.0 escape-string-regexp: 4.0.0 execa: 5.1.1 find-up: 5.0.0 - globby: 11.0.4 + globby: 11.1.0 grouped-queue: 2.0.0 inquirer: 8.2.0 is-scoped: 2.1.0 @@ -39837,7 +39889,7 @@ packages: log-symbols: 4.1.0 mem-fs: 2.2.1 mem-fs-editor: 9.4.0_mem-fs@2.2.1 - minimatch: 3.0.4 + minimatch: 3.1.2 npmlog: 5.0.1 p-queue: 6.6.2 p-transform: 1.3.0 @@ -39865,7 +39917,7 @@ packages: dependencies: chalk: 4.1.2 dargs: 7.0.0 - debug: 4.3.3 + debug: 4.3.4 execa: 4.1.0 github-username: 6.0.0 lodash: 4.17.21 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3352866d80..1b271ff0403 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'packages/js/**' - 'plugins/**' - 'tools/monorepo-merge' + - 'tools/code-analyzer' diff --git a/tools/code-analyzer/.eslintignore b/tools/code-analyzer/.eslintignore new file mode 100644 index 00000000000..9b1c8b133c9 --- /dev/null +++ b/tools/code-analyzer/.eslintignore @@ -0,0 +1 @@ +/dist diff --git a/tools/code-analyzer/.eslintrc b/tools/code-analyzer/.eslintrc new file mode 100644 index 00000000000..efdfa05bbaa --- /dev/null +++ b/tools/code-analyzer/.eslintrc @@ -0,0 +1,9 @@ +{ + "parser": "@typescript-eslint/parser", + "ignorePatterns": [ "dist/", "node_modules/" ], + "plugins": [ "@typescript-eslint/eslint-plugin" ], + "extends": [ "plugin:@wordpress/eslint-plugin/recommended-with-formatting" ], + "rules": { + "no-unused-vars": "off" + } +} diff --git a/tools/code-analyzer/.gitignore b/tools/code-analyzer/.gitignore new file mode 100644 index 00000000000..8cd56546876 --- /dev/null +++ b/tools/code-analyzer/.gitignore @@ -0,0 +1 @@ +/oclif.manifest.json diff --git a/tools/code-analyzer/bin/dev b/tools/code-analyzer/bin/dev new file mode 100755 index 00000000000..bbc3f51d59a --- /dev/null +++ b/tools/code-analyzer/bin/dev @@ -0,0 +1,17 @@ +#!/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) diff --git a/tools/code-analyzer/bin/dev.cmd b/tools/code-analyzer/bin/dev.cmd new file mode 100755 index 00000000000..8ae2b12c192 --- /dev/null +++ b/tools/code-analyzer/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\dev" %* diff --git a/tools/code-analyzer/bin/run b/tools/code-analyzer/bin/run new file mode 100755 index 00000000000..a7635de86ed --- /dev/null +++ b/tools/code-analyzer/bin/run @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) diff --git a/tools/code-analyzer/bin/run.cmd b/tools/code-analyzer/bin/run.cmd new file mode 100755 index 00000000000..968fc30758e --- /dev/null +++ b/tools/code-analyzer/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/tools/code-analyzer/package.json b/tools/code-analyzer/package.json new file mode 100644 index 00000000000..0e82b843ff0 --- /dev/null +++ b/tools/code-analyzer/package.json @@ -0,0 +1,60 @@ +{ + "name": "code-analyzer", + "version": "0.0.0", + "description": "A tool to analyze code changes in WooCommerce Monorepo.", + "author": "Automattic", + "bin": { + "code-analyzer": "./bin/run" + }, + "homepage": "https://github.com/woocommerce/woocommerce", + "license": "GPLv2", + "main": "dist/index.js", + "repository": "woocommerce/woocommerce", + "files": [ + "/bin", + "/dist", + "/npm-shrinkwrap.json", + "/oclif.manifest.json" + ], + "dependencies": { + "@oclif/core": "^1", + "@oclif/plugin-help": "^5", + "@oclif/plugin-plugins": "^2.0.1" + }, + "devDependencies": { + "@types/node": "^16.9.4", + "eslint": "^7.32.0", + "globby": "^11", + "oclif": "^2", + "shx": "^0.3.3", + "ts-node": "^10.2.1", + "tslib": "^2.3.1", + "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": { + "build": "shx rm -rf dist && tsc -b", + "lint": "eslint . --ext .ts --config .eslintrc", + "postpack": "shx rm -f oclif.manifest.json", + "posttest": "pnpm lint", + "prepack": "pnpm build && oclif manifest" + }, + "engines": { + "node": ">=12.0.0" + }, + "types": "dist/index.d.ts" +} diff --git a/tools/code-analyzer/src/commands/analyzer/index.ts b/tools/code-analyzer/src/commands/analyzer/index.ts new file mode 100644 index 00000000000..965f19b8dd1 --- /dev/null +++ b/tools/code-analyzer/src/commands/analyzer/index.ts @@ -0,0 +1,553 @@ +/** + * External dependencies + */ +import { CliUx, Command, Flags } from '@oclif/core'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { readFileSync } from 'fs'; +import { execSync } from 'child_process'; + +/** + * Internal dependencies + */ +import { MONOREPO_ROOT } from '../../const'; + +/** + * Analyzer class + */ +export default class Analyzer extends Command { + /** + * CLI description + */ + static description = 'Analyze code changes in WooCommerce Monorepo.'; + + /** + * CLI arguments + */ + static args = [ + { + name: 'compare', + description: + 'GitHub branch or commit hash to compare against the base branch/commit.', + 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: 'GitHub organization/repository.', + default: 'woocommerce/woocommerce', + } ), + plugin: Flags.string( { + char: 'p', + description: 'Plugin to check for', + options: [ 'core', 'admin', 'beta' ], + default: 'core', + } ), + }; + + /** + * This method is called to execute the command + */ + async run(): Promise< void > { + const { args, flags } = await this.parse( Analyzer ); + + await this.validateArgs( flags.source ); + + const patchContent = await this.getChanges( + flags.source, + args.compare, + flags.base + ); + + const pluginData = await this.getPluginData( flags.plugin ); + this.log( `${ pluginData[ 1 ] } Version: ${ pluginData[ 0 ] }` ); + + await this.scanChanges( patchContent, pluginData[ 0 ], flags.output ); + } + + /** + * Validates all of the arguments to make sure + * + * @param {string} source The GitHub repository we are merging. + */ + private async validateArgs( source: string ): Promise< void > { + // We only support pulling from GitHub so the format needs to match that. + if ( ! source.match( /^[a-z0-9\-]+\/[a-z0-9\-]+$/ ) ) { + this.error( + 'The "source" argument must be in "organization/repository" format' + ); + } + } + + /** + * Get plugin data + * + * @param {string} plugin Plugin slug. + * @return {Promise} Promise. + */ + private async getPluginData( plugin: string ): Promise< string[] > { + /** + * List of plugins from our monorepo. + */ + const plugins = { + core: { + name: 'WooCommerce', + mainFile: join( + MONOREPO_ROOT, + 'plugins', + 'woocommerce', + 'woocommerce.php' + ), + }, + admin: { + name: 'WooCommerce Admin', + mainFile: join( + MONOREPO_ROOT, + 'plugins', + 'woocommerce-admin', + 'woocommerce-admin.php' + ), + }, + beta: { + name: 'WooCommerce Beta Tester', + mainFile: join( + MONOREPO_ROOT, + 'plugins', + 'woocommerce-beta-tester', + 'woocommerce-beta-tester.php' + ), + }, + }; + + const pluginData = plugins[ plugin ]; + + CliUx.ux.action.start( `Getting ${ pluginData.name } version` ); + + const content = readFileSync( pluginData.mainFile ).toString(); + const rawVer = content.match( /^\s+\*\s+Version:\s+(.*)/m ); + + if ( ! rawVer ) { + this.error( 'Failed to find plugin version!' ); + } + const version = rawVer[ 1 ].replace( /\-.*/, '' ); + + CliUx.ux.action.stop(); + + return [ version, pluginData.name, pluginData.mainFile ]; + } + + /** + * Fetch branches from origin. + * + * @param {string} branch branch/commit hash. + * @return {Promise} Promise. + */ + private async fetchBranch( branch: string ): Promise< boolean > { + CliUx.ux.action.start( `Fetching ${ branch }` ); + const branches = execSync( 'git branch', { + encoding: 'utf-8', + } ); + + const branchExistsLocally = branches.includes( branch ); + + if ( branchExistsLocally ) { + CliUx.ux.action.stop(); + return true; + } + + try { + // Fetch branch. + execSync( `git fetch origin ${ branch }` ); + // Create branch. + execSync( `git branch ${ branch } origin/${ branch }` ); + } catch ( e ) { + this.error( `Unable to fetch ${ branch }` ); + } + + CliUx.ux.action.stop(); + return true; + } + + /** + * Generate a patch file into the temp directory and return its contents + * + * @param {string} source The GitHub repository. + * @param {string} compare Branch/commit hash to compare against the base. + * @param {string} base Base branch/commit hash. + * @return {Promise} Promise. + */ + private async getChanges( + source: string, + compare: string, + base: string + ): Promise< string > { + const filename = `${ source }-${ base }-${ compare }.patch`.replace( + /\//g, + '-' + ); + const filepath = join( tmpdir(), filename ); + + await this.fetchBranch( base ); + await this.fetchBranch( compare ); + + CliUx.ux.action.start( 'Generating patch for ' + compare ); + + try { + const diffCommand = `git diff ${ base }...${ compare } > ${ filepath }`; + execSync( diffCommand ); + } catch ( e ) { + this.error( + 'Unable to create diff. Check that git origin, base branch, and compare branch all exist.' + ); + } + + const content = readFileSync( filepath ).toString(); + + CliUx.ux.action.stop(); + return content; + } + + /** + * Get patches + * + * @param {string} content Patch content. + * @param {RegExp} regex Regex to find specific patches. + * @return {Promise} Promise. + */ + private async getPatches( + content: string, + regex: RegExp + ): Promise< 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 filename from patch + * + * @param {string} str String to extract filename from. + * @return {Promise} Promise. + */ + private async getFilename( str: string ): Promise< string > { + return str.replace( /^a(.*)\s.*/, '$1' ); + } + + /** + * Format version string for regex. + * + * @param {string} rawVersion Raw version number. + * @return {Promise} Promise. + */ + private async getVersionRegex( rawVersion: string ): Promise< string > { + const version = rawVersion.replace( /\./g, '\\.' ); + + if ( rawVersion.endsWith( '.0' ) ) { + return version + '|' + version.slice( 0, -3 ) + '\\n'; + } + + return version; + } + + /** + * 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. + */ + private async scanChanges( + content: string, + version: string, + output: string + ): Promise< void > { + const templates = await this.scanTemplates( content, version ); + const hooks = await this.scanHooks( content, version, output ); + // @todo: Scan for changes to database schema. + + if ( templates.size ) { + await this.printTemplateResults( + templates, + output, + 'TEMPLATE CHANGES' + ); + } else { + this.log( 'No template changes found' ); + } + + if ( hooks.size ) { + await this.printHookResults( hooks, output, 'HOOKS' ); + } else { + this.log( 'No new hooks found' ); + } + } + + /** + * Print template results + * + * @param {Map} data Raw data. + * @param {string} output Output style. + * @param {string} title Section title. + */ + private async printTemplateResults( + data: Map< string, string[] >, + output: string, + title: string + ): Promise< void > { + if ( output === 'github' ) { + let opt = '\\n\\n### Template changes:'; + for ( const [ key, value ] of data ) { + opt += `\\n* **file:** ${ key }`; + opt += `\\n * ${ value[ 0 ].toUpperCase() }: ${ value[ 2 ] }`; + this.log( + `::${ value[ 0 ] } file=${ key },line=1,title=${ value[ 1 ] }::${ value[ 2 ] }` + ); + } + + this.log( `::set-output name=templates::${ opt }` ); + } else { + this.log( `\n## ${ title }:` ); + for ( const [ key, value ] of data ) { + this.log( 'FILE: ' + key ); + this.log( + '---------------------------------------------------' + ); + this.log( + ` ${ value[ 0 ].toUpperCase() } | ${ value[ 1 ] } | ${ + value[ 2 ] + }` + ); + this.log( + '---------------------------------------------------' + ); + } + } + } + + /** + * Print hook results + * + * @param {Map} data Raw data. + * @param {string} output Output style. + * @param {string} title Section title. + */ + private async printHookResults( + data: Map< string, Map< string, string[] > >, + output: string, + title: string + ): Promise< void > { + if ( output === 'github' ) { + let opt = '\\n\\n### New hooks:'; + for ( const [ key, value ] of data ) { + if ( value.size ) { + opt += `\\n* **file:** ${ key }`; + for ( const [ k, v ] of value ) { + opt += `\\n * ${ v[ 0 ].toUpperCase() }: ${ v[ 2 ] }`; + this.log( + `::${ v[ 0 ] } file=${ key },line=1,title=${ v[ 1 ] } - ${ k }::${ v[ 2 ] }` + ); + } + } + } + + this.log( `::set-output name=wphooks::${ opt }` ); + } else { + this.log( `\n## ${ title }:` ); + for ( const [ key, value ] of data ) { + if ( value.size ) { + this.log( 'FILE: ' + key ); + this.log( + '---------------------------------------------------' + ); + for ( const [ k, v ] of value ) { + this.log( 'HOOK: ' + k ); + this.log( + '---------------------------------------------------' + ); + this.log( + ` ${ v[ 0 ].toUpperCase() } | ${ v[ 1 ] } | ${ + v[ 2 ] + }` + ); + this.log( + '---------------------------------------------------' + ); + } + } + } + } + } + + /** + * Get hook name. + * + * @param {string} name Raw hook name. + * @return {Promise} Promise. + */ + private async getHookName( name: string ): Promise< string > { + if ( name.indexOf( ',' ) > -1 ) { + name = name.substring( 0, name.indexOf( ',' ) ); + } + + return name.replace( /(\'|\")/g, '' ).trim(); + } + + /** + * Scan patches for changes in templates + * + * @param {string} content Patch content. + * @param {string} version Current product version. + * @return {Promise>} Promise. + */ + private async scanTemplates( + content: string, + version: string + ): Promise< Map< string, string[] > > { + CliUx.ux.action.start( 'Scanning template changes' ); + + 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 = await this.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 = await this.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>>} Promise. + */ + private async scanHooks( + content: string, + version: string, + output: string + ): Promise< Map< string, Map< string, string[] > > > { + CliUx.ux.action.start( 'Scanning for new hooks' ); + + 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 = await this.getPatches( content, matchPatches ); + const verRegEx = await this.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 ]; + const results = patch.match( newRegEx ); + const hooksList: Map< string, string[] > = new Map< + string, + string[] + >(); + + if ( ! results ) { + continue; + } + + const lines = patch.split( '\n' ); + const filepath = await this.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 = await this.getHookName( hookName[ 3 ] ); + 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 title = `New ${ kind } found`; + + if ( ! hookName[ 2 ].startsWith( '-' ) ) { + hooksList.set( name, [ 'NOTICE', title, message ] ); + } + } + + report.set( filepath, hooksList ); + } + + CliUx.ux.action.stop(); + return report; + } +} diff --git a/tools/code-analyzer/src/const.ts b/tools/code-analyzer/src/const.ts new file mode 100644 index 00000000000..2475bfc9b95 --- /dev/null +++ b/tools/code-analyzer/src/const.ts @@ -0,0 +1,7 @@ +/** + * External dependencies + */ +import { dirname } from 'path'; + +// Escape from ./tools/monorepo-merge/src +export const MONOREPO_ROOT = dirname( dirname( dirname( __dirname ) ) ); diff --git a/tools/code-analyzer/src/index.ts b/tools/code-analyzer/src/index.ts new file mode 100644 index 00000000000..d620e709ab9 --- /dev/null +++ b/tools/code-analyzer/src/index.ts @@ -0,0 +1 @@ +export { run } from '@oclif/core'; diff --git a/tools/code-analyzer/tsconfig.json b/tools/code-analyzer/tsconfig.json new file mode 100644 index 00000000000..cc840adc398 --- /dev/null +++ b/tools/code-analyzer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "importHelpers": true, + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "es2019" + }, + "include": [ + "src/**/*" + ] +}