diff --git a/README.md b/README.md index 677f2b5..4e702d2 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,6 @@ [![Follow JamieMason on GitHub](https://img.shields.io/github/followers/JamieMason.svg?style=social&label=Follow)](https://github.com/JamieMason) [![Follow fold_left on Twitter](https://img.shields.io/twitter/follow/fold_left.svg?style=social&label=Follow)](https://twitter.com/fold_left) -## 🎨 Screenshot - -![screenshot](static/screenshot.png) - ## ☁️ Installation ``` @@ -26,49 +22,105 @@ npm install --save-dev eslint eslint-formatter-git-log ## 🕹 Usage +To use the default configuration, set ESLint's +[`--format`](https://eslint.org/docs/user-guide/command-line-interface#-f---format) +option to `git-log` as follows: + ``` -eslint --format 'git-log' file.js +eslint --format git-log './src/**/*.js' ``` -## ⚖️ Configuration +## Examples + +### Full Report + +By default the formatter will display a report of every error or warning in the +codebase: + +![screenshot](static/screenshot.png) + +### Personalised Reports + +If you work with a lot of Developers in a large Organisation or Codebase — +introducing new rules is a great way to codify conventions and ensure quality at +scale. However, when a new rule applies to a particularly prevalent coding +pattern, it can result in every Developer being presented with a huge list of +warnings. + +Rather than disabling these rules completely, an alternative can be to only +present each Developer with Errors and Warnings that relate to changes they +themselves have made. + +1. Create a file in your project which follows the structure below. + + ```js + const gitLogFormatter = require('eslint-formatter-git-log'); + + module.exports = gitLogFormatter.withConfig({ + emailRegExp: new RegExp(process.env.GIT_COMMITTER_EMAIL), + }); + ``` + + In this example we require that the + [Git Environment Variable](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables) + `GIT_COMMITTER_EMAIL` is exported and reachable but, since this is an + ordinary Node.js Script, the Email Address can be retrieved from the current + Contributor's Machine in any way you prefer. + +2. Set ESLint's + [`--format`](https://eslint.org/docs/user-guide/command-line-interface#-f---format) + option to your customised version instead of `git-log`: + + ``` + eslint --format ./path/to/your/custom-formatter.js './src/**/*.js' + ``` + +![screenshot](static/screenshot-when-filtered.png) -This formatter is written to be as customisable as possible. To create a -customised version of the formatter you can create a file somewhere in your -project which follows the structure below. +## ⚖️ Configuration -For this example I am using the default values. You do not need to provide a -value for every configuration item, only those you want to change. +This example lists every available option with its corresponding default value. +You don't need to provide a value for every configuration item, just the ones +you want to change. ```js -const gitLogFormatter = require('eslint-formatter-git-log'); const chalk = require('chalk'); +const gitLogFormatter = require('eslint-formatter-git-log'); module.exports = gitLogFormatter.withConfig({ + // If set, only show result when Author Email matches this pattern + emailRegExp: undefined, + // Whitespace to insert between items when formatting + gutter: ' ', + // Translations for plain text used when formatting + label: { + error: 'error', + warning: 'warning', + }, + // Increase if you have files with 1000s of lines + locationColumnWidth: 8, + // Which methods of https://github.com/chalk/chalk to use when formatting style: { + // eg. "error" error: chalk.red, + // eg. "/Users/guybrush/Dev/grogrates/src/index.js" filePath: chalk.underline, + // eg. "warning" warning: chalk.yellow, + // eg. "161:12" location: chalk.dim, + // eg. "no-process-exit" rule: chalk.dim, + // eg. "bda304e570" commit: chalk.magenta, + // eg. "(1 year, 2 months ago)" date: chalk.greenBright, + // eg. "" email: chalk.blueBright, }, - gutter: ' ', - label: { - error: 'error', - warning: 'warning', - }, - locationColumnWidth: 8, }); ``` -Then point at your custom formatter instead of the default like so: - -``` -eslint --format './path-to-your-custom-formatter.js' file.js -``` - ## ❓ Getting Help - Get help with issues by creating a diff --git a/src/formatters/git-log.ts b/src/formatters/git-log.ts index c94b760..e6e6b8c 100644 --- a/src/formatters/git-log.ts +++ b/src/formatters/git-log.ts @@ -33,7 +33,17 @@ interface EslintResult { source?: string; } +interface ResultItem { + commit: string; + date: string; + email: string; + message: EslintMessage; + result: EslintResult; +} + interface FinalConfig { + /** If set, show only results for Emails matching this pattern */ + emailRegExp?: RegExp; /** Whitespace to insert between items when formatting */ gutter: string; /** Translations for plain text used when formatting */ @@ -77,6 +87,7 @@ export interface GitLogFormatter { export type CreateGitLogFormatter = (config: Config) => GitLogFormatter; export const defaultConfig: FinalConfig = Object.freeze({ + emailRegExp: undefined, gutter: ' ', label: { error: 'error', @@ -98,53 +109,151 @@ export const defaultConfig: FinalConfig = Object.freeze({ /** Create an instance of the Formatter with your own alternative config */ export const createGitLogFormatter: CreateGitLogFormatter = (config) => { const formatter = (results: EslintResult[]) => { - const getConfig = (path: string) => - getIn(path, config) || getIn(path, defaultConfig); - - const gutter = getConfig('gutter'); - const locationColumnWidth = getConfig('locationColumnWidth'); - const errorLabel = getConfig('label.error'); - const warningLabel = getConfig('label.warning'); - const styledError = getConfig('style.error'); - const styledFilePath = getConfig('style.filePath'); - const styledWarning = getConfig('style.warning'); - const styledLocation = getConfig('style.location'); - const styledRule = getConfig('style.rule'); - const styledCommit = getConfig('style.commit'); - const styledDate = getConfig('style.date'); - const styledEmail = getConfig('style.email'); + const getConfig = (path: string) => + getIn(path, config) || (getIn(path, defaultConfig) as T); + + const emailRegExp = getIn('emailRegExp', config); + const gutter = getConfig('gutter'); + const locationColumnWidth = getConfig('locationColumnWidth'); + const errorLabel = getConfig('label.error'); + const warningLabel = getConfig('label.warning'); + const styledError = getConfig('style.error'); + const styledFilePath = getConfig('style.filePath'); + const styledWarning = getConfig('style.warning'); + const styledLocation = getConfig('style.location'); + const styledRule = getConfig('style.rule'); + const styledCommit = getConfig('style.commit'); + const styledDate = getConfig('style.date'); + const styledEmail = getConfig('style.email'); const WARNING = styledWarning(warningLabel); const ERROR = styledError(errorLabel); - return results.reduce((output, { filePath, messages }) => { - if (messages.length > 0) { - output += `\n${styledFilePath(filePath)}\n`; - messages.forEach(({ ruleId, severity, message, line, column }) => { - const command = `git blame --date=relative --show-email -L ${line},${line} -- "${filePath}"`; - const blame = execSync(command, { encoding: 'utf8' }); - const rawLocation = `${line}:${column}`; - const status = severity === 1 ? WARNING : ERROR; - const commitMatch = blame.match(/^[^ ]+/) || ['']; - const dateMatch = blame.match(/> (.+ ago)/) || ['', '']; - const emailMatch = blame.match(/<([^>]+)>/) || ['', '']; - const rightAlignLocations = ' '.repeat( - locationColumnWidth - rawLocation.length, - ); - const leftAlignCommitsWithStatuses = ' '.repeat( - rightAlignLocations.length + rawLocation.length + gutter.length, + const mergeMessageWith = (result: EslintResult) => ( + message: EslintMessage, + ): ResultItem => { + const { filePath } = result; + const { line } = message; + const command = `git blame --date=relative --show-email -L ${line},${line} -- "${filePath}"`; + const blame = execSync(command, { encoding: 'utf8' }); + const commitMatch = blame.match(/^[^ ]+/) || ['']; + const dateMatch = blame.match(/> (.+ ago)/) || ['', '']; + const emailMatch = blame.match(/<([^>]+)>/) || ['', '']; + const commit = commitMatch[0]; + const date = dateMatch[1].trim(); + const email = emailMatch[1]; + return { + commit, + date, + email, + message, + result, + }; + }; + + const rightAlignToCol1 = (col1Contents: string) => + ' '.repeat(locationColumnWidth - col1Contents.length); + + const leftAlignToCol2 = (col1Contents: string) => + ' '.repeat( + rightAlignToCol1(col1Contents).length + + col1Contents.length + + gutter.length, + ); + + const cols = (col1: string | number, col2: string | number) => + `${rightAlignToCol1(`${col1}`)}${col1}${gutter}${col2}`; + + const formatMessage = ({ + commit: rawCommit, + date: rawDate, + email: rawEmail, + message: { ruleId, severity, message, line, column }, + }: ResultItem) => { + const rawLocation = `${line}:${column}`; + const status = severity === 1 ? WARNING : ERROR; + const headIndent = rightAlignToCol1(rawLocation); + const footIndent = leftAlignToCol2(rawLocation); + const location = styledLocation(rawLocation); + const rule = ruleId ? styledRule(ruleId) : ''; + const commit = styledCommit(`${rawCommit}`); + const date = styledDate(`(${rawDate})`); + const email = styledEmail(`<${rawEmail}>`); + let output = ''; + output += `${headIndent}${location}${gutter}${status}${gutter}${message}${gutter}${rule}\n`; + output += `${footIndent}${commit} ${email} ${date}\n`; + return output; + }; + + const isIncluded = ({ email }: ResultItem) => + emailRegExp ? emailRegExp.test(email) : true; + + const authors = new Set(); + const total = { + all: 0, + errors: 0, + userErrors: 0, + userWarnings: 0, + visible: 0, + warnings: 0, + }; + + const body = results.reduce((output, result) => { + if (result.messages.length > 0) { + const items = result.messages + .map(mergeMessageWith(result)) + .map((item) => { + authors.add(item.email); + return item; + }) + .filter(isIncluded) + .map((item) => { + if (item.message.severity === 2) { + total.userErrors++; + } else if (item.message.severity === 1) { + total.userWarnings++; + } + return item; + }); + + total.errors += result.errorCount; + total.warnings += result.warningCount; + total.all += result.messages.length; + total.visible += items.length; + + if (items.length > 0) { + output += `\n${styledFilePath(result.filePath)}\n`; + output += items.reduce( + (str: string, item: ResultItem) => `${str}${formatMessage(item)}`, + '', ); - const location = styledLocation(rawLocation); - const rule = ruleId ? styledRule(ruleId) : ''; - const commit = styledCommit(`${commitMatch[0]}`); - const date = styledDate(`(${dateMatch[1].trim()})`); - const email = styledEmail(`<${emailMatch[1]}>`); - output += `${rightAlignLocations}${location}${gutter}${status}${gutter}${message}${gutter}${rule}\n`; - output += `${leftAlignCommitsWithStatuses}${commit} ${email} ${date}\n`; - }); + } } return output; }, ''); + + const banner = chalk.inverse(' REPORT COMPLETE '); + + let footer = ''; + + if (emailRegExp) { + const totalWarnings = `${total.userWarnings}/${total.warnings}`; + const totalErrors = `${total.userErrors}/${total.errors}`; + const totalWarningsLabel = `Warnings assigned to ${emailRegExp}`; + const totalErrorsLabel = `Errors assigned to ${emailRegExp}`; + footer += `${cols(results.length, 'Files')}\n`; + footer += `${cols(totalWarnings, totalWarningsLabel)}\n`; + footer += `${cols(totalErrors, totalErrorsLabel)}\n`; + footer += `${cols(authors.size, 'Assignees')}\n`; + } else { + footer += `${cols(results.length, 'Files')}\n`; + footer += `${cols(total.warnings, 'Warnings')}\n`; + footer += `${cols(total.errors, 'Errors')}\n`; + footer += `${cols(authors.size, 'Assignees')}\n`; + } + + return `${body}\n${banner}\n\n${footer}`; }; + formatter.defaultConfig = defaultConfig; formatter.withConfig = createGitLogFormatter; return formatter; diff --git a/src/formatters/lib/get-in.ts b/src/formatters/lib/get-in.ts index 925ef4f..f61b381 100644 --- a/src/formatters/lib/get-in.ts +++ b/src/formatters/lib/get-in.ts @@ -4,11 +4,11 @@ const isWalkable = (value: any) => const getChild = (parent: any, child: any): any => isWalkable(parent) ? parent[child] : undefined; -export const getIn = ( +export const getIn = ( pathToValue: string | number, owner?: any, - defaultValue?: any, + defaultValue?: T, ) => { const value = `${pathToValue}`.split('.').reduce(getChild, owner); - return value !== undefined ? value : defaultValue; + return value !== undefined ? (value as T) : defaultValue; }; diff --git a/static/screenshot-when-filtered.png b/static/screenshot-when-filtered.png new file mode 100644 index 0000000..f692ec7 Binary files /dev/null and b/static/screenshot-when-filtered.png differ diff --git a/static/screenshot.png b/static/screenshot.png index a4f1c7e..63969a7 100644 Binary files a/static/screenshot.png and b/static/screenshot.png differ