Skip to content

Commit

Permalink
feat(format): add support for personalised reports
Browse files Browse the repository at this point in the history
  • Loading branch information
JamieMason committed Aug 3, 2019
1 parent 3b46a34 commit ae941c1
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 66 deletions.
100 changes: 76 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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. "<guybrush@threepwood.grog>"
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
Expand Down
187 changes: 148 additions & 39 deletions src/formatters/git-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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',
Expand All @@ -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 = <T>(path: string) =>
getIn<T>(path, config) || (getIn<T>(path, defaultConfig) as T);

const emailRegExp = getIn<RegExp | undefined>('emailRegExp', config);
const gutter = getConfig<string>('gutter');
const locationColumnWidth = getConfig<number>('locationColumnWidth');
const errorLabel = getConfig<string>('label.error');
const warningLabel = getConfig<string>('label.warning');
const styledError = getConfig<typeof chalk>('style.error');
const styledFilePath = getConfig<typeof chalk>('style.filePath');
const styledWarning = getConfig<typeof chalk>('style.warning');
const styledLocation = getConfig<typeof chalk>('style.location');
const styledRule = getConfig<typeof chalk>('style.rule');
const styledCommit = getConfig<typeof chalk>('style.commit');
const styledDate = getConfig<typeof chalk>('style.date');
const styledEmail = getConfig<typeof chalk>('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<string>(
(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;
Expand Down
6 changes: 3 additions & 3 deletions src/formatters/lib/get-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(
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;
};
Binary file added static/screenshot-when-filtered.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit ae941c1

Please sign in to comment.