Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve autofixing #844

Merged
merged 2 commits into from
Apr 16, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/six-worms-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'skuba': patch
---

lint: Detect and autofix ESLint warnings
5 changes: 5 additions & 0 deletions .changeset/tasty-steaks-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'skuba': patch
---

lint: Skip autofixing when ESLint reports no fixable issues
10 changes: 8 additions & 2 deletions src/cli/adapter/eslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ export interface ESLintResult {

export interface ESLintOutput {
errors: ESLintResult[];
warnings: ESLintResult[];
fixable: boolean;
ok: boolean;
output: string;
warnings: ESLintResult[];
}

export const runESLint = async (
Expand Down Expand Up @@ -81,9 +82,14 @@ export const runESLint = async (

const errors: ESLintResult[] = [];
const warnings: ESLintResult[] = [];
let fixable = false;

for (const result of results) {
const relativePath = path.relative(cwd, result.filePath);
if (result.fixableErrorCount + result.fixableWarningCount) {
fixable = true;
}

if (result.errorCount) {
errors.push({
filePath: relativePath,
Expand Down Expand Up @@ -111,5 +117,5 @@ export const runESLint = async (
logger.plain(output);
}

return { ok, output, errors, warnings };
return { errors, fixable, ok, output, warnings };
};
4 changes: 4 additions & 0 deletions src/cli/lint/annotate/github/eslint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ it('should create failure annotations for ESLint errors', () => {
],
},
],
fixable: false,
ok: false,
output: '',
warnings: [],
Expand All @@ -49,6 +50,7 @@ it('should create failure annotations for ESLint errors', () => {
it('should create warning annotations for ESLint warnings', () => {
const eslintOutput: ESLintOutput = {
errors: [],
fixable: false,
ok: true,
output: '',
warnings: [
Expand Down Expand Up @@ -110,6 +112,7 @@ it('should create both failure and warning annotations for ESLint errors and war
],
},
],
fixable: false,
ok: false,
output: '',
warnings: [
Expand Down Expand Up @@ -181,6 +184,7 @@ it('should not specify columns when an annotation spans multiple lines', () => {
],
},
],
fixable: false,
ok: false,
output: '',
warnings: [],
Expand Down
1 change: 1 addition & 0 deletions src/cli/lint/annotate/github/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const eslintOutput: ESLintOutput = {
],
},
],
fixable: false,
ok: false,
output: '',
warnings: [],
Expand Down
92 changes: 78 additions & 14 deletions src/cli/lint/autofix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ const stdout = () => {
return `\n${result}`;
};

const expectAutofixCommit = () => {
expect(runESLint).toHaveBeenCalledTimes(1);
const expectAutofixCommit = (
{ eslint }: { eslint: boolean } = { eslint: true },
) => {
expect(runESLint).toHaveBeenCalledTimes(eslint ? 1 : 0);
expect(runPrettier).toHaveBeenCalledTimes(1);
expect(Git.commitAllChanges).toHaveBeenCalledTimes(1);
};
Expand All @@ -51,26 +53,28 @@ beforeEach(() => {
afterEach(jest.resetAllMocks);

describe('autofix', () => {
const params = { debug: false, eslint: true, prettier: true };

it('bails on a non-CI environment', async () => {
delete process.env.CI;

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});

it('bails on the master branch', async () => {
jest.mocked(Git.currentBranch).mockResolvedValue('master');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});

it('bails on the main branch', async () => {
jest.mocked(Git.currentBranch).mockResolvedValue('main');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});
Expand All @@ -80,7 +84,7 @@ describe('autofix', () => {

jest.mocked(Git.currentBranch).mockResolvedValue('devel');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});
Expand All @@ -90,7 +94,7 @@ describe('autofix', () => {

jest.mocked(Git.currentBranch).mockResolvedValue('beta');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});
Expand All @@ -101,15 +105,23 @@ describe('autofix', () => {
.mocked(Git.getHeadCommitMessage)
.mockResolvedValue('Run `skuba format`');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectNoAutofix();
});

it('bails on no fixable issues', async () => {
await expect(
autofix({ ...params, eslint: false, prettier: false }),
).resolves.toBeUndefined();

expectNoAutofix();
});

it('skips push on empty commit', async () => {
jest.mocked(Git.commitAllChanges).mockResolvedValue(undefined);

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).not.toHaveBeenCalled();
Expand All @@ -131,7 +143,7 @@ describe('autofix', () => {

jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();

Expand All @@ -151,7 +163,7 @@ describe('autofix', () => {
jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');
jest.mocked(Git.currentBranch).mockResolvedValue('dev');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).toHaveBeenNthCalledWith(1, {
Expand All @@ -170,6 +182,58 @@ describe('autofix', () => {
`);
});

it('handles fixable issues from ESLint only', async () => {
jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');
jest.mocked(Git.currentBranch).mockResolvedValue('dev');

await expect(
autofix({ ...params, eslint: true, prettier: false }),
).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).toHaveBeenNthCalledWith(1, {
auth: { type: 'gitHubApp' },
dir: expect.any(String),
ref: 'commit-sha',
remoteRef: 'dev',
});

// We should run both ESLint and Prettier
expect(stdout()).toMatchInlineSnapshot(`
"

Trying to autofix with ESLint and Prettier...
Pushed fix commit commit-sha.
"
`);
});

it('handles fixable issues from Prettier only', async () => {
jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');
jest.mocked(Git.currentBranch).mockResolvedValue('dev');

await expect(
autofix({ ...params, eslint: false, prettier: true }),
).resolves.toBeUndefined();

expectAutofixCommit({ eslint: false });
expect(Git.push).toHaveBeenNthCalledWith(1, {
auth: { type: 'gitHubApp' },
dir: expect.any(String),
ref: 'commit-sha',
remoteRef: 'dev',
});

// We should only run Prettier
expect(stdout()).toMatchInlineSnapshot(`
"

Trying to autofix with Prettier...
Pushed fix commit commit-sha.
"
`);
});

it('tolerates guard errors', async () => {
const ERROR = new Error('badness!');

Expand All @@ -178,7 +242,7 @@ describe('autofix', () => {

jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).toHaveBeenNthCalledWith(1, {
Expand All @@ -199,7 +263,7 @@ describe('autofix', () => {
it('bails on commit error', async () => {
jest.mocked(Git.commitAllChanges).mockRejectedValue(MOCK_ERROR);

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).not.toHaveBeenCalled();
Expand All @@ -219,7 +283,7 @@ describe('autofix', () => {
jest.mocked(Git.commitAllChanges).mockResolvedValue('commit-sha');
jest.mocked(Git.push).mockRejectedValue(MOCK_ERROR);

await expect(autofix({ debug: false })).resolves.toBeUndefined();
await expect(autofix(params)).resolves.toBeUndefined();

expectAutofixCommit();
expect(Git.push).toHaveBeenNthCalledWith(1, {
Expand Down
26 changes: 21 additions & 5 deletions src/cli/lint/autofix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ const shouldPush = async ({
return true;
};

export const autofix = async (input: Pick<Input, 'debug'>): Promise<void> => {
interface AutofixParameters {
debug: Input['debug'];

eslint: boolean;
prettier: boolean;
}

export const autofix = async (params: AutofixParameters): Promise<void> => {
if (!params.eslint && !params.prettier) {
return;
}

const dir = process.cwd();

let currentBranch;
Expand All @@ -68,14 +79,19 @@ export const autofix = async (input: Pick<Input, 'debug'>): Promise<void> => {
return;
}

// Naively try to autofix issues as we can't tell from ESLint output.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brain must've 404ed when I wrote this

try {
log.newline();
log.warn(`Trying to autofix with ESLint and Prettier...`);
log.warn(
`Trying to autofix with ${params.eslint ? 'ESLint and ' : ''}Prettier...`,
);

const logger = createLogger(input.debug);
const logger = createLogger(params.debug);

await runESLint('format', logger);
if (params.eslint) {
await runESLint('format', logger);
}
// Unconditionally re-run Prettier; reaching here means we have pre-existing
// format violations or may have created new ones through ESLint fixes.
await runPrettier('format', logger);

const ref = await Git.commitAllChanges({
Expand Down
31 changes: 14 additions & 17 deletions src/cli/lint/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,22 @@ export const externalLint = async (input: Input) => {
log.subtle(inspect(err));
}

if (eslint.ok && prettier.ok && tscOk) {
return;
}

const tools = [
...(eslint.ok ? [] : ['ESLint']),
...(prettier.ok ? [] : ['Prettier']),
...(tscOk ? [] : ['tsc']),
];

log.newline();
log.err(`${tools.join(', ')} found issues that require triage.`);
if (!eslint.ok || !prettier.ok || !tscOk) {
const tools = [
...(eslint.ok ? [] : ['ESLint']),
...(prettier.ok ? [] : ['Prettier']),
...(tscOk ? [] : ['tsc']),
];

process.exitCode = 1;
log.newline();
log.err(`${tools.join(', ')} found issues that require triage.`);

if (eslint.ok && prettier.ok) {
// If these are fine then the issue lies with tsc, which we can't autofix.
return;
process.exitCode = 1;
}

await autofix(input);
await autofix({
debug: input.debug,
eslint: eslint.fixable,
prettier: !prettier.ok,
});
};