From 1530c28e05c64406457e42ad248fa78009004496 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 30 Jan 2020 08:05:40 -0800 Subject: [PATCH] ci: add verification of the pullapprove config (#35060) Verify that all files in the repo are covered by the pullapprove config and that all rules in the pullapprove config match at least one file in the repo. PR Close #35060 --- .circleci/config.yml | 1 + package.json | 3 + tools/pullapprove/verify.js | 206 ++++++++++++++++++++++++++++++++++++ yarn.lock | 21 +++- 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tools/pullapprove/verify.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 903e3b1aff..6e10584f81 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -271,6 +271,7 @@ jobs: (echo -e "\n.bzl files have lint errors. Please run ''yarn bazel:lint-fix''"; exit 1)' - run: yarn gulp lint + - run: node tools/pullapprove/verify.js test: executor: diff --git a/package.json b/package.json index 9b78026910..5143950862 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/semver": "^6.0.2", "@types/shelljs": "^0.8.6", "@types/systemjs": "0.19.32", + "@types/yaml": "^1.2.0", "@types/yargs": "^11.1.1", "@webcomponents/custom-elements": "^1.0.4", "angular": "npm:angular@1.7", @@ -104,6 +105,7 @@ "karma-sourcemap-loader": "^0.3.7", "magic-string": "^0.25.0", "materialize-css": "1.0.0", + "minimatch": "^3.0.4", "minimist": "1.2.0", "node-uuid": "1.4.8", "nodejs-websocket": "^1.7.2", @@ -127,6 +129,7 @@ "tslint": "5.7.0", "typescript": "~3.7.4", "xhr2": "0.1.4", + "yaml": "^1.7.2", "yargs": "13.1.0" }, "// 2": "devDependencies are not used under Bazel. Many can be removed after test.sh is deleted.", diff --git a/tools/pullapprove/verify.js b/tools/pullapprove/verify.js new file mode 100644 index 0000000000..35b3c54010 --- /dev/null +++ b/tools/pullapprove/verify.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +/* tslint:disable:no-console */ +const parseYaml = require('yaml').parse; +const readFileSync = require('fs').readFileSync; +const Minimatch = require('minimatch').Minimatch; +const {exec, set, cd} = require('shelljs'); +const path = require('path'); + +// Exit early on shelljs errors +set('-e'); + +// Regex Matcher for contains_any_globs conditions +const CONTAINS_ANY_GLOBS_REGEX = /^'([^']+)',?$/; + +// Full path of the angular project directory +const ANGULAR_PROJECT_DIR = path.resolve(__dirname, '../..'); +// Change to the Angular project directory +cd(ANGULAR_PROJECT_DIR); + +// Whether to log verbosely +const VERBOSE_MODE = process.argv.includes('-v'); +// Full path to PullApprove config file +const PULL_APPROVE_YAML_PATH = path.resolve(ANGULAR_PROJECT_DIR, '.pullapprove.yml'); +// All relative path file names in the git repo, this is retrieved using git rather +// that a glob so that we only get files that are checked in, ignoring things like +// node_modules, .bazelrc.user, etc +const ALL_FILES = exec('git ls-tree --full-tree -r --name-only HEAD', {silent: true}) + .trim() + .split('\n') + .filter(_ => _); +if (!ALL_FILES.length) { + console.error( + `No files were found to be in the git tree, did you run this command from \n` + + `inside the angular repository?`); + process.exit(1); +} + +/** Gets the glob matching information from each group's condition. */ +function getGlobMatchersFromCondition(groupName, condition) { + const trimmedCondition = condition.trim(); + const globMatchers = []; + const badConditionLines = []; + + // If the condition starts with contains_any_globs, evaluate all of the globs + if (trimmedCondition.startsWith('contains_any_globs')) { + trimmedCondition.split('\n') + .slice(1, -1) + .map(glob => { + const trimmedGlob = glob.trim(); + const match = trimmedGlob.match(CONTAINS_ANY_GLOBS_REGEX); + if (!match) { + badConditionLines.push(trimmedGlob); + return; + } + return match[1]; + }) + .filter(globString => !!globString) + .forEach(globString => globMatchers.push({ + group: groupName, + glob: globString, + matcher: new Minimatch(globString, {dot: true}), + matchCount: 0, + })); + } + return [globMatchers, badConditionLines]; +} + +/** Create logs for each review group. */ +function logGroups(groups) { + Array.from(groups.entries()).sort().forEach(([groupName, globs]) => { + console.groupCollapsed(groupName); + Array.from(globs.values()) + .sort((a, b) => b.matchCount - a.matchCount) + .forEach(glob => console.log(`${glob.glob} - ${glob.matchCount}`)); + console.groupEnd(); + }); +} + +/** Logs a header within a text drawn box. */ +function logHeader(...params) { + const totalWidth = 80; + const fillWidth = totalWidth - 2; + const headerText = params.join(' ').substr(0, fillWidth); + const leftSpace = Math.ceil((fillWidth - headerText.length) / 2); + const rightSpace = fillWidth - leftSpace - headerText.length; + const fill = (count, content) => content.repeat(count); + + console.log(`┌${fill(fillWidth, '─')}┐`); + console.log(`│${fill(leftSpace, ' ')}${headerText}${fill(rightSpace, ' ')}│`); + console.log(`└${fill(fillWidth, '─')}┘`); +} + +/** Runs the pull approve verification check on provided files. */ +function runVerification(files) { + // All of the globs created for each group's conditions. + const allGlobs = []; + // The pull approve config file. + const pullApprove = readFileSync(PULL_APPROVE_YAML_PATH, {encoding: 'utf8'}); + // All of the PullApprove groups, parsed from the PullApprove yaml file. + const parsedPullApproveGroups = parseYaml(pullApprove).groups; + // All files which were found to match a condition in PullApprove. + const matchedFiles = new Set(); + // All files which were not found to match a condition in PullApprove. + const unmatchedFiles = new Set(); + // All PullApprove groups which matched at least one file. + const matchedGroups = new Map(); + // All PullApprove groups which did not match at least one file. + const unmatchedGroups = new Map(); + // All condition lines which were not able to be correctly parsed, by group. + const badConditionLinesByGroup = new Map(); + // Total number of condition lines which were not able to be correctly parsed. + let badConditionLineCount = 0; + + // Get all of the globs from the PullApprove group conditions. + Object.entries(parsedPullApproveGroups).map(([groupName, group]) => { + for (const condition of group.conditions) { + const [matchers, badConditions] = getGlobMatchersFromCondition(groupName, condition); + if (badConditions.length) { + badConditionLinesByGroup.set(groupName, badConditions); + badConditionLineCount += badConditions.length; + } + allGlobs.push(...matchers); + } + }); + + if (badConditionLineCount) { + console.log(`Discovered ${badConditionLineCount} parsing errors in PullApprove conditions`); + console.log(`Attempted parsing using: ${CONTAINS_ANY_GLOBS_REGEX}`); + console.log(); + console.log(`Unable to properly parse the following line(s) by group:`); + for (const [groupName, badConditionLines] of badConditionLinesByGroup.entries()) { + console.log(`- ${groupName}:`); + badConditionLines.forEach(line => console.log(` ${line}`)); + } + console.log(); + console.log( + `Please correct the invalid conditions, before PullApprove verification can be completed`); + process.exit(1); + } + + // Check each file for if it is matched by a PullApprove condition. + for (let file of files) { + const matched = allGlobs.filter(glob => glob.matcher.match(file)); + matched.length ? matchedFiles.add(file) : unmatchedFiles.add(file); + matched.forEach(glob => glob.matchCount++); + } + + // Add each glob for each group to a map either matched or unmatched. + allGlobs.forEach(glob => { + const groups = glob.matchCount ? matchedGroups : unmatchedGroups; + const globs = groups.get(glob.group) || new Map(); + // Set the globs map in the groups map + groups.set(glob.group, globs); + // Set the glob in the globs map + globs.set(glob.glob, glob); + }); + + // PullApprove is considered verified if no files or groups are found to be unsed. + const verificationSucceeded = !(unmatchedFiles.size || unmatchedGroups.size); + + /** + * Overall result + */ + logHeader('Result'); + if (verificationSucceeded) { + console.log('PullApprove verification succeeded!'); + } else { + console.log(`PullApprove verification failed.\n`); + console.log(`Please update '.pullapprove.yml' to ensure that all necessary`); + console.log(`files/directories have owners and all patterns that appear in`); + console.log(`the file correspond to actual files/directories in the repo.`); + } + /** + * File by file Summary + */ + logHeader('PullApprove file match results'); + console.groupCollapsed(`Matched Files (${matchedFiles.size} files)`); + VERBOSE_MODE && matchedFiles.forEach(file => console.log(file)); + console.groupEnd(); + console.groupCollapsed(`Unmatched Files (${unmatchedFiles.size} files)`); + unmatchedFiles.forEach(file => console.log(file)); + console.groupEnd(); + + /** + * Group by group Summary + */ + logHeader('PullApprove group matches'); + console.groupCollapsed(`Matched Groups (${matchedGroups.size} groups)`); + VERBOSE_MODE && logGroups(matchedGroups); + console.groupEnd(); + console.groupCollapsed(`Unmatched Groups (${unmatchedGroups.size} groups)`); + logGroups(unmatchedGroups); + console.groupEnd(); + + // Provide correct exit code based on verification success. + process.exit(verificationSucceeded ? 0 : 1); +} + +runVerification(ALL_FILES); diff --git a/yarn.lock b/yarn.lock index 2bfc2eee41..1938241ba6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -936,6 +936,13 @@ js-levenshtein "^1.1.3" semver "^5.5.0" +"@babel/runtime@^7.6.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1" + integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/template@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.4.tgz#428a7d9eecffe27deac0a98e23bf8e3675d2a77b" @@ -1720,6 +1727,11 @@ "@types/source-list-map" "*" source-map "^0.6.1" +"@types/yaml@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/yaml/-/yaml-1.2.0.tgz#4ed577fc4ebbd6b829b28734e56d10c9e6984e09" + integrity sha512-GW8b9qM+ebgW3/zjzPm0I1NxMvLaz/YKT9Ph6tTb+Fkeyzd9yLTvQ6ciQ2MorTRmb/qXmfjMerRpG4LviixaqQ== + "@types/yargs@^11.1.1": version "11.1.1" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-11.1.1.tgz#2e724257167fd6b615dbe4e54301e65fe597433f" @@ -12375,7 +12387,7 @@ regenerate@^1.4.0: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== -regenerator-runtime@0.13.3: +regenerator-runtime@0.13.3, regenerator-runtime@^0.13.2: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== @@ -15636,6 +15648,13 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.7.2.tgz#f26aabf738590ab61efaca502358e48dc9f348b2" + integrity sha512-qXROVp90sb83XtAoqE8bP9RwAkTTZbugRUTm5YeFCBfNRPEp2YzTeqWiz7m5OORHzEvrA/qcGS8hp/E+MMROYw== + dependencies: + "@babel/runtime" "^7.6.3" + yargs-parser@^11.1.1: version "11.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4"