-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
build(actions): commit syntax parser (#317)
- Loading branch information
Showing
1 changed file
with
153 additions
and
78 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,108 +1,183 @@ | ||
/** | ||
* Breakout individual commits. | ||
* Available message scope types. | ||
* | ||
* @param {string} commits | ||
* @returns {{issueNumber: string, description: string, trimmedMessage: string, hash: string, typeScope: string}[]} | ||
* @type {Array<string>} | ||
*/ | ||
const availableMessageTypes = [ | ||
'feat', | ||
'fix', | ||
'docs', | ||
'style', | ||
'refactor', | ||
'perf', | ||
'test', | ||
'build', | ||
'ci', | ||
'chore', | ||
'revert' | ||
]; | ||
|
||
/** | ||
* Parse a commit message | ||
* | ||
* @param {string} message | ||
* @param {Array} messageTypes | ||
* @returns {{scope: string, description: string, type: string, prNumber: string, hash: string, | ||
* typeScope: string, isBreaking: boolean, original: string, message: string, length: number}} | ||
*/ | ||
const messages = commits => | ||
commits | ||
const parseCommitMessage = (message, messageTypes = availableMessageTypes) => { | ||
let output; | ||
|
||
const [hashTypeScope, ...descriptionEtAll] = message.trim().split(/:/); | ||
const [description, ...partialPr] = descriptionEtAll | ||
.join(' ') | ||
.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}\)/ | ||
); | ||
.split(/(\(#|#)/); | ||
const [hash, ...typeScope] = hashTypeScope.replace(/!$/, '').trim().split(/\s/); | ||
const [type, scope = ''] = typeScope.join(' ').trim().split('('); | ||
|
||
return { | ||
trimmedMessage: | ||
(remainingMessage.trim().length === 0 && updatedMessage.trim()) || | ||
`${updatedTypeScope} ${issueNumber} ${updatedDescription}`, | ||
hash, | ||
typeScope: updatedTypeScope, | ||
issueNumber, | ||
description: updatedDescription | ||
}; | ||
}); | ||
output = { | ||
hash, | ||
typeScope: typeScope.join(' ').trim() || undefined, | ||
type: (messageTypes.includes(type) && type) || undefined, | ||
scope: scope.split(')')[0] || undefined, | ||
description: description.trim() || undefined, | ||
prNumber: (partialPr.join('(#').trim() || '').replace(/\D/g, '') || undefined, | ||
isBreaking: /!$/.test(hashTypeScope) | ||
}; | ||
|
||
if (!output.type || (output.type && !descriptionEtAll?.length)) { | ||
const [hashFallback, ...descriptionEtAllFallback] = message.trim().split(/\s/); | ||
const [descriptionFallback, ...partialPrFallback] = descriptionEtAllFallback.join(' ').trim().split(/\(#/); | ||
|
||
output = { | ||
hash: hashFallback, | ||
typeScope: undefined, | ||
type: undefined, | ||
scope: undefined, | ||
description: descriptionFallback.trim(), | ||
prNumber: (partialPrFallback.join('(#').trim() || '').replace(/\D/g, '') || undefined, | ||
isBreaking: undefined | ||
}; | ||
} | ||
|
||
const updatedMessage = [ | ||
`${output.typeScope || ''}${(output.isBreaking && '!') || ''}${(output.typeScope && ':') || ''}`, | ||
output.description | ||
] | ||
.filter(value => !!value) | ||
.join(' ') | ||
.trim(); | ||
|
||
const out = { | ||
...output, | ||
messageLength: updatedMessage?.length || 0, | ||
message: updatedMessage, | ||
original: message | ||
}; | ||
|
||
return out; | ||
}; | ||
|
||
/** | ||
* Apply valid/invalid checks. | ||
* | ||
* @param {Array} parsedMessages | ||
* @param {object} options Default options, update accordingly | ||
* @param {boolean|undefined|null|Array} options.issueNumberExceptions An "undefined" or "false" or "falsy" value | ||
* will ignore issue numbers. An array of issue type exceptions can be used to identify which commit message | ||
* type scopes to ignore, i.e. ['chore', 'fix', 'build', 'perf']. See NPM conventional-commit-types for full | ||
* listing options, https://bit.ly/2L0yr6I | ||
* @param {Array|string|undefined} options.issueNumberExceptions An "undefined" or "false" or "falsy" value | ||
* will ignore issue numbers. A string of "*" will allow every type. An array of issue types can be used | ||
* to identify which commit message type scopes to ignore, i.e. ['chore', 'fix', 'build', 'perf']. | ||
* See NPM conventional-commit-types for full listing options, https://bit.ly/2L0yr6I | ||
* @param {number} options.maxMessageLength Max length of the main message string. Messages considered "body" | ||
* do not count against this limit. | ||
* @param {Array|string|undefined} options.typeScopeExceptions see options.issueNumberExceptions | ||
* @returns {Array} | ||
*/ | ||
const messagesList = (parsedMessages, { issueNumberExceptions = false, maxMessageLength = 65 } = {}) => | ||
parsedMessages.map(message => { | ||
const { trimmedMessage = null, typeScope = null, issueNumber = null, description = null } = message; | ||
|
||
const issueNumberRegex = `(^{0}\\([\\d\\D]+\\))`; | ||
const issueNumberException = !issueNumberExceptions | ||
? true | ||
: new RegExp( | ||
`${issueNumberExceptions.map(issueType => issueNumberRegex.replace('{0}', issueType)).join('|')}` | ||
).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') || | ||
(/(^[a-zA-Z]+-[\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 <= maxMessageLength && 'valid') || | ||
`INVALID: message length (${trimmedMessage && trimmedMessage.length} > ${maxMessageLength})`; | ||
|
||
// <type>([scope]): issues/<number> <description> <messageLength> | ||
return `${typeScope}<${typeScopeValid}> ${issueNumber}<${issueNumberValid}> ${description}<${descriptionValid}><${lengthValid}>`; | ||
}); | ||
const messagesList = ( | ||
parsedMessages, | ||
{ issueNumberExceptions = '*', maxMessageLength = 65, typeScopeExceptions = '*' } = {} | ||
) => | ||
parsedMessages.map( | ||
({ messageLength = 0, type = null, scope = null, description = null, message = null, hash = null }) => { | ||
const typeValid = | ||
(type && 'valid') || 'INVALID: type (expected known types and format "<type>:" or "<type>(<scope>):")'; | ||
|
||
/** | ||
* Remove valid commits. | ||
* | ||
* @param {Array} parsedMessagesList | ||
* @returns {Array} | ||
*/ | ||
const filteredMessages = parsedMessagesList => | ||
parsedMessagesList.filter(value => !/<valid>[\d\D]*<valid>[\d\D]*<valid><valid>/.test(value)); | ||
let scopeException = !typeScopeExceptions || !typeScopeExceptions?.length || typeScopeExceptions === '*'; | ||
|
||
if (!scopeException && Array.isArray(typeScopeExceptions)) { | ||
scopeException = typeScopeExceptions.includes(type); | ||
} | ||
|
||
const scopeValid = (scopeException && 'valid') || (scope && 'valid') || 'INVALID: scope'; | ||
|
||
let issueNumberException = | ||
!issueNumberExceptions || !issueNumberExceptions?.length || issueNumberExceptions === '*'; | ||
|
||
if (!issueNumberException && Array.isArray(issueNumberExceptions)) { | ||
issueNumberException = issueNumberExceptions.includes(type); | ||
} | ||
|
||
const isIssueNumber = /(^[a-zA-Z]+[/-]+[0-9]+)/.test(description); | ||
// Note: skip issueNumber validation if typeValid fails, this is on purpose | ||
const issueNumberValid = | ||
(typeValid !== 'valid' && 'valid') || | ||
(issueNumberException && 'valid') || | ||
(isIssueNumber && 'valid') || | ||
'INVALID: issue number (expected format "<desc>/<number>" or "<desc>-<number>")'; | ||
|
||
const descriptionValid = (description && 'valid') || 'INVALID: description (missing description)'; | ||
|
||
const lengthValid = | ||
(messageLength <= maxMessageLength && 'valid') || | ||
`INVALID: message length (${messageLength} > ${maxMessageLength})`; | ||
|
||
return { | ||
hash, | ||
commit: message, | ||
type: typeValid, | ||
scope: scopeValid, | ||
description: descriptionValid, | ||
issueNumber: issueNumberValid, | ||
length: lengthValid | ||
}; | ||
} | ||
); | ||
|
||
/** | ||
* If commits exist, lint them. | ||
* | ||
* @param {string} commits | ||
* @returns {{resultsArray: Array, resultsString: string}} | ||
*/ | ||
module.exports = commits => { | ||
const actionCommitCheck = commits => { | ||
const lintResults = { resultsArray: [], resultsString: '' }; | ||
|
||
if (commits) { | ||
const parsedResults = filteredMessages(messagesList(messages(commits))); | ||
lintResults.resultsArray = parsedResults; | ||
lintResults.resultsString = JSON.stringify(parsedResults, null, 2); | ||
const updatedCommits = commits | ||
.trim() | ||
.replace(/\n/g, '') | ||
.replace(/\+\s/g, '\n') | ||
.replace(/\n/, '') | ||
.split(/\n/g) | ||
.filter(value => value !== '') | ||
.map(message => parseCommitMessage(message)); | ||
let filteredResults = messagesList(updatedCommits); | ||
|
||
filteredResults.forEach(obj => { | ||
const updatedObj = obj; | ||
Object.entries(updatedObj).forEach(([key, value]) => { | ||
if (value === 'valid') { | ||
delete updatedObj[key]; | ||
} | ||
}); | ||
}); | ||
|
||
filteredResults = filteredResults.filter(({ hash, commit, ...rest }) => Object.keys(rest).length > 0); | ||
lintResults.resultsArray = filteredResults; | ||
lintResults.resultsString = JSON.stringify(filteredResults, null, 2); | ||
} | ||
|
||
return lintResults; | ||
}; | ||
|
||
module.exports = actionCommitCheck; |