├── .eslintignore ├── src ├── __tests__ │ ├── eslintReport-empty.json │ ├── eslintReport-unused-eslint-disable-directive.json │ ├── getPullRequestFiles.test.ts │ ├── eslintReport-unused-eslint-disable-directive-analyzed.ts │ ├── eslintJsonReportToJs.test.ts │ ├── getAnalyzedReport.test.ts │ ├── eslintReport-3-errors-analyzed.ts │ ├── eslintReport-1-error-analyzed.ts │ ├── eslintReport-3-errors.ts │ ├── eslintReport-1-error.json │ ├── eslintReport-1-error.ts │ └── eslintReport-3-errors.json ├── addSummary.ts ├── createStatusCheck.ts ├── updateStatusCheck.ts ├── getPullRequestFiles.ts ├── openStatusCheck.ts ├── eslintJsonReportToJs.ts ├── closeStatusCheck.ts ├── getPullRequestChangedAnalyzedReport.ts ├── types.d.ts ├── addAnnotationsToStatusCheck.ts ├── index.ts ├── constants.ts └── getAnalyzedReport.ts ├── .prettierrc ├── assets ├── eslint-annotate-action-pr-error-example.png └── eslint-annotate-action-push-report-example.png ├── jest.setup.ts ├── CHANGELOG.md ├── jest.config.ts ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── nodejs.yml ├── action.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !/.github -------------------------------------------------------------------------------- /src/__tests__/eslintReport-empty.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "parser": "typescript", 4 | "trailingComma": "es5", 5 | "singleQuote": true, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /assets/eslint-annotate-action-pr-error-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataylorme/eslint-annotate-action/HEAD/assets/eslint-annotate-action-pr-error-example.png -------------------------------------------------------------------------------- /assets/eslint-annotate-action-push-report-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataylorme/eslint-annotate-action/HEAD/assets/eslint-annotate-action-push-report-example.png -------------------------------------------------------------------------------- /src/addSummary.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | /** 4 | * Add to job summary 5 | */ 6 | export default async function addSummary(summary: string): Promise { 7 | core.summary.addRaw(summary) 8 | await core.summary.write() 9 | } 10 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | let originalEnv: NodeJS.ProcessEnv 2 | 3 | beforeAll(() => { 4 | // Store the original environment 5 | originalEnv = {...process.env} 6 | }) 7 | 8 | // beforeEach(() => {}) 9 | 10 | afterAll(() => { 11 | process.env = originalEnv 12 | }) 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `3.0.0` - CONTAINS BREAKING CHANGES 2 | 3 | - Rename the `repo-token` input to `GITHUB_TOKEN` 4 | - Run the Action itself on Node 20 instead of Node 16 5 | - Truncate summary if too long 6 | - Only add changed file to markdown summary if only changed files is true 7 | - Use `@octokit/action` instead of `actions-toolkit` 8 | - Use ESLint types from `@types/eslint` instead of custom types 9 | - Default line to 1 if it's not present -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from 'jest' 2 | 3 | const config: Config = { 4 | clearMocks: true, 5 | moduleFileExtensions: ['js', 'ts'], 6 | testEnvironment: 'node', 7 | testMatch: ['**/*.test.ts'], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest', 10 | }, 11 | transformIgnorePatterns: ['^.+\\.js$'], 12 | verbose: true, 13 | setupFilesAfterEnv: ['./jest.setup.ts'], 14 | } 15 | 16 | export default config 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022"], 4 | "module": "commonjs", 5 | "target": "ES2022", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitAny": true, 12 | "removeComments": false, 13 | "preserveConstEnums": true, 14 | "outDir": "./lib", 15 | "rootDir": "./src", 16 | "moduleResolution": "node" 17 | }, 18 | "include": [".github/actions/**/*.ts", "src/**/*.ts"], 19 | "exclude": ["node_modules"] 20 | } -------------------------------------------------------------------------------- /src/createStatusCheck.ts: -------------------------------------------------------------------------------- 1 | import constants from './constants' 2 | const {octokit} = constants 3 | import type {checkCreateParametersType, createCheckRunResponseDataType} from './types' 4 | 5 | /** 6 | * Create a new GitHub check run 7 | * @param options octokit.checks.create parameters 8 | */ 9 | export default async function createStatusCheck( 10 | options: checkCreateParametersType, 11 | ): Promise { 12 | try { 13 | // https://developer.github.com/v3/checks/runs/#create-a-check-run 14 | // https://octokit.github.io/rest.js/v16#checks-create 15 | const response = await octokit.checks.create(options) 16 | return Promise.resolve(response.data) 17 | } catch (error) { 18 | return Promise.reject(error) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/updateStatusCheck.ts: -------------------------------------------------------------------------------- 1 | import constants from './constants' 2 | const {octokit} = constants 3 | import type {checkUpdateParametersType, updateCheckRunResponseDataType} from './types' 4 | 5 | /** 6 | * Update a GitHub check run 7 | * @param options the parameter for octokit.checks.update 8 | */ 9 | export default async function updateStatusCheck( 10 | options: checkUpdateParametersType, 11 | ): Promise { 12 | try { 13 | // https://developer.github.com/v3/checks/runs/#update-a-check-run 14 | // https://octokit.github.io/rest.js/v18#checks-update 15 | const response = await octokit.checks.update(options) 16 | return Promise.resolve(response.data) 17 | } catch (error) { 18 | return Promise.reject(error) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-unused-eslint-disable-directive.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "filePath": "src/index.ts", 4 | "messages": [ 5 | { 6 | "ruleId": null, 7 | "message": "Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').", 8 | "line": 10, 9 | "column": 3, 10 | "severity": 2, 11 | "nodeType": null, 12 | "fix": { 13 | "range": [ 14 | 319, 15 | 380 16 | ], 17 | "text": " " 18 | } 19 | } 20 | ], 21 | "suppressedMessages": [], 22 | "errorCount": 1, 23 | "fatalErrorCount": 0, 24 | "warningCount": 0, 25 | "fixableErrorCount": 1, 26 | "fixableWarningCount": 0, 27 | "source": "src/index.ts", 28 | "usedDeprecatedRules": [] 29 | } 30 | ] -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:prettier/recommended" 8 | ], 9 | "rules": { 10 | "prettier/prettier": [ 11 | "error", 12 | { 13 | "singleQuote": true, 14 | "trailingComma": "all", 15 | "bracketSpacing": false, 16 | "printWidth": 120, 17 | "tabWidth": 2, 18 | "semi": false 19 | } 20 | ], 21 | // octokit/rest requires parameters that are not in camelCase 22 | "camelcase": "off" 23 | }, 24 | "env": { 25 | "node": true, 26 | "jest": true, 27 | "es6": true 28 | }, 29 | "parserOptions": { 30 | "ecmaVersion": 2018, 31 | "sourceType": "module" 32 | } 33 | } -------------------------------------------------------------------------------- /src/getPullRequestFiles.ts: -------------------------------------------------------------------------------- 1 | import type {prFilesParametersType, prFilesResponseType} from './types' 2 | import constants from './constants' 3 | const {octokit} = constants 4 | 5 | /** 6 | * Get an array of files changed in a pull request 7 | * @param options the parameters for octokit.pulls.listFiles 8 | */ 9 | export default async function getPullRequestFiles(options: prFilesParametersType): Promise { 10 | try { 11 | // https://developer.github.com/v3/pulls/#list-pull-requests-files 12 | // https://octokit.github.io/rest.js/v18#pulls-list-files 13 | // https://octokit.github.io/rest.js/v18#pagination 14 | const prFiles: prFilesResponseType['data'] = await octokit.paginate( 15 | 'GET /repos/:owner/:repo/pulls/:pull_number/files', 16 | options, 17 | ) 18 | return prFiles.map((prFiles: prFilesResponseType['data']) => prFiles.filename) 19 | } catch (error) { 20 | return Promise.reject(error) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/__tests__/getPullRequestFiles.test.ts: -------------------------------------------------------------------------------- 1 | import getPrFiles from '../getPullRequestFiles' 2 | 3 | describe('get PR files', () => { 4 | it('returns an array of PR files', async () => { 5 | const prFilesExpected = [ 6 | '.vscode/settings.json', 7 | 'action.yml', 8 | 'dist/index.js', 9 | 'jest.config.js', 10 | 'package-lock.json', 11 | 'package.json', 12 | 'src/analyze-eslint-js.ts', 13 | 'src/constants.ts', 14 | 'src/eslint-action.ts', 15 | 'src/eslint-json-report-to-js.ts', 16 | 'src/get-pr-files-changed.ts', 17 | 'src/split-array-into-chunks.ts', 18 | 'src/types.d.ts', 19 | 'tsconfig.json', 20 | ] 21 | const prFiles = await getPrFiles({owner: 'ataylorme', repo: 'eslint-annotate-action', pull_number: 3}) 22 | // https://jestjs.io/docs/en/expect#expectarraycontainingarray 23 | expect(prFiles).toEqual(expect.arrayContaining(prFilesExpected)) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/openStatusCheck.ts: -------------------------------------------------------------------------------- 1 | import createStatusCheck from './createStatusCheck' 2 | import constants from './constants' 3 | const {OWNER, REPO, SHA, getTimestamp, checkName} = constants 4 | 5 | /** 6 | * Open a new, in-progress GitHub check run 7 | * @return the check ID of the created run 8 | */ 9 | export default async function openStatusCheck(): Promise { 10 | // Create a new status check and leave it in-progress 11 | const createCheckResponse = await createStatusCheck({ 12 | owner: OWNER, 13 | repo: REPO, 14 | started_at: getTimestamp(), 15 | head_sha: SHA, 16 | status: 'in_progress', 17 | name: checkName, 18 | /** 19 | * The check run API is still in beta and the developer preview must be opted into 20 | * See https://developer.github.com/changes/2018-05-07-new-checks-api-public-beta/ 21 | */ 22 | mediaType: { 23 | previews: ['antiope'], 24 | }, 25 | }) 26 | 27 | // Return the status check ID 28 | return createCheckResponse.id as number 29 | } 30 | -------------------------------------------------------------------------------- /src/eslintJsonReportToJs.ts: -------------------------------------------------------------------------------- 1 | import * as glob from '@actions/glob' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | import type {ESLintReport} from './types' 6 | 7 | function parseReportFile(reportFile: string) { 8 | const reportPath = path.resolve(reportFile) 9 | if (!fs.existsSync(reportPath)) { 10 | throw new Error(`The report-json file "${reportFile}" could not be resolved.`) 11 | } 12 | 13 | const reportContents = fs.readFileSync(reportPath, 'utf-8') 14 | let reportParsed: ESLintReport 15 | 16 | try { 17 | reportParsed = JSON.parse(reportContents) 18 | } catch (error) { 19 | throw new Error(`Error parsing the report-json file "${reportFile}".`) 20 | } 21 | 22 | return reportParsed 23 | } 24 | 25 | /** 26 | * Converts an ESLint report JSON file to an array of JavaScript objects 27 | * @param reportFile path to an ESLint JSON file 28 | */ 29 | export default async function eslintJsonReportToJs(reportFile: string): Promise { 30 | const globber = await glob.create(reportFile) 31 | const files = await globber.glob() 32 | 33 | return files.map(parseReportFile).flat() 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2022 Andrew Taylor and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/__tests__/eslintReport-unused-eslint-disable-directive-analyzed.ts: -------------------------------------------------------------------------------- 1 | import {AnalyzedESLintReport} from '../types' 2 | 3 | /* eslint-disable */ 4 | 5 | const reportAnalyzedExpected: AnalyzedESLintReport = { 6 | errorCount: 1, 7 | warningCount: 0, 8 | markdown: '## 1 Error(s):\n' + 9 | '### [`src/index.ts` line `10`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/index.ts#L10:L10)\n' + 10 | '- Start Line: `10`\n' + 11 | '- End Line: `10`\n' + 12 | "- Message: Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').\n" + 13 | ' - From: [`null`]\n' + 14 | '\n', 15 | success: false, 16 | summary: '1 ESLint error(s) and 0 ESLint warning(s) found', 17 | annotations: [ 18 | { 19 | path: 'src/index.ts', 20 | start_line: 10, 21 | end_line: 10, 22 | annotation_level: 'failure', 23 | message: "[null] Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').", 24 | start_column: 3, 25 | end_column: 3 26 | } 27 | ] 28 | } 29 | 30 | /* eslint-enable */ 31 | 32 | export default reportAnalyzedExpected 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Typescript 2 | lib/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # VSCode 67 | /.vscode 68 | -------------------------------------------------------------------------------- /src/closeStatusCheck.ts: -------------------------------------------------------------------------------- 1 | import updateStatusCheck from './updateStatusCheck' 2 | import constants from './constants' 3 | const {OWNER, REPO, getTimestamp, checkName} = constants 4 | import type {checkUpdateParametersType} from './types' 5 | 6 | /** 7 | * 8 | * @param conclusion whether or not the status check was successful. Must be one of: success, failure, neutral, cancelled, skipped, timed_out, or action_required. 9 | * @param checkId the ID of the check run to close 10 | * @param summary a markdown summary of the check run results 11 | */ 12 | export default async function closeStatusCheck( 13 | conclusion: checkUpdateParametersType['conclusion'], 14 | checkId: checkUpdateParametersType['check_run_id'], 15 | summary: string, 16 | text: string, 17 | ): Promise { 18 | await updateStatusCheck({ 19 | conclusion, 20 | owner: OWNER, 21 | repo: REPO, 22 | completed_at: getTimestamp(), 23 | status: 'completed', 24 | check_run_id: checkId, 25 | output: { 26 | title: checkName, 27 | summary: summary, 28 | text: text, 29 | }, 30 | /** 31 | * The check run API is still in beta and the developer preview must be opted into 32 | * See https://developer.github.com/changes/2018-05-07-new-checks-api-public-beta/ 33 | */ 34 | mediaType: { 35 | previews: ['antiope'], 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - v2 8 | - v3 9 | pull_request: 10 | 11 | jobs: 12 | node_test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup NodeJS 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | cache: npm 22 | - name: Install Node Dependencies 23 | run: npm ci 24 | env: 25 | CI: TRUE 26 | - name: Save Code Linting Report JSON 27 | # npm script for ESLint 28 | # eslint --output-file eslint_report.json --format json src 29 | # See https://eslint.org/docs/user-guide/command-line-interface#options 30 | run: npm run lint:report 31 | continue-on-error: true 32 | - name: Annotate Code Linting Results 33 | uses: ./ 34 | with: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | report-json: "eslint_report.json" 37 | - name: Upload ESLint report 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: eslint_report.json 41 | path: eslint_report.json 42 | retention-days: 7 43 | - name: Test That The Project Builds 44 | run: npm run build 45 | - name: Run Unit Tests 46 | run: npm run test -------------------------------------------------------------------------------- /src/__tests__/eslintJsonReportToJs.test.ts: -------------------------------------------------------------------------------- 1 | import eslintJsonReportToJs from '../eslintJsonReportToJs' 2 | import reportJSExpected from './eslintReport-3-errors' 3 | import indentReportJSExpected from './eslintReport-1-error' 4 | const cwd = process.cwd() 5 | 6 | describe('ESLint report JSON to JS', () => { 7 | it('Converts a standard ESLint JSON file to a JS object', async () => { 8 | const testReportPath = `${cwd}/src/__tests__/eslintReport-3-errors.json` 9 | const reportJS = await eslintJsonReportToJs(testReportPath) 10 | expect(reportJS).toEqual(reportJSExpected) 11 | }) 12 | 13 | it('Converts an ESLint JSON file with indentation errors to a JS object', async () => { 14 | const testReportPath = `${cwd}/src/__tests__/eslintReport-1-error.json` 15 | const reportJS = await eslintJsonReportToJs(testReportPath) 16 | expect(reportJS).toEqual(indentReportJSExpected) 17 | }) 18 | 19 | it('Supports glob paths', async () => { 20 | const testReportPath = `${cwd}/src/__tests__/eslintReport-*-error*.json` 21 | const reportJS = await eslintJsonReportToJs(testReportPath) 22 | expect(reportJS).toEqual([...indentReportJSExpected, ...reportJSExpected]) 23 | }) 24 | 25 | it('Throws an error when the report is empty', () => { 26 | const testReportPath = `${cwd}/src/__tests__/eslintReport-empty.json` 27 | expect(() => eslintJsonReportToJs(testReportPath)).rejects.toThrowError() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "ESLint Annotate from Report JSON" 2 | description: "Annotates pull request diffs with warnings and errors from an ESLint report JSON file." 3 | inputs: 4 | GITHUB_TOKEN: 5 | description: "The 'GITHUB_TOKEN' secret" 6 | default: ${{ github.token }} 7 | required: true 8 | report-json: 9 | description: "Path or glob pattern to locate the ESLint report JSON file" 10 | default: "eslint_report.json" 11 | required: false 12 | only-pr-files: 13 | description: "Only annotate files changed when run on the 'pull_request' event" 14 | default: 'true' 15 | required: false 16 | fail-on-warning: 17 | description: "Fail the GitHub Action when ESLint warnings are detected. Set to 'true' to enable." 18 | default: 'false' 19 | required: false 20 | fail-on-error: 21 | description: "Whether to fail the Github action when ESLint errors are detected. If set to false, the check that is created will still fail on ESLint errors." 22 | default: 'true' 23 | required: false 24 | check-name: 25 | description: "The name of the GitHub status check created." 26 | default: 'ESLint Report Analysis' 27 | required: false 28 | markdown-report-on-step-summary: 29 | description: "Whether to show a markdown report in the step summary." 30 | default: 'false' 31 | required: false 32 | runs: 33 | using: "node20" 34 | main: "dist/index.js" 35 | branding: 36 | icon: "check-circle" 37 | color: "yellow" 38 | -------------------------------------------------------------------------------- /src/__tests__/getAnalyzedReport.test.ts: -------------------------------------------------------------------------------- 1 | import getAnalyzedReport from '../getAnalyzedReport' 2 | import eslintJsonReportToJs from '../eslintJsonReportToJs' 3 | import reportAnalyzedExpected from './eslintReport-3-errors-analyzed' 4 | import indentationReportAnalyzedExpected from './eslintReport-1-error-analyzed' 5 | import unusedDisabledDirectiveAnalyzedExpected from './eslintReport-unused-eslint-disable-directive-analyzed' 6 | 7 | const cwd = process.cwd() 8 | 9 | describe('ESLint report JSON to Analyzed report', () => { 10 | it('Converts a standard ESLint JSON file to an analyzed report', async () => { 11 | const testReportPath = `${cwd}/src/__tests__/eslintReport-3-errors.json` 12 | const reportJS = await eslintJsonReportToJs(testReportPath) 13 | const analyzedReport = getAnalyzedReport(reportJS) 14 | expect(analyzedReport).toEqual(reportAnalyzedExpected) 15 | }) 16 | 17 | it('Converts an ESLint JSON file with indentation errors to an analyzed report', async () => { 18 | const testReportPath = `${cwd}/src/__tests__/eslintReport-1-error.json` 19 | const reportJS = await eslintJsonReportToJs(testReportPath) 20 | const analyzedReport = getAnalyzedReport(reportJS) 21 | expect(analyzedReport).toEqual(indentationReportAnalyzedExpected) 22 | }) 23 | 24 | it('Converts an ESLint JSON file with --report-unused-disable-directives', async () => { 25 | const testReportPath = `${cwd}/src/__tests__/eslintReport-unused-eslint-disable-directive.json` 26 | const reportJS = await eslintJsonReportToJs(testReportPath) 27 | const analyzedReport = getAnalyzedReport(reportJS) 28 | expect(analyzedReport).toEqual(unusedDisabledDirectiveAnalyzedExpected) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-annotate-action", 3 | "version": "3.0.0", 4 | "description": "A GitHub action that takes ESLint results from a JSON file and adds them as annotated pull request comments", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "ncc build src/index.ts", 9 | "prestart": "npm run build", 10 | "start": "node dist/index.js", 11 | "test": "GITHUB_ACTION=1 INPUT_GITHUB_TOKEN='secret123' jest", 12 | "lint": "eslint --ext .ts src", 13 | "lint:fix": "eslint --fix --ext .ts src", 14 | "lint:report": "eslint --ext .ts --output-file eslint_report.json --format json src" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/ataylorme/eslint-annotate-action.git" 19 | }, 20 | "keywords": [], 21 | "author": "Andrew Taylor ", 22 | "bugs": { 23 | "url": "https://github.com/ataylorme/eslint-annotate-action/issues" 24 | }, 25 | "homepage": "https://github.com/ataylorme/eslint-annotate-action#readme", 26 | "dependencies": { 27 | "@actions/core": "^1.10.1", 28 | "@actions/github": "^6.0.0", 29 | "@actions/glob": "^0.4.0", 30 | "@octokit/action": "^6.0.7", 31 | "dotenv": "^16.4.5" 32 | }, 33 | "devDependencies": { 34 | "@octokit/webhooks-definitions": "^3.67.0", 35 | "@types/eslint": "^8.56.5", 36 | "@types/jest": "^29.5.12", 37 | "@types/node": "^20.11.24", 38 | "@typescript-eslint/eslint-plugin": "^7.1.0", 39 | "@typescript-eslint/parser": "^7.1.0", 40 | "@vercel/ncc": "^0.38.1", 41 | "eslint": "^8.57.0", 42 | "eslint-config-prettier": "^9.1.0", 43 | "eslint-plugin-import": "^2.29.1", 44 | "eslint-plugin-prettier": "^5.1.3", 45 | "jest": "^29", 46 | "js-yaml": "^4.1.0", 47 | "prettier": "^3.2.5", 48 | "ts-jest": "^29.1.2", 49 | "ts-node": "^10.9.2", 50 | "typescript": "^5.3.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-3-errors-analyzed.ts: -------------------------------------------------------------------------------- 1 | import {AnalyzedESLintReport} from '../types' 2 | 3 | /* eslint-disable */ 4 | 5 | const reportAnalyzedExpected: AnalyzedESLintReport = { 6 | errorCount: 3, 7 | warningCount: 0, 8 | markdown: 9 | '## 3 Error(s):\n' + 10 | '### [`src/__tests__/eslintJsonReportToJs.test.ts` line `20`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/__tests__/eslintJsonReportToJs.test.ts#L20:L20)\n' + 11 | '- Start Line: `20`\n' + 12 | '- End Line: `20`\n' + 13 | '- Message: Delete `;`\n' + 14 | ' - From: [`prettier/prettier`]\n' + 15 | '### [`src/__tests__/eslintJsonReportToJs.test.ts` line `21`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/__tests__/eslintJsonReportToJs.test.ts#L21:L21)\n' + 16 | '- Start Line: `21`\n' + 17 | '- End Line: `21`\n' + 18 | '- Message: Delete `;`\n' + 19 | ' - From: [`prettier/prettier`]\n' + 20 | '### [`src/__tests__/eslintJsonReportToJs.test.ts` line `25`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/__tests__/eslintJsonReportToJs.test.ts#L25:L25)\n' + 21 | '- Start Line: `25`\n' + 22 | '- End Line: `25`\n' + 23 | '- Message: Insert `⏎`\n' + 24 | ' - From: [`prettier/prettier`]\n' + 25 | '\n', 26 | success: false, 27 | summary: '3 ESLint error(s) and 0 ESLint warning(s) found', 28 | annotations: [ 29 | { 30 | path: 'src/__tests__/eslintJsonReportToJs.test.ts', 31 | start_line: 20, 32 | end_line: 20, 33 | annotation_level: 'failure', 34 | message: '[prettier/prettier] Delete `;`', 35 | start_column: 6, 36 | end_column: 7, 37 | }, 38 | { 39 | path: 'src/__tests__/eslintJsonReportToJs.test.ts', 40 | start_line: 21, 41 | end_line: 21, 42 | annotation_level: 'failure', 43 | message: '[prettier/prettier] Delete `;`', 44 | start_column: 52, 45 | end_column: 53, 46 | }, 47 | { 48 | path: 'src/__tests__/eslintJsonReportToJs.test.ts', 49 | start_line: 25, 50 | end_line: 25, 51 | annotation_level: 'failure', 52 | message: '[prettier/prettier] Insert `⏎`', 53 | start_column: 3, 54 | end_column: 3, 55 | }, 56 | ], 57 | } 58 | 59 | /* eslint-enable */ 60 | 61 | export default reportAnalyzedExpected 62 | -------------------------------------------------------------------------------- /src/getPullRequestChangedAnalyzedReport.ts: -------------------------------------------------------------------------------- 1 | import getPullRequestFiles from './getPullRequestFiles' 2 | import getAnalyzedReport from './getAnalyzedReport' 3 | import type {ESLintReport, AnalyzedESLintReport} from './types' 4 | import constants from './constants' 5 | const {GITHUB_WORKSPACE, OWNER, REPO, pullRequest, onlyChangedFiles} = constants 6 | 7 | /** 8 | * Analyzes an ESLint report, separating pull request changed files 9 | * @param reportJS a JavaScript representation of an ESLint JSON report 10 | */ 11 | export default async function getPullRequestChangedAnalyzedReport( 12 | reportJS: ESLintReport, 13 | ): Promise { 14 | const changedFiles = await getPullRequestFiles({ 15 | owner: OWNER, 16 | repo: REPO, 17 | pull_number: pullRequest.number, 18 | }) 19 | 20 | // Separate lint reports for PR and non-PR files 21 | const pullRequestFilesReportJS: ESLintReport = reportJS.filter((file) => { 22 | file.filePath = file.filePath.replace(GITHUB_WORKSPACE + '/', '') 23 | return changedFiles.indexOf(file.filePath) !== -1 24 | }) 25 | 26 | const analyzedPullRequestReport = getAnalyzedReport(pullRequestFilesReportJS) 27 | let summary = `${analyzedPullRequestReport.summary} in pull request changed files.` 28 | let markdown = `# Pull Request Changed Files ESLint Results:\n**${analyzedPullRequestReport.summary}**\n${analyzedPullRequestReport.markdown}` 29 | 30 | if (!onlyChangedFiles) { 31 | const nonPullRequestFilesReportJS: ESLintReport = reportJS.filter((file) => { 32 | file.filePath = file.filePath.replace(GITHUB_WORKSPACE + '/', '') 33 | return changedFiles.indexOf(file.filePath) === -1 34 | }) 35 | 36 | const analyzedNonPullRequestReport = getAnalyzedReport(nonPullRequestFilesReportJS) 37 | 38 | summary += `${analyzedNonPullRequestReport.summary} in files outside of the pull request.` 39 | markdown += `\n\n# Non-Pull Request Changed Files ESLint Results:\n**${analyzedNonPullRequestReport.summary}**\n${analyzedNonPullRequestReport.markdown}` 40 | } 41 | 42 | if (markdown.length > 65535) { 43 | markdown = markdown.slice(0, 65250) + '\n\n...summary too long, truncated.' 44 | } 45 | 46 | return { 47 | errorCount: analyzedPullRequestReport.errorCount, 48 | warningCount: analyzedPullRequestReport.warningCount, 49 | markdown, 50 | success: analyzedPullRequestReport.success, 51 | summary, 52 | annotations: analyzedPullRequestReport.annotations, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import {ESLint as ESLintType} from '@types/eslint' 2 | 3 | // https://www.npmjs.com/package/@octokit/types 4 | import {Endpoints, GetResponseDataTypeFromEndpointMethod} from '@octokit/types' 5 | import {Webhooks} from '@octokit/webhooks-definitions' 6 | 7 | // GitHub Octokit Types 8 | export type prFilesParametersType = Endpoints['GET /repos/:owner/:repo/pulls/:pull_number/files']['parameters'] 9 | export type prFilesResponseType = Endpoints['GET /repos/:owner/:repo/pulls/:pull_number/files']['response'] 10 | export type checkUpdateParametersType = Endpoints['PATCH /repos/:owner/:repo/check-runs/:check_run_id']['parameters'] 11 | export type checkCreateParametersType = Endpoints['POST /repos/:owner/:repo/check-runs']['parameters'] 12 | export type pullRequestWebhook = Webhooks['pull_request'] 13 | export type createCheckRunResponseDataType = GetResponseDataTypeFromEndpointMethod 14 | export type updateCheckRunResponseDataType = GetResponseDataTypeFromEndpointMethod 15 | 16 | // Custom Types 17 | 18 | // https://developer.github.com/v3/checks/runs/#list-check-run-annotations 19 | export interface ChecksUpdateParamsOutputAnnotations { 20 | path: string 21 | start_line: number 22 | end_line: number 23 | start_column?: number 24 | end_column?: number 25 | annotation_level: 'notice' | 'warning' | 'failure' 26 | message: string 27 | title?: string 28 | raw_details?: string 29 | } 30 | 31 | export interface ESLintMessage { 32 | ruleId: string 33 | severity: number 34 | message: string 35 | line: number 36 | column: number 37 | nodeType: string | null 38 | endLine?: number 39 | endColumn?: number | null 40 | fix?: { 41 | range: number[] 42 | text: string 43 | } 44 | messageId?: string 45 | } 46 | 47 | export type ESLintEntry = ESLintType.LintResult 48 | 49 | export type ESLintReport = ESLintEntry[] 50 | 51 | export interface AnalyzedESLintReport { 52 | errorCount: number 53 | warningCount: number 54 | success: boolean 55 | markdown: string 56 | summary: string 57 | annotations: ChecksUpdateParamsOutputAnnotations[] 58 | } 59 | 60 | export interface RollupReport { 61 | errorCount: number 62 | warningCount: number 63 | success: boolean 64 | markdown: string 65 | summary: string 66 | annotations: ChecksUpdateParamsOutputAnnotations[] 67 | reports: AnalyzedESLintReport[] 68 | } 69 | 70 | export interface FileSet { 71 | name: string 72 | files: ESLintEntry[] 73 | } 74 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-1-error-analyzed.ts: -------------------------------------------------------------------------------- 1 | import {AnalyzedESLintReport} from '../types' 2 | 3 | /* eslint-disable */ 4 | 5 | const reportAnalyzedExpected: AnalyzedESLintReport = { 6 | errorCount: 3, 7 | warningCount: 0, 8 | markdown: 9 | '## 3 Error(s):\n' + 10 | '### [`src/form-validation/FormValidatorStrategyFactory.ts` line `18`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/form-validation/FormValidatorStrategyFactory.ts#L18:L18)\n' + 11 | '- Start Line: `18`\n' + 12 | '- End Line: `18`\n' + 13 | '- Message: Expected indentation of 6 spaces but found 4.\n' + 14 | ' - From: [`indent`]\n' + 15 | '### [`src/form-validation/FormValidatorStrategyFactory.ts` line `18`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/form-validation/FormValidatorStrategyFactory.ts#L18:L18)\n' + 16 | '- Start Line: `18`\n' + 17 | '- End Line: `18`\n' + 18 | '- Message: Unexpected newline between object and [ of property access.\n' + 19 | ' - From: [`no-unexpected-multiline`]\n' + 20 | '### [`src/form-validation/FormValidatorStrategyFactory.ts` line `18`](https://github.com/ataylorme/eslint-annotate-github-action/blob/8e80ec28fec6ef9763aacbabb452bcb5d92315ca/src/form-validation/FormValidatorStrategyFactory.ts#L18:L18)\n' + 21 | '- Start Line: `18`\n' + 22 | '- End Line: `18`\n' + 23 | '- Message: Unexpected use of comma operator.\n' + 24 | ' - From: [`no-sequences`]\n' + 25 | '\n', 26 | success: false, 27 | summary: '3 ESLint error(s) and 0 ESLint warning(s) found', 28 | annotations: [ 29 | { 30 | path: 'src/form-validation/FormValidatorStrategyFactory.ts', 31 | start_line: 18, 32 | end_line: 18, 33 | annotation_level: 'failure', 34 | message: '[indent] Expected indentation of 6 spaces but found 4.', 35 | start_column: 1, 36 | end_column: 5, 37 | }, 38 | { 39 | path: 'src/form-validation/FormValidatorStrategyFactory.ts', 40 | start_line: 18, 41 | end_line: 18, 42 | annotation_level: 'failure', 43 | message: '[no-unexpected-multiline] Unexpected newline between object and [ of property access.', 44 | start_column: 5, 45 | end_column: 5, 46 | }, 47 | { 48 | path: 'src/form-validation/FormValidatorStrategyFactory.ts', 49 | start_line: 18, 50 | end_line: 18, 51 | annotation_level: 'failure', 52 | message: '[no-sequences] Unexpected use of comma operator.', 53 | start_column: 12, 54 | end_column: 13, 55 | }, 56 | ], 57 | } 58 | 59 | /* eslint-enable */ 60 | 61 | export default reportAnalyzedExpected 62 | -------------------------------------------------------------------------------- /src/addAnnotationsToStatusCheck.ts: -------------------------------------------------------------------------------- 1 | import updateStatusCheck from './updateStatusCheck' 2 | import type {ChecksUpdateParamsOutputAnnotations, createCheckRunResponseDataType} from './types' 3 | import constants from './constants' 4 | const {OWNER, REPO, core, checkName} = constants 5 | 6 | /** 7 | * Add annotations to an existing GitHub check run 8 | * @param annotations an array of annotation objects. See https://developer.github.com/v3/checks/runs/#annotations-object-1 9 | * @param checkId the ID of the check run to add annotations to 10 | */ 11 | export default async function addAnnotationsToStatusCheck( 12 | annotations: ChecksUpdateParamsOutputAnnotations[], 13 | checkId: createCheckRunResponseDataType['id'], 14 | ): Promise> { 15 | /** 16 | * Update the GitHub check with the 17 | * annotations from the report analysis. 18 | * 19 | * If there are more than 50 annotations 20 | * we need to make multiple API requests 21 | * to avoid rate limiting errors 22 | * 23 | * See https://developer.github.com/v3/checks/runs/#output-object-1 24 | */ 25 | const numberOfAnnotations = annotations.length 26 | const batchSize = 50 27 | const numBatches = Math.ceil(numberOfAnnotations / batchSize) 28 | const checkUpdatePromises = [] 29 | for (let batch = 1; batch <= numBatches; batch++) { 30 | const batchMessage = `Found ${numberOfAnnotations} ESLint errors and warnings, processing batch ${batch} of ${numBatches}...` 31 | core.info(batchMessage) 32 | const annotationBatch = annotations.splice(0, batchSize) 33 | try { 34 | const currentCheckPromise = updateStatusCheck({ 35 | owner: OWNER, 36 | repo: REPO, 37 | check_run_id: checkId, 38 | status: 'in_progress', 39 | output: { 40 | title: checkName, 41 | summary: batchMessage, 42 | annotations: annotationBatch, 43 | }, 44 | /** 45 | * The check run API is still in beta and the developer preview must be opted into 46 | * See https://developer.github.com/changes/2018-05-07-new-checks-api-public-beta/ 47 | */ 48 | mediaType: { 49 | previews: ['antiope'], 50 | }, 51 | }) 52 | checkUpdatePromises.push(currentCheckPromise) 53 | } catch (err) { 54 | const errorMessage = `Error adding anotations to the GitHub status check with ID: ${checkId}` 55 | // err only has an error message if it is an instance of Error 56 | if (err instanceof Error) { 57 | core.setFailed(err.message ? err.message : errorMessage) 58 | } else { 59 | core.setFailed(errorMessage) 60 | } 61 | } 62 | } 63 | return Promise.all(checkUpdatePromises) 64 | } 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import eslintJsonReportToJs from './eslintJsonReportToJs' 3 | import getAnalyzedReport from './getAnalyzedReport' 4 | import openStatusCheck from './openStatusCheck' 5 | import closeStatusCheck from './closeStatusCheck' 6 | import addAnnotationsToStatusCheck from './addAnnotationsToStatusCheck' 7 | import getPullRequestChangedAnalyzedReport from './getPullRequestChangedAnalyzedReport' 8 | import addSummary from './addSummary' 9 | import constants from './constants' 10 | const {reportFile, onlyChangedFiles, failOnError, failOnWarning, markdownReportOnStepSummary} = constants 11 | 12 | async function run(): Promise { 13 | core.info(`Starting analysis of the ESLint report ${reportFile.replace(/\n/g, ', ')}. Standby...`) 14 | const reportJS = await eslintJsonReportToJs(reportFile) 15 | const analyzedReport = onlyChangedFiles 16 | ? await getPullRequestChangedAnalyzedReport(reportJS) 17 | : getAnalyzedReport(reportJS) 18 | const annotations = analyzedReport.annotations 19 | const conclusion = analyzedReport.success ? 'success' : 'failure' 20 | 21 | core.info(analyzedReport.summary) 22 | 23 | core.setOutput('summary', analyzedReport.summary) 24 | core.setOutput('errorCount', analyzedReport.errorCount) 25 | core.setOutput('warningCount', analyzedReport.warningCount) 26 | 27 | try { 28 | // Create a new, in-progress status check 29 | const checkId = await openStatusCheck() 30 | 31 | // Add all the annotations to the status check 32 | await addAnnotationsToStatusCheck(annotations, checkId) 33 | 34 | // Add report to job summary 35 | if (markdownReportOnStepSummary) { 36 | await addSummary(analyzedReport.markdown) 37 | } 38 | 39 | // Finally, close the GitHub check as completed 40 | await closeStatusCheck( 41 | conclusion, 42 | checkId, 43 | analyzedReport.summary, 44 | markdownReportOnStepSummary ? analyzedReport.markdown : '', 45 | ) 46 | 47 | // Fail the Action if the report analysis conclusions is failure 48 | if ((failOnWarning || failOnError) && conclusion === 'failure') { 49 | core.setFailed(`${analyzedReport.errorCount} errors and ${analyzedReport.warningCount} warnings`) 50 | process.exit(1) 51 | } 52 | } catch (err) { 53 | const errorMessage = 'Error creating a status check for the ESLint analysis.' 54 | // err only has an error message if it is an instance of Error 55 | if (err instanceof Error) { 56 | core.setFailed(err.message ? err.message : errorMessage) 57 | } else { 58 | core.setFailed(errorMessage) 59 | } 60 | } 61 | // If we got this far things were a success 62 | core.info('ESLint report analysis complete. No errors found!') 63 | process.exit(0) 64 | } 65 | 66 | run() 67 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-3-errors.ts: -------------------------------------------------------------------------------- 1 | import {ESLintReport} from '../types' 2 | 3 | /* eslint-disable */ 4 | 5 | const reportJSExpected: ESLintReport = [{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/__tests__/eslintJsonReportToJs.test.ts`,"messages":[{"ruleId":"prettier/prettier","severity":2,"message":"Delete `;`","line":20,"column":6,"endLine":20,"endColumn":7,"fix":{"range":[609,610],"text":""}},{"ruleId":"prettier/prettier","severity":2,"message":"Delete `;`","line":21,"column":52,"endLine":21,"endColumn":53,"fix":{"range":[662,663],"text":""}},{"ruleId":"prettier/prettier","severity":2,"message":"Insert `⏎`","line":25,"column":3,"endLine":25,"endColumn":3,"fix":{"range":[809,809],"text":"\n"}}],"errorCount":3,"warningCount":0,"fixableErrorCount":3,"fixableWarningCount":0,"source":"import eslintJsonReportToJs from '../eslintJsonReportToJs'\n\ndescribe('ESLint report JSON to JS', () => {\n it('converts an ESLint JSON file to a JS object', async () => {\n const reportJSExpected = [\n '.vscode/settings.json',\n 'action.yml',\n 'dist/index.js',\n 'jest.config.js',\n 'package-lock.json',\n 'package.json',\n 'src/analyze-eslint-js.ts',\n 'src/constants.ts',\n 'src/eslint-action.ts',\n 'src/eslint-json-report-to-js.ts',\n 'src/get-pr-files-changed.ts',\n 'src/split-array-into-chunks.ts',\n 'src/types.d.ts',\n 'tsconfig.json',\n ];\n const reportJS = await eslintJsonReportToJs('');\n // https://jestjs.io/docs/en/expect#expectarraycontainingarray\n expect(reportJS).toEqual(expect.arrayContaining(reportJSExpected))\n })\n})","usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/__tests__/getPullRequestFiles.test.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/constants.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/eslintJsonReportToJs.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/getPullRequestFiles.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/index.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{suppressedMessages: [], fatalErrorCount: 0, "filePath":`src/types.d.ts`,"messages":[],"errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]}] 6 | 7 | /* eslint-enable */ 8 | 9 | export default reportJSExpected 10 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-1-error.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "suppressedMessages": [], 4 | "fatalErrorCount": 0, 5 | "usedDeprecatedRules": [], 6 | "filePath": "src/form-validation/FormValidatorStrategyFactory.ts", 7 | "messages": [ 8 | { 9 | "ruleId": "indent", 10 | "severity": 2, 11 | "message": "Expected indentation of 6 spaces but found 4.", 12 | "line": 18, 13 | "column": 1, 14 | "nodeType": "Punctuator", 15 | "messageId": "wrongIndentation", 16 | "endLine": 18, 17 | "endColumn": 5, 18 | "fix": { 19 | "range": [ 20 | 1091, 21 | 1095 22 | ], 23 | "text": " " 24 | } 25 | }, 26 | { 27 | "ruleId": "no-unexpected-multiline", 28 | "severity": 2, 29 | "message": "Unexpected newline between object and [ of property access.", 30 | "line": 18, 31 | "column": 5, 32 | "nodeType": "ArrayExpression", 33 | "messageId": "property" 34 | }, 35 | { 36 | "ruleId": "no-sequences", 37 | "severity": 2, 38 | "message": "Unexpected use of comma operator.", 39 | "line": 18, 40 | "column": 12, 41 | "nodeType": "SequenceExpression", 42 | "endLine": 18, 43 | "endColumn": 13 44 | } 45 | ], 46 | "errorCount": 3, 47 | "warningCount": 0, 48 | "fixableErrorCount": 1, 49 | "fixableWarningCount": 0, 50 | "source": "import { IFormValidatorStrategy } from './IFormValidatorStrategy'\nimport { DemoComponentValidator } from './strategies/DemoComponentValidator'\nimport { TwitterFeedValidator } from './strategies/TwitterFeedValidator'\nimport { ContactFormValidator } from './strategies/ContactFormValidator'\nimport { AdvancedMediaValidator } from './strategies/AdvancedMediaValidator'\nimport { LiveEventHeadingValidator } from './strategies/LiveEventHeadingValidator'\nimport { PollValidator } from './strategies/PollValidator'\nimport { TvFavoriteNoticeValidator } from './strategies/TvFavoriteNoticeValidator'\nimport { SponsorAdPlacementValidator } from './strategies/SponsorAdPlacementValidator'\nimport { StrikeGamValidator } from './strategies/StrikeGamValidator'\n\nexport class FormValidatorStrategyFactory {\n private static readonly strategies = new Map([\n ['demo-component', new DemoComponentValidator()],\n ['twitter-feed', new TwitterFeedValidator()],\n ['advanced-media', new AdvancedMediaValidator()],\n ['live-event-heading', new LiveEventHeadingValidator()]\n ['poll', new PollValidator()],\n ['tv-favorite-notice', new TvFavoriteNoticeValidator()],\n ['sponsor-ad-placement', new SponsorAdPlacementValidator()],\n ['contact-form', new ContactFormValidator()],\n ['strike-gam', new StrikeGamValidator()]\n ])\n\n public static build(\n componentName: string\n ): IFormValidatorStrategy | undefined {\n return this.strategies.get(componentName)\n }\n}\n" 51 | } 52 | ] -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {config as dotEnvConfig} from 'dotenv' 3 | dotEnvConfig() 4 | import {context} from '@actions/github' 5 | import {Octokit} from '@octokit/action' 6 | import type {pullRequestWebhook} from './types' 7 | import type {OctokitOptions} from '@octokit/core' 8 | 9 | export const cwd = process.cwd() 10 | 11 | const areTesting = process.env.NODE_ENV === 'test' 12 | 13 | const isGitHubActions = process.env.GITHUB_ACTIONS 14 | 15 | const octokitParams: OctokitOptions = areTesting ? {authStrategy: undefined} : {} 16 | const octokit = new Octokit(octokitParams) 17 | 18 | // If this is a pull request, store the context 19 | // Otherwise, set to false 20 | const isPullRequest = Object.prototype.hasOwnProperty.call(context.payload, 'pull_request') 21 | const pullRequest: pullRequestWebhook | false = isPullRequest ? context.payload.pull_request : false 22 | 23 | let sha = context.sha 24 | 25 | if (isPullRequest) { 26 | sha = pullRequest.head.sha 27 | } 28 | 29 | if (areTesting) { 30 | sha = '8e80ec28fec6ef9763aacbabb452bcb5d92315ca' 31 | } 32 | 33 | function getBooleanInput(inputName: string, defaultValue: string): boolean { 34 | const inputValue = core.getInput(inputName) || defaultValue 35 | return inputValue === 'true' 36 | } 37 | 38 | function getInputs() { 39 | const onlyChangedFiles = getBooleanInput('only-pr-files', 'true') 40 | const failOnWarning = getBooleanInput('fail-on-warning', 'false') 41 | const failOnError = getBooleanInput('fail-on-error', 'true') 42 | const markdownReportOnStepSummary = getBooleanInput('markdown-report-on-step-summary', 'false') 43 | const checkName = core.getInput('check-name') || 'ESLint Report Analysis' 44 | const reportFile = areTesting 45 | ? 'src/__tests__/eslintReport-3-errors.json' 46 | : core.getInput('report-json', {required: true}) 47 | 48 | return { 49 | onlyChangedFiles, 50 | failOnWarning, 51 | failOnError, 52 | markdownReportOnStepSummary, 53 | checkName, 54 | reportFile, 55 | } 56 | } 57 | 58 | const {onlyChangedFiles, failOnWarning, failOnError, markdownReportOnStepSummary, checkName, reportFile} = getInputs() 59 | 60 | // https://github.com/eslint/eslint/blob/a59a4e6e9217b3cc503c0a702b9e3b02b20b980d/lib/linter/apply-disable-directives.js#L253 61 | const unusedDirectiveMessagePrefix = 'Unused eslint-disable directive' 62 | 63 | const getTimestamp = (): string => { 64 | return new Date().toISOString() 65 | } 66 | 67 | export default { 68 | core, 69 | octokit, 70 | cwd, 71 | context, 72 | pullRequest, 73 | GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE as string, 74 | SHA: sha, 75 | CONTEXT: context, 76 | OWNER: areTesting ? 'ataylorme' : context.repo.owner, 77 | REPO: areTesting ? 'eslint-annotate-github-action' : context.repo.repo, 78 | checkName, 79 | onlyChangedFiles: isPullRequest && onlyChangedFiles, 80 | reportFile, 81 | isPullRequest, 82 | isGitHubActions, 83 | getTimestamp, 84 | failOnWarning, 85 | failOnError, 86 | markdownReportOnStepSummary, 87 | unusedDirectiveMessagePrefix, 88 | } 89 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-1-error.ts: -------------------------------------------------------------------------------- 1 | import {ESLintReport} from '../types' 2 | 3 | /* eslint-disable */ 4 | 5 | const reportJSExpected: ESLintReport = [ 6 | { 7 | suppressedMessages: [], 8 | fatalErrorCount: 0, 9 | usedDeprecatedRules: [], 10 | filePath: "src/form-validation/FormValidatorStrategyFactory.ts", 11 | messages: [ 12 | { 13 | "ruleId": "indent", 14 | "severity": 2, 15 | "message": "Expected indentation of 6 spaces but found 4.", 16 | "line": 18, 17 | "column": 1, 18 | "nodeType": "Punctuator", 19 | "messageId": "wrongIndentation", 20 | "endLine": 18, 21 | "endColumn": 5, 22 | "fix": { 23 | "range": [ 24 | 1091, 25 | 1095 26 | ], 27 | "text": " " 28 | } 29 | }, 30 | { 31 | "ruleId": "no-unexpected-multiline", 32 | "severity": 2, 33 | "message": "Unexpected newline between object and [ of property access.", 34 | "line": 18, 35 | "column": 5, 36 | "nodeType": "ArrayExpression", 37 | "messageId": "property" 38 | }, 39 | { 40 | "ruleId": "no-sequences", 41 | "severity": 2, 42 | "message": "Unexpected use of comma operator.", 43 | "line": 18, 44 | "column": 12, 45 | "nodeType": "SequenceExpression", 46 | "endLine": 18, 47 | "endColumn": 13 48 | } 49 | ], 50 | "errorCount": 3, 51 | "warningCount": 0, 52 | "fixableErrorCount": 1, 53 | "fixableWarningCount": 0, 54 | "source": `import { IFormValidatorStrategy } from './IFormValidatorStrategy'\nimport { DemoComponentValidator } from './strategies/DemoComponentValidator'\nimport { TwitterFeedValidator } from './strategies/TwitterFeedValidator'\nimport { ContactFormValidator } from './strategies/ContactFormValidator'\nimport { AdvancedMediaValidator } from './strategies/AdvancedMediaValidator'\nimport { LiveEventHeadingValidator } from './strategies/LiveEventHeadingValidator'\nimport { PollValidator } from './strategies/PollValidator'\nimport { TvFavoriteNoticeValidator } from './strategies/TvFavoriteNoticeValidator'\nimport { SponsorAdPlacementValidator } from './strategies/SponsorAdPlacementValidator'\nimport { StrikeGamValidator } from './strategies/StrikeGamValidator'\n\nexport class FormValidatorStrategyFactory {\n private static readonly strategies = new Map([\n ['demo-component', new DemoComponentValidator()],\n ['twitter-feed', new TwitterFeedValidator()],\n ['advanced-media', new AdvancedMediaValidator()],\n ['live-event-heading', new LiveEventHeadingValidator()]\n ['poll', new PollValidator()],\n ['tv-favorite-notice', new TvFavoriteNoticeValidator()],\n ['sponsor-ad-placement', new SponsorAdPlacementValidator()],\n ['contact-form', new ContactFormValidator()],\n ['strike-gam', new StrikeGamValidator()]\n ])\n\n public static build(\n componentName: string\n ): IFormValidatorStrategy | undefined {\n return this.strategies.get(componentName)\n }\n}\n` 55 | }] 56 | 57 | /* eslint-enable */ 58 | 59 | export default reportJSExpected 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESLint Annotate from Report JSON 2 | 3 | ## Version `3.0.0` 4 | 5 | See the [Changelog](./CHANGELOG.md) for breaking changes when upgrading from `v1` or `v2` 6 | 7 | ## Description 8 | 9 | Analyzes an ESLint a report JSON file and posts the results. 10 | 11 | On `pull_request` annotates the pull request diff with warnings and errors 12 | 13 | ![image](./assets/eslint-annotate-action-pr-error-example.png) 14 | 15 | On `push` creates a `ESLint Report Analysis` with a summary of errors and warnings, including links to the line numbers of the violations. 16 | 17 | ![image](./assets/eslint-annotate-action-push-report-example.png) 18 | 19 | ## Why another ESLint action? 20 | 21 | The others I tried to use ran ESLint in NodeJS themselves. With this action, I can take an ESLint report generated from the command line and process the results. 22 | 23 | This allows for more flexibility on how ESLint is run. This action is agnostic enough to handle different configurations, extensions, etc. across projects without making assumptions on how ESLint should be run. 24 | 25 | ## Inputs 26 | 27 | | Name | Description | Required | Default Value | 28 | |---|---|---|---| 29 | | `GITHUB_TOKEN` | The [`GITHUB_TOKEN` secret](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#about-the-github_token-secret) | No | `${{ github.token }}` | 30 | | `report-json` | Path or [glob pattern](https://github.com/actions/toolkit/tree/master/packages/glob) to locate the ESLint report JSON file. Use multiple lines to specify multiple glob patterns. | No | `eslint_report.json` | 31 | | `only-pr-files` | Only annotate files changed when run on the `pull_request` event | No | `true` | 32 | | `fail-on-warning` | Fail the GitHub Action when ESLint warnings are detected. Set to `true` to enable. | No | `false` | 33 | | `fail-on-error` | Whether to fail the Github action when ESLint errors are detected. If set to false, the check that is created will still fail on ESLint errors. | No | `true` | 34 | | `check-name` | The name of the GitHub status check created. | No | `ESLint Report Analysis` | 35 | | `markdown-report-on-step-summary` | Whether to show a markdown report in the step summary. | No | `false` | 36 | 37 | ## Outputs 38 | 39 | | Name | Description | 40 | |---|---| 41 | | `summary` | A short description of the error and warning count | 42 | | `errorCount` | The amount of errors ESLint reported on | 43 | | `warningCount` | The amount of warnings ESLint reported on | 44 | 45 | ## Usage Example 46 | 47 | In `.github/workflows/nodejs.yml`: 48 | 49 | ```yml 50 | name: Example NodeJS Workflow 51 | 52 | on: [pull_request] 53 | 54 | jobs: 55 | node_test: 56 | permissions: 57 | # Default permissions (matching what would be set if the permissions section was missing at all) 58 | contents: read 59 | packages: read 60 | 61 | # Need to add these 2 for eslint-annotate-action 62 | pull-requests: read 63 | checks: write 64 | runs-on: ubuntu-latest 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Setup Node 69 | uses: actions/setup-node@v4 70 | with: 71 | node-version: 20 72 | cache: 'npm' 73 | - name: Install Node Dependencies 74 | run: npm ci 75 | env: 76 | CI: TRUE 77 | - name: Test Code Linting 78 | run: npm run lint 79 | - name: Save Code Linting Report JSON 80 | # npm script for ESLint 81 | # eslint --output-file eslint_report.json --format json src 82 | # See https://eslint.org/docs/user-guide/command-line-interface#options 83 | run: npm run lint:report 84 | # Continue to the next step even if this fails 85 | continue-on-error: true 86 | - name: Annotate Code Linting Results 87 | uses: ataylorme/eslint-annotate-action@v3 88 | with: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | report-json: "eslint_report.json" 91 | # OPTIONAL: save a copy of the usage report for download or use in another job 92 | # - name: Upload ESLint report 93 | # uses: actions/upload-artifact@v4 94 | # with: 95 | # name: eslint_report.json 96 | # path: eslint_report.json 97 | # retention-days: 5 98 | ``` 99 | -------------------------------------------------------------------------------- /src/__tests__/eslintReport-3-errors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "suppressedMessages": [], 4 | "fatalErrorCount": 0, 5 | "filePath": "src/__tests__/eslintJsonReportToJs.test.ts", 6 | "messages": [ 7 | { 8 | "ruleId": "prettier/prettier", 9 | "severity": 2, 10 | "message": "Delete `;`", 11 | "line": 20, 12 | "column": 6, 13 | "endLine": 20, 14 | "endColumn": 7, 15 | "fix": { 16 | "range": [ 17 | 609, 18 | 610 19 | ], 20 | "text": "" 21 | } 22 | }, 23 | { 24 | "ruleId": "prettier/prettier", 25 | "severity": 2, 26 | "message": "Delete `;`", 27 | "line": 21, 28 | "column": 52, 29 | "endLine": 21, 30 | "endColumn": 53, 31 | "fix": { 32 | "range": [ 33 | 662, 34 | 663 35 | ], 36 | "text": "" 37 | } 38 | }, 39 | { 40 | "ruleId": "prettier/prettier", 41 | "severity": 2, 42 | "message": "Insert `⏎`", 43 | "line": 25, 44 | "column": 3, 45 | "endLine": 25, 46 | "endColumn": 3, 47 | "fix": { 48 | "range": [ 49 | 809, 50 | 809 51 | ], 52 | "text": "\n" 53 | } 54 | } 55 | ], 56 | "errorCount": 3, 57 | "warningCount": 0, 58 | "fixableErrorCount": 3, 59 | "fixableWarningCount": 0, 60 | "source": "import eslintJsonReportToJs from '../eslintJsonReportToJs'\n\ndescribe('ESLint report JSON to JS', () => {\n it('converts an ESLint JSON file to a JS object', async () => {\n const reportJSExpected = [\n '.vscode/settings.json',\n 'action.yml',\n 'dist/index.js',\n 'jest.config.js',\n 'package-lock.json',\n 'package.json',\n 'src/analyze-eslint-js.ts',\n 'src/constants.ts',\n 'src/eslint-action.ts',\n 'src/eslint-json-report-to-js.ts',\n 'src/get-pr-files-changed.ts',\n 'src/split-array-into-chunks.ts',\n 'src/types.d.ts',\n 'tsconfig.json',\n ];\n const reportJS = await eslintJsonReportToJs('');\n // https://jestjs.io/docs/en/expect#expectarraycontainingarray\n expect(reportJS).toEqual(expect.arrayContaining(reportJSExpected))\n })\n})", 61 | "usedDeprecatedRules": [] 62 | }, 63 | { 64 | "suppressedMessages": [], 65 | "fatalErrorCount": 0, 66 | "filePath": "src/__tests__/getPullRequestFiles.test.ts", 67 | "messages": [], 68 | "errorCount": 0, 69 | "warningCount": 0, 70 | "fixableErrorCount": 0, 71 | "fixableWarningCount": 0, 72 | "usedDeprecatedRules": [] 73 | }, 74 | { 75 | "suppressedMessages": [], 76 | "fatalErrorCount": 0, 77 | "filePath": "src/constants.ts", 78 | "messages": [], 79 | "errorCount": 0, 80 | "warningCount": 0, 81 | "fixableErrorCount": 0, 82 | "fixableWarningCount": 0, 83 | "usedDeprecatedRules": [] 84 | }, 85 | { 86 | "suppressedMessages": [], 87 | "fatalErrorCount": 0, 88 | "filePath": "src/eslintJsonReportToJs.ts", 89 | "messages": [], 90 | "errorCount": 0, 91 | "warningCount": 0, 92 | "fixableErrorCount": 0, 93 | "fixableWarningCount": 0, 94 | "usedDeprecatedRules": [] 95 | }, 96 | { 97 | "suppressedMessages": [], 98 | "fatalErrorCount": 0, 99 | "filePath": "src/getPullRequestFiles.ts", 100 | "messages": [], 101 | "errorCount": 0, 102 | "warningCount": 0, 103 | "fixableErrorCount": 0, 104 | "fixableWarningCount": 0, 105 | "usedDeprecatedRules": [] 106 | }, 107 | { 108 | "suppressedMessages": [], 109 | "fatalErrorCount": 0, 110 | "filePath": "src/index.ts", 111 | "messages": [], 112 | "errorCount": 0, 113 | "warningCount": 0, 114 | "fixableErrorCount": 0, 115 | "fixableWarningCount": 0, 116 | "usedDeprecatedRules": [] 117 | }, 118 | { 119 | "suppressedMessages": [], 120 | "fatalErrorCount": 0, 121 | "filePath": "src/types.d.ts", 122 | "messages": [], 123 | "errorCount": 0, 124 | "warningCount": 0, 125 | "fixableErrorCount": 0, 126 | "fixableWarningCount": 0, 127 | "usedDeprecatedRules": [] 128 | } 129 | ] -------------------------------------------------------------------------------- /src/getAnalyzedReport.ts: -------------------------------------------------------------------------------- 1 | import type {ESLintReport, ChecksUpdateParamsOutputAnnotations, AnalyzedESLintReport} from './types' 2 | import constants from './constants' 3 | const {core, GITHUB_WORKSPACE, OWNER, REPO, SHA, failOnWarning, unusedDirectiveMessagePrefix} = constants 4 | 5 | /** 6 | * Analyzes an ESLint report JS object and returns a report 7 | * @param files a JavaScript representation of an ESLint JSON report 8 | */ 9 | export default function getAnalyzedReport(files: ESLintReport): AnalyzedESLintReport { 10 | // Create markdown placeholder 11 | let markdownText = '' 12 | 13 | // Start the error and warning counts at 0 14 | let errorCount = 0 15 | let warningCount = 0 16 | 17 | // Create text string placeholders 18 | let errorText = '' 19 | let warningText = '' 20 | 21 | // Create an array for annotations 22 | const annotations: ChecksUpdateParamsOutputAnnotations[] = [] 23 | 24 | // Loop through each file 25 | for (const file of files) { 26 | // Get the file path and any warning/error messages 27 | const {filePath, messages} = file 28 | 29 | core.info(`Analyzing ${filePath}`) 30 | 31 | // Skip files with no error or warning messages 32 | if (!messages.length) { 33 | continue 34 | } 35 | 36 | /** 37 | * Increment the error and warning counts by 38 | * the number of errors/warnings for this file 39 | * and note files in the PR 40 | */ 41 | errorCount += file.errorCount 42 | warningCount += file.warningCount 43 | 44 | // Loop through all the error/warning messages for the file 45 | for (const lintMessage of messages) { 46 | // Pull out information about the error/warning message 47 | const {column, severity, ruleId, message} = lintMessage 48 | // Default line to 1 if it's not present 49 | let {line} = lintMessage 50 | if (!line) { 51 | line = 1 52 | } 53 | 54 | // If there's no rule ID (e.g. an ignored file warning), skip 55 | if (!ruleId && !message.startsWith(unusedDirectiveMessagePrefix)) continue 56 | 57 | const endLine = lintMessage.endLine ? lintMessage.endLine : line 58 | const endColumn = lintMessage.endColumn ? lintMessage.endColumn : column 59 | 60 | // Check if it a warning or error 61 | const isWarning = severity < 2 62 | 63 | // Trim the absolute path prefix from the file path 64 | const filePathTrimmed: string = filePath.replace(`${GITHUB_WORKSPACE}/`, '') 65 | 66 | /** 67 | * Create a GitHub annotation object for the error/warning 68 | * See https://developer.github.com/v3/checks/runs/#annotations-object 69 | */ 70 | const annotation: ChecksUpdateParamsOutputAnnotations = { 71 | path: filePathTrimmed, 72 | start_line: line, 73 | end_line: endLine, 74 | annotation_level: isWarning ? 'warning' : 'failure', 75 | message: `[${ruleId}] ${message}`, 76 | } 77 | 78 | /** 79 | * Start and end column can only be added to the 80 | * annotation if start_line and end_line are equal 81 | */ 82 | if (line === endLine) { 83 | annotation.start_column = column 84 | if (endColumn !== null) { 85 | annotation.end_column = endColumn 86 | } 87 | } 88 | 89 | // Add the annotation object to the array 90 | annotations.push(annotation) 91 | 92 | /** 93 | * Develop user-friendly markdown message 94 | * text for the error/warning 95 | */ 96 | const link = `https://github.com/${OWNER}/${REPO}/blob/${SHA}/${filePathTrimmed}#L${line}:L${endLine}` 97 | 98 | let messageText = `### [\`${filePathTrimmed}\` line \`${line.toString()}\`](${link})\n` 99 | messageText += '- Start Line: `' + line.toString() + '`\n' 100 | messageText += '- End Line: `' + endLine.toString() + '`\n' 101 | messageText += '- Message: ' + message + '\n' 102 | messageText += ' - From: [`' + ruleId + '`]\n' 103 | 104 | // Add the markdown text to the appropriate placeholder 105 | if (isWarning) { 106 | warningText += messageText 107 | } else { 108 | errorText += messageText 109 | } 110 | } 111 | } 112 | 113 | // If there is any markdown error text, add it to the markdown output 114 | if (errorText.length) { 115 | markdownText += '## ' + errorCount.toString() + ' Error(s):\n' 116 | markdownText += errorText + '\n' 117 | } 118 | 119 | // If there is any markdown warning text, add it to the markdown output 120 | if (warningText.length) { 121 | markdownText += '## ' + warningCount.toString() + ' Warning(s):\n' 122 | markdownText += warningText + '\n' 123 | } 124 | 125 | let success = errorCount === 0 126 | if (failOnWarning && warningCount > 0) { 127 | success = false 128 | } 129 | 130 | // Return the ESLint report analysis 131 | return { 132 | errorCount, 133 | warningCount, 134 | markdown: markdownText, 135 | success, 136 | summary: `${errorCount} ESLint error(s) and ${warningCount} ESLint warning(s) found`, 137 | annotations, 138 | } 139 | } 140 | --------------------------------------------------------------------------------