diff --git a/.github/workflows/commit_lint.yml b/.github/workflows/commit_lint.yml new file mode 100644 index 000000000..31a852112 --- /dev/null +++ b/.github/workflows/commit_lint.yml @@ -0,0 +1,36 @@ +name: Commit lint +on: + pull_request +env: + BRANCH: ${{ github.base_ref }} + +jobs: + Commits: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Git commits + run: | + git remote add ssh-origin "https://github.com/$GITHUB_REPOSITORY" + git fetch ssh-origin + COMMITS=$(git cherry -v ssh-origin/${{ env.BRANCH }} | grep "+") + echo ::set-env name=GIT_COMMITS::$COMMITS + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Lint + uses: actions/github-script@v2 + with: + script: | + const actionCommit = require(`${process.env.GITHUB_WORKSPACE}/scripts/actions.commit.js`) + const { resultsArray, resultsString } = actionCommit(process.env.GIT_COMMITS) + + if (resultsArray.length) { + core.setFailed(resultsString) + } diff --git a/scripts/actions.commit.js b/scripts/actions.commit.js new file mode 100644 index 000000000..7a2e559d4 --- /dev/null +++ b/scripts/actions.commit.js @@ -0,0 +1,98 @@ +/** + * Breakout individual commits. + * + * @param {string} commits + * @returns {{issueNumber: string, description: string, trimmedMessage: string, hash: string, typeScope: string}[]} + */ +const messages = commits => + commits + .trim() + .replace(/\n/g, '') + .replace(/\+\s/g, '\n') + .replace(/\n/, '') + .split(/\n/g) + .map(message => { + const [hashTypeScope, ...issueNumberDescription] = + (/:/.test(message) && message.split(/:/)) || message.split(/\s/); + + const [hash, typeScope = ''] = hashTypeScope.split(/\s/); + const [issueNumber, ...description] = issueNumberDescription.join(' ').trim().split(/\s/g); + + const updatedTypeScope = (typeScope && `${typeScope}:`) || ''; + const updatedDescription = description.join(' '); + const [updatedMessage, remainingMessage = ''] = `${updatedTypeScope} ${issueNumber} ${updatedDescription}`.split( + /\(#\d{1,5}\)/ + ); + + return { + trimmedMessage: + (remainingMessage.trim().length === 0 && updatedMessage.trim()) || + `${updatedTypeScope} ${issueNumber} ${updatedDescription}`, + hash, + typeScope: updatedTypeScope, + issueNumber, + description: updatedDescription + }; + }); + +/** + * Apply valid/invalid checks. + * + * @param {Array} parsedMessages + * @returns {Array} + */ +const messagesList = parsedMessages => + parsedMessages.map(message => { + const { trimmedMessage = null, typeScope = null, issueNumber = null, description = null } = message; + + const issueNumberException = + /(^chore\([\d\D]+\))|(^fix\([\d\D]+\))|(^perf\([\d\D]+\))|(^build\([\d\D]+\))|(^[\d\D]+\(build\))/.test( + typeScope + ) || /\(#[\d\D]+\)$/.test(description); + + const typeScopeValid = (/(^[\d\D]+\([\d\D]+\):$)|(^[\d\D]+:$)/.test(typeScope) && 'valid') || 'INVALID: type scope'; + + const issueNumberValid = + (/(^issues\/[\d,]+$)/.test(issueNumber) && 'valid') || + (issueNumberException && 'valid') || + 'INVALID: issue number'; + + const descriptionValid = + (/(^[\d\D]+$)/.test(description || (issueNumberException && issueNumber)) && 'valid') || + (issueNumberException && !description && issueNumber && 'valid') || + 'INVALID: description'; + + const lengthValid = + (trimmedMessage && trimmedMessage.length <= 65 && 'valid') || + `INVALID: message length (${trimmedMessage && trimmedMessage.length} > 65)`; + + // ([scope]): issues/ + return `${typeScope}<${typeScopeValid}> ${issueNumber}<${issueNumberValid}> ${description}<${descriptionValid}><${lengthValid}>`; + }); + +/** + * Remove valid commits. + * + * @param {Array} parsedMessagesList + * @returns {Array} + */ +const filteredMessages = parsedMessagesList => + parsedMessagesList.filter(value => !/[\d\D]*[\d\D]*/.test(value)); + +/** + * If commits exist, lint them. + * + * @param {string} commits + * @returns {{resultsArray: Array, resultsString: string}} + */ +module.exports = commits => { + const lintResults = { resultsArray: [], resultsString: '' }; + + if (commits) { + const parsedResults = filteredMessages(messagesList(messages(commits))); + lintResults.resultsArray = parsedResults; + lintResults.resultsString = JSON.stringify(parsedResults, null, 2); + } + + return lintResults; +}; diff --git a/tests/commit.test.js b/tests/commit.test.js deleted file mode 100644 index 1534367ff..000000000 --- a/tests/commit.test.js +++ /dev/null @@ -1,99 +0,0 @@ -const { execSync } = require('child_process'); - -/** - * ToDo: evaluate moving this check to Github actions - * And evaluate removing the `grep "+"` filter to check for rebase - */ -/** - * See CONTRIBUTING.md for commit messaging guidelines - * - * TypeScope is required and should be in the form of "([scope]):" or ":" - * - * IssueNumber is conditional and should be in the form of "issues/[issue number]" - * Exceptions are available for chore and fix type scopes, and "build" scope descriptions. - * i.e. "chore([scope])", "fix([scope])", "(build):" - * An exception for having a PR reference at the end of the description is also allowed, - * i.e. " (#10)" - * - * Minimal description is required, can be in the form of "" - * - * Message lengths should not be greater than 65 characters. Appended PR numbers in the - * form of (#) are ignored i.e. (#10) - */ -describe('Commit Message', () => { - it('should have consistently formatted commit messages on pull requests', () => { - // see .travis.yml globals - if (!process.env.IS_PR || process.env.IS_PR === 'false') { - expect(1).toBe(1); - return; - } - - let stdout = ''; - - try { - stdout = execSync(`git cherry -v master | grep "+"`); - } catch (e) { - console.log(`Skipping commit check... ${e.message}`); - } - - const messages = stdout - .toString() - .trim() - .replace(/\+\s/g, '') - .split(/\n/g) - .map(message => { - const [hashTypeScope, ...issueNumberDescription] = - (/:/.test(message) && message.split(/:/)) || message.split(/\s/); - - const [hash, typeScope = ''] = hashTypeScope.split(/\s/); - const [issueNumber, ...description] = issueNumberDescription.join(' ').trim().split(/\s/g); - - const updatedTypeScope = (typeScope && `${typeScope}:`) || ''; - const updatedDescription = description.join(' '); - const [ - updatedMessage, - remainingMessage = '' - ] = `${updatedTypeScope} ${issueNumber} ${updatedDescription}`.split(/\(#\d{1,5}\)/); - - return { - trimmedMessage: - (remainingMessage.trim().length === 0 && updatedMessage.trim()) || - `${updatedTypeScope} ${issueNumber} ${updatedDescription}`, - hash, - typeScope: updatedTypeScope, - issueNumber, - description: updatedDescription - }; - }); - - const messagesList = messages.map(message => { - const { trimmedMessage = null, typeScope = null, issueNumber = null, description = null } = message; - - const issueNumberException = - /(^chore\([\d\D]+\))|(^fix\([\d\D]+\))|(^[\d\D]+\(build\))/.test(typeScope) || - /\(#[\d\D]+\)$/.test(description); - - const typeScopeValid = - (/(^[\d\D]+\([\d\D]+\):$)|(^[\d\D]+:$)/.test(typeScope) && 'valid') || 'INVALID: type scope'; - - const issueNumberValid = - (/(^issues\/[\d,]+$)/.test(issueNumber) && 'valid') || - (issueNumberException && 'valid') || - 'INVALID: issue number'; - - const descriptionValid = - (/(^[\d\D]+$)/.test(description || (issueNumberException && issueNumber)) && 'valid') || - (issueNumberException && !description && issueNumber && 'valid') || - 'INVALID: description'; - - const lengthValid = - (trimmedMessage && trimmedMessage.length <= 65 && 'valid') || - `INVALID: message length (${trimmedMessage && trimmedMessage.length} > 65)`; - - // ([scope]): issues/ - return `${typeScope}<${typeScopeValid}> ${issueNumber}<${issueNumberValid}> ${description}<${descriptionValid}><${lengthValid}>`; - }); - - expect(messagesList.filter(value => !/[\d\D]*[\d\D]*/.test(value))).toEqual([]); - }); -});