├── .gitattributes ├── .gitignore ├── test ├── linters │ ├── projects │ │ ├── xo │ │ │ ├── .gitignore │ │ │ ├── file2.js │ │ │ ├── package.json │ │ │ └── file1.js │ │ ├── eslint │ │ │ ├── .gitignore │ │ │ ├── file2.js │ │ │ ├── package.json │ │ │ ├── file1.js │ │ │ └── .eslintrc.json │ │ ├── black │ │ │ ├── requirements.txt │ │ │ ├── file2.py │ │ │ └── file1.py │ │ ├── flake8 │ │ │ ├── requirements.txt │ │ │ ├── file2.py │ │ │ └── file1.py │ │ ├── mypy │ │ │ ├── requirements.txt │ │ │ ├── file2.py │ │ │ └── file1.py │ │ ├── prettier │ │ │ ├── .gitignore │ │ │ ├── file2.css │ │ │ ├── .prettierrc.json │ │ │ ├── file1.js │ │ │ ├── package.json │ │ │ └── yarn.lock │ │ ├── stylelint │ │ │ ├── .gitignore │ │ │ ├── file2.scss │ │ │ ├── file1.css │ │ │ ├── .stylelintrc.json │ │ │ └── package.json │ │ ├── eslint-typescript │ │ │ ├── .gitignore │ │ │ ├── file2.js │ │ │ ├── file1.ts │ │ │ ├── .eslintrc.json │ │ │ └── package.json │ │ ├── swiftlint │ │ │ ├── Mintfile │ │ │ ├── .swiftlint.yml │ │ │ ├── file2.swift │ │ │ └── file1.swift │ │ ├── swift-format-lockwood │ │ │ ├── .swiftformat │ │ │ ├── Mintfile │ │ │ ├── file2.swift │ │ │ └── file1.swift │ │ ├── rubocop │ │ │ ├── .rubocop.yml │ │ │ ├── file2.rb │ │ │ ├── Gemfile │ │ │ ├── file1.rb │ │ │ └── Gemfile.lock │ │ ├── swift-format-official │ │ │ ├── file2.swift │ │ │ └── file1.swift │ │ ├── gofmt │ │ │ ├── file2.go │ │ │ └── file1.go │ │ └── golint │ │ │ ├── file2.go │ │ │ └── file1.go │ ├── params │ │ ├── xo.js │ │ ├── golint.js │ │ ├── mypy.js │ │ ├── flake8.js │ │ ├── prettier.js │ │ ├── swift-format-official.js │ │ ├── black.js │ │ ├── swift-format-lockwood.js │ │ ├── gofmt.js │ │ ├── swiftlint.js │ │ ├── stylelint.js │ │ ├── rubocop.js │ │ ├── eslint.js │ │ └── eslint-typescript.js │ └── linters.test.js ├── teardown.js ├── setup.js ├── utils │ ├── command-exists.test.js │ └── npm │ │ ├── get-npm-bin-command.test.js │ │ └── use-yarn.test.js ├── github │ ├── test-constants.js │ ├── api.test.js │ ├── api-responses │ │ └── check-runs.json │ ├── context.test.js │ └── events │ │ ├── push.json │ │ └── pull-request-open.json └── test-utils.js ├── .github ├── screenshots │ ├── auto-fix.png │ ├── check-runs.png │ └── check-annotations.png └── workflows │ ├── lint.yml │ └── test.yml ├── .prettierignore ├── .editorconfig ├── src ├── utils │ ├── npm │ │ ├── get-npm-bin-command.js │ │ └── use-yarn.js │ ├── command-exists.js │ ├── string.js │ ├── diff.js │ ├── request.js │ ├── lint-result.js │ └── action.js ├── linters │ ├── index.js │ ├── xo.js │ ├── black.js │ ├── swiftlint.js │ ├── golint.js │ ├── swift-format-lockwood.js │ ├── swift-format-official.js │ ├── prettier.js │ ├── flake8.js │ ├── stylelint.js │ ├── gofmt.js │ ├── rubocop.js │ ├── mypy.js │ └── eslint.js ├── github │ ├── api.js │ └── context.js ├── git.js └── index.js ├── LICENSE.md ├── CREDITS.md ├── package.json ├── vendor ├── command-exists.min.js └── parse-diff.min.js ├── README.md └── action.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/tmp/ 3 | -------------------------------------------------------------------------------- /test/linters/projects/xo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /test/linters/projects/eslint/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /test/linters/projects/black/requirements.txt: -------------------------------------------------------------------------------- 1 | black>=19.10b0 2 | -------------------------------------------------------------------------------- /test/linters/projects/flake8/requirements.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.0.0 2 | -------------------------------------------------------------------------------- /test/linters/projects/mypy/requirements.txt: -------------------------------------------------------------------------------- 1 | mypy>=0.761 2 | -------------------------------------------------------------------------------- /test/linters/projects/prettier/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /test/linters/projects/stylelint/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /test/linters/projects/eslint-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /test/linters/projects/swiftlint/Mintfile: -------------------------------------------------------------------------------- 1 | realm/SwiftLint@0.38.1 2 | -------------------------------------------------------------------------------- /test/linters/projects/swiftlint/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | trailing_semicolon: error 2 | -------------------------------------------------------------------------------- /test/linters/projects/stylelint/file2.scss: -------------------------------------------------------------------------------- 1 | main {} // "block-no-empty" error 2 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-lockwood/.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 5.1 2 | -------------------------------------------------------------------------------- /test/linters/projects/prettier/file2.css: -------------------------------------------------------------------------------- 1 | body {color: red;} /* Line break errors */ 2 | -------------------------------------------------------------------------------- /test/linters/projects/rubocop/.rubocop.yml: -------------------------------------------------------------------------------- 1 | Layout/EndOfLine: 2 | Enabled: false 3 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-lockwood/Mintfile: -------------------------------------------------------------------------------- 1 | nicklockwood/SwiftFormat@0.43.5 2 | -------------------------------------------------------------------------------- /test/linters/projects/xo/file2.js: -------------------------------------------------------------------------------- 1 | const str = 'Hello world'; // "no-unused-vars" error 2 | -------------------------------------------------------------------------------- /test/linters/projects/eslint/file2.js: -------------------------------------------------------------------------------- 1 | const str = 'Hello world'; // "no-unused-vars" error 2 | -------------------------------------------------------------------------------- /test/linters/projects/eslint-typescript/file2.js: -------------------------------------------------------------------------------- 1 | const str = 'Hello world'; // "no-unused-vars" error 2 | -------------------------------------------------------------------------------- /test/linters/projects/swiftlint/file2.swift: -------------------------------------------------------------------------------- 1 | // "trailing_semicolon" error 2 | print("hello \(str)"); 3 | -------------------------------------------------------------------------------- /test/linters/projects/black/file2.py: -------------------------------------------------------------------------------- 1 | def add(num_1, num_2): 2 | return num_1 + num_2 # Indentation error 3 | -------------------------------------------------------------------------------- /test/linters/projects/flake8/file2.py: -------------------------------------------------------------------------------- 1 | def add(num_1, num_2): 2 | return num_1 + num_2 # Indentation error 3 | -------------------------------------------------------------------------------- /test/linters/projects/stylelint/file1.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red;; /* "no-extra-semicolons" warning */ 3 | } 4 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-lockwood/file2.swift: -------------------------------------------------------------------------------- 1 | // "semicolons" warning 2 | print("hello \(str)"); 3 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-official/file2.swift: -------------------------------------------------------------------------------- 1 | // "semicolons" warning 2 | print("hello \(str)"); 3 | -------------------------------------------------------------------------------- /.github/screenshots/auto-fix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quartz/lint-action/master/.github/screenshots/auto-fix.png -------------------------------------------------------------------------------- /.github/screenshots/check-runs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quartz/lint-action/master/.github/screenshots/check-runs.png -------------------------------------------------------------------------------- /test/linters/projects/prettier/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "useTabs": true 5 | } 6 | -------------------------------------------------------------------------------- /test/linters/projects/rubocop/file2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # "Lint/UselessAssignment" warning 4 | x = 1 5 | -------------------------------------------------------------------------------- /test/linters/projects/mypy/file2.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | 4 | def helper(var: Mapping[str, str]): 5 | pass 6 | -------------------------------------------------------------------------------- /.github/screenshots/check-annotations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quartz/lint-action/master/.github/screenshots/check-annotations.png -------------------------------------------------------------------------------- /test/linters/projects/rubocop/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rubocop', '~>0.77.0' 6 | -------------------------------------------------------------------------------- /test/linters/projects/gofmt/file2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func divide(num1 int, num2 int) int { 4 | return num1 / num2 // Whitespace error 5 | } 6 | -------------------------------------------------------------------------------- /test/linters/projects/prettier/file1.js: -------------------------------------------------------------------------------- 1 | function main() { 2 | console.log("Hello world"); // "singleQuote" error 3 | } 4 | 5 | main() // "semi" error 6 | -------------------------------------------------------------------------------- /test/linters/projects/rubocop/file1.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def method 4 | # "Style/RedundantReturn" convention 5 | return 'words' 6 | end 7 | -------------------------------------------------------------------------------- /test/linters/projects/golint/file2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Error for incorrect comment format 4 | func Divide(num1 int, num2 int) int { 5 | return num1 / num2 6 | } 7 | -------------------------------------------------------------------------------- /test/linters/projects/stylelint/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "block-no-empty": true, 4 | "no-extra-semicolons": [true, { "severity": "warning" }] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/linters/projects/xo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-xo", 3 | "main": "index.js", 4 | "private": true, 5 | "devDependencies": { 6 | "xo": "^0.27.2" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/linters/projects/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-eslint", 3 | "main": "index.js", 4 | "private": true, 5 | "devDependencies": { 6 | "eslint": "^6.8.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/linters/projects/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-prettier", 3 | "main": "index.js", 4 | "private": true, 5 | "devDependencies": { 6 | "prettier": "^1.19.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/linters/projects/stylelint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-stylelint", 3 | "main": "index.js", 4 | "private": true, 5 | "devDependencies": { 6 | "stylelint": "^13.2.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/linters/projects/swiftlint/file1.swift: -------------------------------------------------------------------------------- 1 | let str = "world" 2 | 3 | // "vertical_whitespace" warning 4 | 5 | 6 | func main() { 7 | print("hello \(str)") 8 | } 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # TODO: Move config into `package.json` once supported 2 | # https://github.com/prettier/prettier/issues/3460 3 | 4 | node_modules/ 5 | test/linters/projects/ 6 | test/tmp/ 7 | vendor/ 8 | -------------------------------------------------------------------------------- /test/teardown.js: -------------------------------------------------------------------------------- 1 | const { remove } = require("fs-extra"); 2 | 3 | const { tmpDir } = require("./test-utils"); 4 | 5 | module.exports = async () => { 6 | // Remove temporary directory 7 | await remove(tmpDir); 8 | }; 9 | -------------------------------------------------------------------------------- /test/linters/projects/xo/file1.js: -------------------------------------------------------------------------------- 1 | let str = 'world'; // "prefer-const" warning 2 | 3 | function main() { 4 | // "no-warning-comments" error 5 | console.log('hello ' + str); // TODO: Change something 6 | } 7 | 8 | main(); 9 | -------------------------------------------------------------------------------- /test/linters/projects/eslint/file1.js: -------------------------------------------------------------------------------- 1 | let str = 'world'; // "prefer-const" warning 2 | 3 | function main() { 4 | // "no-warning-comments" error 5 | console.log('hello ' + str); // TODO: Change something 6 | } 7 | 8 | main(); 9 | -------------------------------------------------------------------------------- /test/linters/projects/mypy/file1.py: -------------------------------------------------------------------------------- 1 | from file2 import helper 2 | 3 | 4 | def main(input_str: str): 5 | print(input_str) 6 | print(helper({ 7 | input_str: 42, 8 | })) 9 | 10 | 11 | main(["hello"]) 12 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-lockwood/file1.swift: -------------------------------------------------------------------------------- 1 | let str = "world" 2 | 3 | // "consecutiveBlankLines" warning 4 | 5 | 6 | func main() { 7 | print("hello \(str)") // "indent" warning 8 | } 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /test/linters/projects/swift-format-official/file1.swift: -------------------------------------------------------------------------------- 1 | let str = "world" 2 | 3 | // "consecutiveBlankLines" warning 4 | 5 | 6 | func main() { 7 | print("hello \(str)") // "indent" warning 8 | } 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /test/linters/projects/eslint-typescript/file1.ts: -------------------------------------------------------------------------------- 1 | let str = 'world'; // "prefer-const" warning 2 | 3 | function main(): void { 4 | // "no-warning-comments" error 5 | console.log('hello ' + str); // TODO: Change something 6 | } 7 | 8 | main(); 9 | -------------------------------------------------------------------------------- /test/linters/projects/eslint/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "rules": { 8 | "no-unused-vars": 2, 9 | "no-warning-comments": 1, 10 | "prefer-const": 2 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const { mkdir } = require("fs").promises; 2 | 3 | const { tmpDir } = require("./test-utils"); 4 | 5 | module.exports = async () => { 6 | // Create temporary directory which tests can write to 7 | await mkdir(tmpDir, { recursive: true }); 8 | }; 9 | -------------------------------------------------------------------------------- /test/linters/projects/eslint-typescript/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "browser": true, 6 | "es6": true 7 | }, 8 | "rules": { 9 | "no-unused-vars": 2, 10 | "no-warning-comments": 1, 11 | "prefer-const": 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = tab 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{yaml,yml}] 12 | indent_style = space 13 | 14 | [*.py] 15 | indent_style = space 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /test/linters/params/xo.js: -------------------------------------------------------------------------------- 1 | const XO = require("../../../src/linters/xo"); 2 | const eslintParams = require("./eslint"); 3 | 4 | const testName = "xo"; 5 | const linter = XO; 6 | const extensions = ["js"]; 7 | 8 | // Same expected output as ESLint 9 | module.exports = [testName, linter, extensions, eslintParams[3], eslintParams[4]]; 10 | -------------------------------------------------------------------------------- /test/linters/projects/eslint-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-eslint-typescript", 3 | "main": "index.js", 4 | "private": true, 5 | "devDependencies": { 6 | "@typescript-eslint/eslint-plugin": "^2.23.0", 7 | "@typescript-eslint/parser": "^2.23.0", 8 | "eslint": "^6.8.0", 9 | "typescript": "^3.8.3" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/linters/projects/golint/file1.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | var str = "world" 6 | 7 | func main() { 8 | fmt.Println("hello " + doSomething(str)) 9 | } 10 | 11 | func doSomething(str string) string { 12 | if str == "" { 13 | return "default" 14 | } else { 15 | // Error for unnecessary "else" statement 16 | return str 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/linters/projects/prettier/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | prettier@^1.19.1: 6 | version "1.19.1" 7 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" 8 | integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== 9 | -------------------------------------------------------------------------------- /test/linters/projects/gofmt/file1.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | var str = "world" 6 | 7 | func main () { // Whitespace error 8 | fmt.Println("hello " + str) 9 | } 10 | 11 | func add(num1 int, num2 int) int { 12 | return num1 + num2 13 | } 14 | 15 | func subtract(num1 int, num2 int) int { 16 | return num1 - num2 17 | } 18 | 19 | func multiply(num1 int, num2 int) int { 20 | return num1 * num2 // Indentation error 21 | } 22 | -------------------------------------------------------------------------------- /test/utils/command-exists.test.js: -------------------------------------------------------------------------------- 1 | const commandExists = require("../../src/utils/command-exists"); 2 | 3 | describe("commandExists()", () => { 4 | test("should return `true` for existing command", async () => { 5 | await expect(commandExists("cat")).resolves.toEqual(true); 6 | }); 7 | 8 | test("should return `false` for non-existent command", async () => { 9 | await expect(commandExists("nonexistentcommand")).resolves.toEqual(false); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/linters/projects/black/file1.py: -------------------------------------------------------------------------------- 1 | var_1 = "hello" 2 | var_2 = "world" 3 | 4 | 5 | def main (): # Whitespace error 6 | print("hello " + var_2) 7 | 8 | 9 | def add(num_1, num_2): 10 | return num_1 + num_2 11 | 12 | 13 | def subtract(num_1, num_2): 14 | return num_1 - num_2 15 | 16 | 17 | def multiply(num_1, num_2): 18 | return num_1 * num_2 19 | 20 | 21 | def divide(num_1, num_2): 22 | return num_1 / num_2 23 | 24 | # Blank lines error 25 | 26 | main() 27 | -------------------------------------------------------------------------------- /test/linters/projects/flake8/file1.py: -------------------------------------------------------------------------------- 1 | var_1 = "hello" 2 | var_2 = "world" 3 | 4 | 5 | def main (): # Whitespace error 6 | print("hello " + var_2) 7 | 8 | 9 | def add(num_1, num_2): 10 | return num_1 + num_2 11 | 12 | 13 | def subtract(num_1, num_2): 14 | return num_1 - num_2 15 | 16 | 17 | def multiply(num_1, num_2): 18 | return num_1 * num_2 19 | 20 | 21 | def divide(num_1, num_2): 22 | return num_1 / num_2 23 | 24 | # Blank lines error 25 | 26 | main() 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out Git repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 12 20 | 21 | - name: Install dependencies 22 | run: yarn install 23 | 24 | - name: Lint 25 | run: | 26 | yarn lint 27 | yarn format 28 | -------------------------------------------------------------------------------- /src/utils/npm/get-npm-bin-command.js: -------------------------------------------------------------------------------- 1 | const { useYarn } = require("./use-yarn"); 2 | 3 | /** 4 | * Returns the NPM or Yarn command ({@see useYarn()}) for executing an NPM binary 5 | * @param {string} [pkgRoot] - Package directory (directory where Yarn lockfile would exist) 6 | * @returns {string} - NPM/Yarn command for executing the NPM binary. The binary name should be 7 | * appended to this command 8 | */ 9 | function getNpmBinCommand(pkgRoot) { 10 | return useYarn(pkgRoot) ? "yarn run --silent" : "npx --no-install"; 11 | } 12 | 13 | module.exports = { getNpmBinCommand }; 14 | -------------------------------------------------------------------------------- /test/linters/projects/rubocop/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | ast (2.4.0) 5 | jaro_winkler (1.5.4) 6 | parallel (1.19.1) 7 | parser (2.7.0.1) 8 | ast (~> 2.4.0) 9 | rainbow (3.0.0) 10 | rubocop (0.77.0) 11 | jaro_winkler (~> 1.5.1) 12 | parallel (~> 1.10) 13 | parser (>= 2.6) 14 | rainbow (>= 2.2.2, < 4.0) 15 | ruby-progressbar (~> 1.7) 16 | unicode-display_width (>= 1.4.0, < 1.7) 17 | ruby-progressbar (1.10.1) 18 | unicode-display_width (1.6.0) 19 | 20 | PLATFORMS 21 | ruby 22 | 23 | DEPENDENCIES 24 | rubocop (~> 0.77.0) 25 | 26 | BUNDLED WITH 27 | 2.1.4 28 | -------------------------------------------------------------------------------- /src/utils/command-exists.js: -------------------------------------------------------------------------------- 1 | const checkForCommand = require("../../vendor/command-exists.min"); 2 | 3 | /** 4 | * Returns whether the provided shell command is available 5 | * @param {string} command - Shell command to check for 6 | * @returns {Promise} - Whether the command is available 7 | */ 8 | async function commandExists(command) { 9 | // The `command-exists` library throws an error if the command is not available. This function 10 | // catches these errors and returns a boolean value instead 11 | try { 12 | await checkForCommand(command); 13 | return true; 14 | } catch (error) { 15 | return false; 16 | } 17 | } 18 | 19 | module.exports = commandExists; 20 | -------------------------------------------------------------------------------- /src/utils/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Capitalizes the first letter of a string 3 | * @param {string} str - String to process 4 | * @returns {string} - Input string with first letter capitalized 5 | */ 6 | function capitalizeFirstLetter(str) { 7 | return str.charAt(0).toUpperCase() + str.slice(1); 8 | } 9 | 10 | /** 11 | * Removes the trailing period from the provided string (if it has one) 12 | * @param {string} str - String to process 13 | * @returns {string} - String without trailing period 14 | */ 15 | function removeTrailingPeriod(str) { 16 | return str[str.length - 1] === "." ? str.substring(0, str.length - 1) : str; 17 | } 18 | 19 | module.exports = { 20 | capitalizeFirstLetter, 21 | removeTrailingPeriod, 22 | }; 23 | -------------------------------------------------------------------------------- /test/github/test-constants.js: -------------------------------------------------------------------------------- 1 | const USERNAME = "test-user"; 2 | const REPOSITORY_NAME = "test-repo"; 3 | const REPOSITORY = `${USERNAME}/${REPOSITORY_NAME}`; 4 | 5 | const FORK_USERNAME = "fork-user"; 6 | const FORK_REPOSITORY_NAME = `${REPOSITORY_NAME}-fork`; 7 | const FORK_REPOSITORY = `${FORK_USERNAME}/${FORK_REPOSITORY_NAME}`; 8 | 9 | const BRANCH = "test-branch"; 10 | const REPOSITORY_DIR = "/path/to/cloned/repo"; 11 | const TOKEN = "test-token"; 12 | 13 | const EVENT_NAME = "push"; 14 | const EVENT_PATH = "/path/to/event.json"; 15 | 16 | module.exports = { 17 | USERNAME, 18 | REPOSITORY_NAME, 19 | REPOSITORY, 20 | FORK_USERNAME, 21 | FORK_REPOSITORY_NAME, 22 | FORK_REPOSITORY, 23 | BRANCH, 24 | REPOSITORY_DIR, 25 | TOKEN, 26 | EVENT_NAME, 27 | EVENT_PATH, 28 | }; 29 | -------------------------------------------------------------------------------- /test/utils/npm/get-npm-bin-command.test.js: -------------------------------------------------------------------------------- 1 | const { getNpmBinCommand } = require("../../../src/utils/npm/get-npm-bin-command"); 2 | const { useYarn } = require("../../../src/utils/npm/use-yarn"); 3 | 4 | jest.mock("../../../src/utils/action"); 5 | jest.mock("../../../src/utils/npm/use-yarn"); 6 | 7 | describe("runNpmBin()", () => { 8 | test("should run correct Yarn command", () => { 9 | useYarn.mockReturnValue(true); 10 | const npmBinCommand = getNpmBinCommand("/this/path/is/not/used"); 11 | expect(npmBinCommand).toEqual("yarn run --silent"); 12 | }); 13 | 14 | test("should run correct NPM command", () => { 15 | useYarn.mockReturnValue(false); 16 | const npmBinCommand = getNpmBinCommand("/this/path/is/not/used"); 17 | expect(npmBinCommand).toEqual("npx --no-install"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils/npm/use-yarn.js: -------------------------------------------------------------------------------- 1 | const { existsSync } = require("fs"); 2 | const { join } = require("path"); 3 | 4 | const YARN_LOCK_NAME = "yarn.lock"; 5 | 6 | /** 7 | * Determines whether Yarn should be used to execute commands or binaries. This decision is based on 8 | * the existence of a Yarn lockfile in the package directory. The distinction between NPM and Yarn 9 | * is necessary e.g. for Yarn Plug'n'Play to work 10 | * @param {string} [pkgRoot] - Package directory (directory where Yarn lockfile would exist) 11 | * @returns {boolean} - Whether Yarn should be used 12 | */ 13 | function useYarn(pkgRoot) { 14 | // Use an absolute path if `pkgRoot` is specified and a relative one (current directory) otherwise 15 | const lockfilePath = pkgRoot ? join(pkgRoot, YARN_LOCK_NAME) : YARN_LOCK_NAME; 16 | return existsSync(lockfilePath); 17 | } 18 | 19 | module.exports = { useYarn }; 20 | -------------------------------------------------------------------------------- /src/utils/diff.js: -------------------------------------------------------------------------------- 1 | const parseDiff = require("../../vendor/parse-diff.min"); 2 | 3 | /** 4 | * Parses linting errors from a unified diff 5 | * @param {string} diff - Unified diff 6 | * @returns {{path: string, firstLine: number, lastLine: number, message: string}[]} - Array of 7 | * parsed errors 8 | */ 9 | function parseErrorsFromDiff(diff) { 10 | const errors = []; 11 | const files = parseDiff(diff); 12 | for (const file of files) { 13 | const { chunks, to: path } = file; 14 | for (const chunk of chunks) { 15 | const { oldStart, oldLines, changes } = chunk; 16 | const chunkDiff = changes.map((change) => change.content).join("\n"); 17 | errors.push({ 18 | path, 19 | firstLine: oldStart, 20 | lastLine: oldStart + oldLines, 21 | message: chunkDiff, 22 | }); 23 | } 24 | } 25 | return errors; 26 | } 27 | 28 | module.exports = { 29 | parseErrorsFromDiff, 30 | }; 31 | -------------------------------------------------------------------------------- /test/utils/npm/use-yarn.test.js: -------------------------------------------------------------------------------- 1 | const { mkdir } = require("fs").promises; 2 | const { join } = require("path"); 3 | 4 | const { ensureFile } = require("fs-extra"); 5 | 6 | const { useYarn } = require("../../../src/utils/npm/use-yarn"); 7 | const { tmpDir } = require("../../test-utils"); 8 | 9 | describe("useYarn()", () => { 10 | test("should return `true` if there is a Yarn lockfile", async () => { 11 | const dir = join(tmpDir, "yarn-project"); 12 | const lockfilePath = join(dir, "yarn.lock"); 13 | 14 | // Create temp directory with lockfile 15 | await mkdir(dir); 16 | await ensureFile(lockfilePath); 17 | 18 | expect(useYarn(dir)).toBeTruthy(); 19 | }); 20 | 21 | test("should return `false` if there is no Yarn lockfile", async () => { 22 | const dir = join(tmpDir, "npm-project"); 23 | 24 | // Create temp directory without lockfile 25 | await mkdir(dir); 26 | 27 | expect(useYarn(dir)).toBeFalsy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | const https = require("https"); 2 | 3 | /** 4 | * Helper function for making HTTP requests 5 | * @param {string | URL} url - Request URL 6 | * @param {object} options - Request options 7 | * @returns {Promise} - JSON response 8 | */ 9 | function request(url, options) { 10 | return new Promise((resolve, reject) => { 11 | const req = https 12 | .request(url, options, (res) => { 13 | let data = ""; 14 | res.on("data", (chunk) => { 15 | data += chunk; 16 | }); 17 | res.on("end", () => { 18 | if (res.statusCode >= 400) { 19 | const err = new Error(`Received status code ${res.statusCode}`); 20 | err.response = res; 21 | err.data = data; 22 | reject(err); 23 | } else { 24 | resolve({ res, data: JSON.parse(data) }); 25 | } 26 | }); 27 | }) 28 | .on("error", reject); 29 | if (options.body) { 30 | req.end(JSON.stringify(options.body)); 31 | } else { 32 | req.end(); 33 | } 34 | }); 35 | } 36 | 37 | module.exports = request; 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Samuel Meuli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/linters/index.js: -------------------------------------------------------------------------------- 1 | const Black = require("./black"); 2 | const ESLint = require("./eslint"); 3 | const Flake8 = require("./flake8"); 4 | const Gofmt = require("./gofmt"); 5 | const Golint = require("./golint"); 6 | const Mypy = require("./mypy"); 7 | const Prettier = require("./prettier"); 8 | const RuboCop = require("./rubocop"); 9 | const Stylelint = require("./stylelint"); 10 | const SwiftFormatLockwood = require("./swift-format-lockwood"); 11 | const SwiftFormatOfficial = require("./swift-format-official"); 12 | const SwiftLint = require("./swiftlint"); 13 | const XO = require("./xo"); 14 | 15 | const linters = { 16 | // Linters 17 | eslint: ESLint, 18 | flake8: Flake8, 19 | golint: Golint, 20 | mypy: Mypy, 21 | rubocop: RuboCop, 22 | stylelint: Stylelint, 23 | swiftlint: SwiftLint, 24 | xo: XO, 25 | 26 | // Formatters (should be run after linters) 27 | black: Black, 28 | gofmt: Gofmt, 29 | prettier: Prettier, 30 | swift_format_lockwood: SwiftFormatLockwood, 31 | swift_format_official: SwiftFormatOfficial, 32 | 33 | // Alias of `swift_format_lockwood` (for backward compatibility) 34 | // TODO: Remove alias in v2 35 | swiftformat: SwiftFormatLockwood, 36 | }; 37 | 38 | module.exports = linters; 39 | -------------------------------------------------------------------------------- /src/utils/lint-result.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an object for storing linting results 3 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Default object 4 | */ 5 | function initLintResult() { 6 | return { 7 | isSuccess: true, // Usually determined by the exit code of the linting command 8 | warning: [], 9 | error: [], 10 | }; 11 | } 12 | 13 | /** 14 | * Returns a text summary of the number of issues found when linting 15 | * @param {{isSuccess: boolean, warning: object[], error: object[]}} lintResult - Parsed linter 16 | * output 17 | * @returns {string} - Text summary 18 | */ 19 | function getSummary(lintResult) { 20 | const nrErrors = lintResult.error.length; 21 | const nrWarnings = lintResult.warning.length; 22 | // Build and log a summary of linting errors/warnings 23 | if (nrWarnings > 0 && nrErrors > 0) { 24 | return `${nrErrors} error${nrErrors > 1 ? "s" : ""} and ${nrWarnings} warning${ 25 | nrWarnings > 1 ? "s" : "" 26 | }`; 27 | } 28 | if (nrErrors > 0) { 29 | return `${nrErrors} error${nrErrors > 1 ? "s" : ""}`; 30 | } 31 | if (nrWarnings > 0) { 32 | return `${nrWarnings} warning${nrWarnings > 1 ? "s" : ""}`; 33 | } 34 | return `no issues`; 35 | } 36 | 37 | module.exports = { 38 | getSummary, 39 | initLintResult, 40 | }; 41 | -------------------------------------------------------------------------------- /test/test-utils.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const DATE_REGEX = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d+ [+-]\d{4}/g; 4 | const TEST_DATE = "2019-01-01 00:00:00.000000 +0000"; 5 | 6 | const testProjectsDir = join(__dirname, "linters", "projects"); 7 | const tmpDir = join(__dirname, "tmp"); // Temporary directory that tests can write to 8 | 9 | /** 10 | * Some tools require paths to contain single forward slashes on macOS/Linux and double backslashes 11 | * on Windows. This is an extended `path.join` function that corrects these path separators 12 | * @param {...string} paths - Paths to join 13 | * @returns {string} - File path 14 | */ 15 | function joinDoubleBackslash(...paths) { 16 | let filePath = join(...paths); 17 | if (process.platform === "win32") { 18 | filePath = filePath.replace(/\\/g, "\\\\"); 19 | } 20 | return filePath; 21 | } 22 | 23 | /** 24 | * Find dates in the provided string and replace them with {@link TEST_DATE} 25 | * @param {string} str - String in which dates should be replaced 26 | * @returns {string} - Normalized date 27 | */ 28 | function normalizeDates(str) { 29 | return str.replace(DATE_REGEX, TEST_DATE); 30 | } 31 | 32 | module.exports = { TEST_DATE, joinDoubleBackslash, normalizeDates, testProjectsDir, tmpDir }; 33 | -------------------------------------------------------------------------------- /test/github/api.test.js: -------------------------------------------------------------------------------- 1 | const { createCheck } = require("../../src/github/api"); 2 | const { 3 | EVENT_NAME, 4 | EVENT_PATH, 5 | FORK_REPOSITORY, 6 | REPOSITORY, 7 | REPOSITORY_DIR, 8 | TOKEN, 9 | USERNAME, 10 | } = require("./test-constants"); 11 | 12 | jest.mock("../../src/utils/request", () => 13 | // eslint-disable-next-line global-require 14 | jest.fn().mockReturnValue(require("./api-responses/check-runs.json")), 15 | ); 16 | 17 | describe("createCheck()", () => { 18 | const LINT_RESULT = { 19 | isSuccess: true, 20 | warning: [], 21 | error: [], 22 | }; 23 | const context = { 24 | actor: USERNAME, 25 | event: {}, 26 | eventName: EVENT_NAME, 27 | eventPath: EVENT_PATH, 28 | repository: { 29 | repoName: REPOSITORY, 30 | forkName: FORK_REPOSITORY, 31 | hasFork: false, 32 | }, 33 | token: TOKEN, 34 | workspace: REPOSITORY_DIR, 35 | }; 36 | 37 | test("mocked request should be successful", async () => { 38 | await expect( 39 | createCheck("check-name", "sha", context, LINT_RESULT, "summary"), 40 | ).resolves.toEqual(undefined); 41 | }); 42 | 43 | test("mocked request should fail when no lint results are provided", async () => { 44 | await expect(createCheck("check-name", "sha", context, null, "summary")).rejects.toEqual( 45 | expect.any(Error), 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/linters/params/golint.js: -------------------------------------------------------------------------------- 1 | const Golint = require("../../../src/linters/golint"); 2 | 3 | const testName = "golint"; 4 | const linter = Golint; 5 | const extensions = ["go"]; 6 | 7 | // Linting without auto-fixing 8 | function getLintParams(dir) { 9 | const stdoutFile1 = 10 | "file1.go:14:9: if block ends with a return statement, so drop this else and outdent its block"; 11 | const stdoutFile2 = 12 | 'file2.go:3:1: comment on exported function Divide should be of the form "Divide ..."'; 13 | return { 14 | // Expected output of the linting function 15 | cmdOutput: { 16 | status: 1, 17 | stdoutParts: [stdoutFile1, stdoutFile2], 18 | stdout: `${stdoutFile1}\n${stdoutFile2}`, 19 | }, 20 | // Expected output of the parsing function 21 | lintResult: { 22 | isSuccess: false, 23 | warning: [], 24 | error: [ 25 | { 26 | path: "file1.go", 27 | firstLine: 14, 28 | lastLine: 14, 29 | message: `If block ends with a return statement, so drop this else and outdent its block`, 30 | }, 31 | { 32 | path: "file2.go", 33 | firstLine: 3, 34 | lastLine: 3, 35 | message: `Comment on exported function Divide should be of the form "Divide ..."`, 36 | }, 37 | ], 38 | }, 39 | }; 40 | } 41 | 42 | // Linting with auto-fixing 43 | const getFixParams = getLintParams; // Does not support auto-fixing -> option has no effect 44 | 45 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 46 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Source Code 4 | 5 | Parts of this action's code are inspired by the [ESLint Action](https://github.com/gimenete/eslint-action) by Alberto Gimeno. 6 | 7 | > MIT License 8 | > 9 | > Copyright (c) 2019 Alberto Gimeno 10 | > 11 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | ## Assets 18 | 19 | Lint Action GitHub profile picture: ["Clean free icon"](https://www.flaticon.com/free-icon/clean_2059802) designed by [Freepik](https://www.flaticon.com/authors/freepik) 20 | -------------------------------------------------------------------------------- /test/linters/params/mypy.js: -------------------------------------------------------------------------------- 1 | const { EOL } = require("os"); 2 | 3 | const Mypy = require("../../../src/linters/mypy"); 4 | 5 | const testName = "mypy"; 6 | const linter = Mypy; 7 | const extensions = ["py"]; 8 | 9 | // Linting without auto-fixing 10 | function getLintParams(dir) { 11 | const stdoutPart1 = `file1.py:7: error: Dict entry 0 has incompatible type "str": "int"; expected "str": "str"`; 12 | const stdoutPart2 = `file1.py:11: error: Argument 1 to "main" has incompatible type "List[str]"; expected "str"`; 13 | return { 14 | // Expected output of the linting function 15 | cmdOutput: { 16 | status: 1, 17 | stdoutParts: [stdoutPart1, stdoutPart2], 18 | stdout: `${stdoutPart1}${EOL}${stdoutPart2}`, 19 | }, 20 | // Expected output of the parsing function 21 | lintResult: { 22 | isSuccess: false, 23 | warning: [], 24 | error: [ 25 | { 26 | path: "file1.py", 27 | firstLine: 7, 28 | lastLine: 7, 29 | message: `Dict entry 0 has incompatible type "str": "int"; expected "str": "str"`, 30 | }, 31 | { 32 | path: "file1.py", 33 | firstLine: 11, 34 | lastLine: 11, 35 | message: `Argument 1 to "main" has incompatible type "List[str]"; expected "str"`, 36 | }, 37 | ], 38 | }, 39 | }; 40 | } 41 | 42 | // Linting with auto-fixing 43 | const getFixParams = getLintParams; // Does not support auto-fixing -> option has no effect 44 | 45 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 46 | -------------------------------------------------------------------------------- /test/linters/params/flake8.js: -------------------------------------------------------------------------------- 1 | const { EOL } = require("os"); 2 | const { sep } = require("path"); 3 | 4 | const Flake8 = require("../../../src/linters/flake8"); 5 | 6 | const testName = "flake8"; 7 | const linter = Flake8; 8 | const extensions = ["py"]; 9 | 10 | // Linting without auto-fixing 11 | function getLintParams(dir) { 12 | const stdoutFile1 = `.${sep}file1.py:5:9: E211 whitespace before '('${EOL}.${sep}file1.py:26:1: E305 expected 2 blank lines after class or function definition, found 1`; 13 | const stdoutFile2 = `.${sep}file2.py:2:3: E111 indentation is not a multiple of four`; 14 | return { 15 | // Expected output of the linting function 16 | cmdOutput: { 17 | status: 1, 18 | stdoutParts: [stdoutFile1, stdoutFile2], 19 | stdout: `${stdoutFile1}${EOL}${stdoutFile2}`, 20 | }, 21 | // Expected output of the parsing function 22 | lintResult: { 23 | isSuccess: false, 24 | warning: [], 25 | error: [ 26 | { 27 | path: "file1.py", 28 | firstLine: 5, 29 | lastLine: 5, 30 | message: "Whitespace before '(' (E211)", 31 | }, 32 | { 33 | path: "file1.py", 34 | firstLine: 26, 35 | lastLine: 26, 36 | message: "Expected 2 blank lines after class or function definition, found 1 (E305)", 37 | }, 38 | { 39 | path: "file2.py", 40 | firstLine: 2, 41 | lastLine: 2, 42 | message: "Indentation is not a multiple of four (E111)", 43 | }, 44 | ], 45 | }, 46 | }; 47 | } 48 | 49 | // Linting with auto-fixing 50 | const getFixParams = getLintParams; // Does not support auto-fixing -> option has no effect 51 | 52 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 53 | -------------------------------------------------------------------------------- /test/linters/params/prettier.js: -------------------------------------------------------------------------------- 1 | const Prettier = require("../../../src/linters/prettier"); 2 | 3 | const testName = "prettier"; 4 | const linter = Prettier; 5 | const extensions = [ 6 | "css", 7 | "html", 8 | "js", 9 | "json", 10 | "jsx", 11 | "md", 12 | "sass", 13 | "scss", 14 | "ts", 15 | "tsx", 16 | "vue", 17 | "yaml", 18 | "yml", 19 | ]; 20 | 21 | // Linting without auto-fixing 22 | function getLintParams(dir) { 23 | const stdoutFile1 = `file1.js`; 24 | const stdoutFile2 = `file2.css`; 25 | return { 26 | // Expected output of the linting function 27 | cmdOutput: { 28 | status: 1, 29 | stdoutParts: [stdoutFile1, stdoutFile2], 30 | stdout: `${stdoutFile1}\n${stdoutFile2}`, 31 | }, 32 | // Expected output of the parsing function 33 | lintResult: { 34 | isSuccess: false, 35 | warning: [], 36 | error: [ 37 | { 38 | path: "file1.js", 39 | firstLine: 1, 40 | lastLine: 1, 41 | message: 42 | "There are issues with this file's formatting, please run Prettier to fix the errors", 43 | }, 44 | { 45 | path: "file2.css", 46 | firstLine: 1, 47 | lastLine: 1, 48 | message: 49 | "There are issues with this file's formatting, please run Prettier to fix the errors", 50 | }, 51 | ], 52 | }, 53 | }; 54 | } 55 | 56 | // Linting with auto-fixing 57 | function getFixParams(dir) { 58 | const stdoutFile1 = `file1.js`; 59 | const stdoutFile2 = `file2.css`; 60 | return { 61 | // Expected output of the linting function 62 | cmdOutput: { 63 | status: 0, 64 | stdoutParts: [stdoutFile1, stdoutFile2], 65 | }, 66 | // Expected output of the parsing function 67 | lintResult: { 68 | isSuccess: true, 69 | warning: [], 70 | error: [], 71 | }, 72 | }; 73 | } 74 | 75 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lint-action", 3 | "version": "1.5.3", 4 | "description": "GitHub Action for detecting and fixing linting errors", 5 | "author": { 6 | "name": "Samuel Meuli", 7 | "email": "me@samuelmeuli.com", 8 | "url": "https://samuelmeuli.com" 9 | }, 10 | "repository": "github:samuelmeuli/lint-action", 11 | "license": "MIT", 12 | "private": true, 13 | "main": "./src/index.js", 14 | "scripts": { 15 | "test": "jest", 16 | "lint": "eslint --max-warnings 0 \"**/*.js\"", 17 | "lint:fix": "yarn lint --fix", 18 | "format": "prettier --list-different \"**/*.{css,html,js,json,jsx,less,md,scss,ts,tsx,vue,yaml,yml}\"", 19 | "format:fix": "yarn format --write" 20 | }, 21 | "dependencies": {}, 22 | "peerDependencies": {}, 23 | "devDependencies": { 24 | "@samuelmeuli/eslint-config": "^6.0.0", 25 | "@samuelmeuli/prettier-config": "^2.0.1", 26 | "eslint": "6.8.0", 27 | "eslint-config-airbnb-base": "14.1.0", 28 | "eslint-config-prettier": "^6.11.0", 29 | "eslint-plugin-import": "^2.20.2", 30 | "eslint-plugin-jsdoc": "^25.4.0", 31 | "fs-extra": "^9.0.0", 32 | "jest": "^26.0.1", 33 | "prettier": "^2.0.5" 34 | }, 35 | "eslintConfig": { 36 | "root": true, 37 | "extends": [ 38 | "@samuelmeuli/eslint-config", 39 | "plugin:jsdoc/recommended" 40 | ], 41 | "env": { 42 | "node": true, 43 | "jest": true 44 | }, 45 | "rules": { 46 | "no-await-in-loop": "off", 47 | "no-unused-vars": [ 48 | "error", 49 | { 50 | "args": "none", 51 | "varsIgnorePattern": "^_" 52 | } 53 | ], 54 | "jsdoc/check-indentation": "error", 55 | "jsdoc/check-syntax": "error", 56 | "jsdoc/newline-after-description": [ 57 | "error", 58 | "never" 59 | ], 60 | "jsdoc/require-description": "error", 61 | "jsdoc/require-hyphen-before-param-description": "error", 62 | "jsdoc/require-jsdoc": "off" 63 | } 64 | }, 65 | "eslintIgnore": [ 66 | "node_modules/", 67 | "test/linters/projects/", 68 | "test/tmp/", 69 | "vendor/" 70 | ], 71 | "jest": { 72 | "globalSetup": "./test/setup.js", 73 | "globalTeardown": "./test/teardown.js" 74 | }, 75 | "prettier": "@samuelmeuli/prettier-config" 76 | } 77 | -------------------------------------------------------------------------------- /src/linters/xo.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { getNpmBinCommand } = require("../utils/npm/get-npm-bin-command"); 4 | const ESLint = require("./eslint"); 5 | 6 | /** 7 | * https://github.com/xojs/xo 8 | * XO is a wrapper for ESLint, so it can use the same logic for parsing lint results 9 | */ 10 | class XO extends ESLint { 11 | static get name() { 12 | return "XO"; 13 | } 14 | 15 | /** 16 | * Verifies that all required programs are installed. Throws an error if programs are missing 17 | * @param {string} dir - Directory to run the linting program in 18 | * @param {string} prefix - Prefix to the lint command 19 | */ 20 | static async verifySetup(dir, prefix = "") { 21 | // Verify that NPM is installed (required to execute XO) 22 | if (!(await commandExists("npm"))) { 23 | throw new Error("NPM is not installed"); 24 | } 25 | 26 | // Verify that XO is installed 27 | const commandPrefix = prefix || getNpmBinCommand(dir); 28 | try { 29 | run(`${commandPrefix} xo --version`, { dir }); 30 | } catch (err) { 31 | throw new Error(`${this.name} is not installed`); 32 | } 33 | } 34 | 35 | /** 36 | * Runs the linting program and returns the command output 37 | * @param {string} dir - Directory to run the linter in 38 | * @param {string[]} extensions - File extensions which should be linted 39 | * @param {string} args - Additional arguments to pass to the linter 40 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 41 | * @param {string} prefix - Prefix to the lint command 42 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 43 | */ 44 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 45 | const extensionArgs = extensions.map((ext) => `--extension ${ext}`).join(" "); 46 | const fixArg = fix ? "--fix" : ""; 47 | const commandPrefix = prefix || getNpmBinCommand(dir); 48 | return run(`${commandPrefix} xo ${extensionArgs} ${fixArg} --reporter json ${args} "."`, { 49 | dir, 50 | ignoreErrors: true, 51 | }); 52 | } 53 | } 54 | 55 | module.exports = XO; 56 | -------------------------------------------------------------------------------- /test/linters/params/swift-format-official.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const SwiftFormatOfficial = require("../../../src/linters/swift-format-official"); 4 | 5 | const testName = "swift-format-official"; 6 | const linter = SwiftFormatOfficial; 7 | const extensions = ["swift"]; 8 | 9 | function getLintParams(dir) { 10 | const warning1 = `${join(dir, "file2.swift")}:2:22: warning: [DoNotUseSemicolons]: remove ';'`; 11 | const warning2 = `${join(dir, "file1.swift")}:3:35: warning: [RemoveLine]: remove line break`; 12 | const warning3 = `${join( 13 | dir, 14 | "file1.swift", 15 | )}:7:1: warning: [Indentation] replace leading whitespace with 2 spaces`; 16 | const warning4 = `${join(dir, "file1.swift")}:7:23: warning: [Spacing]: add 1 space`; 17 | return { 18 | // Expected output of the linting function. 19 | cmdOutput: { 20 | status: 1, 21 | stderrParts: [warning1, warning2, warning3, warning4], 22 | stderr: `${warning1}\n${warning2}\n${warning3}\n${warning4}`, 23 | }, 24 | // Expected output of the parsing function. 25 | lintResult: { 26 | isSuccess: false, 27 | error: [ 28 | { 29 | path: "file2.swift", 30 | firstLine: 2, 31 | lastLine: 2, 32 | message: "[DoNotUseSemicolons]: remove ';'", 33 | }, 34 | { 35 | path: "file1.swift", 36 | firstLine: 3, 37 | lastLine: 3, 38 | message: "[RemoveLine]: remove line break", 39 | }, 40 | { 41 | path: "file1.swift", 42 | firstLine: 7, 43 | lastLine: 7, 44 | message: "[Indentation] replace leading whitespace with 2 spaces", 45 | }, 46 | { 47 | path: "file1.swift", 48 | firstLine: 7, 49 | lastLine: 7, 50 | message: "[Spacing]: add 1 space", 51 | }, 52 | ], 53 | warning: [], 54 | }, 55 | }; 56 | } 57 | 58 | function getFixParams(dir) { 59 | return { 60 | // Expected output of the linting function. 61 | cmdOutput: { 62 | status: 0, 63 | stderr: "", 64 | }, 65 | // Expected output of the parsing function. 66 | lintResult: { 67 | isSuccess: true, 68 | warning: [], 69 | error: [], 70 | }, 71 | }; 72 | } 73 | 74 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 75 | -------------------------------------------------------------------------------- /test/linters/params/black.js: -------------------------------------------------------------------------------- 1 | const Black = require("../../../src/linters/black"); 2 | const { TEST_DATE } = require("../../test-utils"); 3 | 4 | const testName = "black"; 5 | const linter = Black; 6 | const extensions = ["py"]; 7 | 8 | // Linting without auto-fixing 9 | function getLintParams(dir) { 10 | const stdoutFile1 = `--- file1.py ${TEST_DATE}\n+++ file1.py ${TEST_DATE}\n@@ -1,10 +1,10 @@\n var_1 = "hello"\n var_2 = "world"\n \n \n-def main (): # Whitespace error\n+def main(): # Whitespace error\n print("hello " + var_2)\n \n \n def add(num_1, num_2):\n return num_1 + num_2\n@@ -19,9 +19,10 @@\n \n \n def divide(num_1, num_2):\n return num_1 / num_2\n \n+\n # Blank lines error\n \n main()`; 11 | const stdoutFile2 = `--- file2.py ${TEST_DATE}\n+++ file2.py ${TEST_DATE}\n@@ -1,3 +1,3 @@\n def add(num_1, num_2):\n- return num_1 + num_2 # Indentation error\n+ return num_1 + num_2 # Indentation error`; 12 | return { 13 | // Expected output of the linting function 14 | cmdOutput: { 15 | status: 1, 16 | stdoutParts: [stdoutFile1, stdoutFile2], 17 | stdout: `${stdoutFile1}\n \n${stdoutFile2}`, 18 | }, 19 | // Expected output of the parsing function 20 | lintResult: { 21 | isSuccess: false, 22 | warning: [], 23 | error: [ 24 | { 25 | path: "file1.py", 26 | firstLine: 1, 27 | lastLine: 11, 28 | message: ` var_1 = "hello"\n var_2 = "world"\n \n \n-def main (): # Whitespace error\n+def main(): # Whitespace error\n print("hello " + var_2)\n \n \n def add(num_1, num_2):\n return num_1 + num_2`, 29 | }, 30 | { 31 | path: "file1.py", 32 | firstLine: 19, 33 | lastLine: 28, 34 | message: ` \n \n def divide(num_1, num_2):\n return num_1 / num_2\n \n+\n # Blank lines error\n \n main()\n `, 35 | }, 36 | { 37 | path: "file2.py", 38 | firstLine: 1, 39 | lastLine: 4, 40 | message: ` def add(num_1, num_2):\n- return num_1 + num_2 # Indentation error\n+ return num_1 + num_2 # Indentation error`, 41 | }, 42 | ], 43 | }, 44 | }; 45 | } 46 | 47 | // Linting with auto-fixing 48 | function getFixParams(dir) { 49 | return { 50 | // Expected output of the linting function 51 | cmdOutput: { 52 | status: 0, 53 | stdout: "", 54 | }, 55 | // Expected output of the parsing function 56 | lintResult: { 57 | isSuccess: true, 58 | warning: [], 59 | error: [], 60 | }, 61 | }; 62 | } 63 | 64 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 65 | -------------------------------------------------------------------------------- /test/linters/params/swift-format-lockwood.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const SwiftFormatLockwood = require("../../../src/linters/swift-format-lockwood"); 4 | 5 | const testName = "swift-format-lockwood"; 6 | const linter = SwiftFormatLockwood; 7 | const extensions = ["swift"]; 8 | 9 | // Linting without auto-fixing 10 | function getLintParams(dir) { 11 | const warning1 = `${join( 12 | dir, 13 | "file1.swift", 14 | )}:3:1: warning: (consecutiveBlankLines) Replace consecutive blank lines with a single blank line.`; 15 | const warning2 = `${join( 16 | dir, 17 | "file1.swift", 18 | )}:7:1: warning: (indent) Indent code in accordance with the scope level.`; 19 | const warning3 = `${join(dir, "file2.swift")}:2:1: warning: (semicolons) Remove semicolons.`; 20 | return { 21 | // Expected output of the linting function 22 | cmdOutput: { 23 | status: 1, 24 | stderrParts: [warning1, warning2, warning3], 25 | stderr: `Running SwiftFormat...\n(lint mode - no files will be changed.)\n${warning1}\n${warning2}\n${warning3}\nwarning: No swift version was specified, so some formatting features were disabled. Specify the version of swift you are using with the --swiftversion command line option, or by adding a .swift-version file to your project.\nSwiftFormat completed in 0.01s.\nSource input did not pass lint check.\n2/2 files require formatting.`, 26 | }, 27 | // Expected output of the parsing function 28 | lintResult: { 29 | isSuccess: false, 30 | warning: [], 31 | error: [ 32 | { 33 | path: "file1.swift", 34 | firstLine: 3, 35 | lastLine: 3, 36 | message: 37 | "Replace consecutive blank lines with a single blank line (consecutiveBlankLines)", 38 | }, 39 | { 40 | path: "file1.swift", 41 | firstLine: 7, 42 | lastLine: 7, 43 | message: "Indent code in accordance with the scope level (indent)", 44 | }, 45 | { 46 | path: "file2.swift", 47 | firstLine: 2, 48 | lastLine: 2, 49 | message: "Remove semicolons (semicolons)", 50 | }, 51 | ], 52 | }, 53 | }; 54 | } 55 | 56 | // Linting with auto-fixing 57 | function getFixParams(dir) { 58 | return { 59 | // Expected output of the linting function 60 | cmdOutput: { 61 | status: 0, 62 | stderr: "", 63 | }, 64 | // Expected output of the parsing function 65 | lintResult: { 66 | isSuccess: true, 67 | warning: [], 68 | error: [], 69 | }, 70 | }; 71 | } 72 | 73 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 74 | -------------------------------------------------------------------------------- /src/linters/black.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { parseErrorsFromDiff } = require("../utils/diff"); 4 | const { initLintResult } = require("../utils/lint-result"); 5 | 6 | /** 7 | * https://black.readthedocs.io 8 | */ 9 | class Black { 10 | static get name() { 11 | return "Black"; 12 | } 13 | 14 | /** 15 | * Verifies that all required programs are installed. Throws an error if programs are missing 16 | * @param {string} dir - Directory to run the linting program in 17 | * @param {string} prefix - Prefix to the lint command 18 | */ 19 | static async verifySetup(dir, prefix = "") { 20 | // Verify that Python is installed (required to execute Black) 21 | if (!(await commandExists("python"))) { 22 | throw new Error("Python is not installed"); 23 | } 24 | 25 | // Verify that Black is installed 26 | try { 27 | run(`${prefix} black --version`, { dir }); 28 | } catch (err) { 29 | throw new Error(`${this.name} is not installed`); 30 | } 31 | } 32 | 33 | /** 34 | * Runs the linting program and returns the command output 35 | * @param {string} dir - Directory to run the linter in 36 | * @param {string[]} extensions - File extensions which should be linted 37 | * @param {string} args - Additional arguments to pass to the linter 38 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 39 | * @param {string} prefix - Prefix to the lint command 40 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 41 | */ 42 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 43 | const files = `^.*\\.(${extensions.join("|")})$`; 44 | const fixArg = fix ? "" : "--check --diff"; 45 | return run(`${prefix} black ${fixArg} --include "${files}" ${args} "."`, { 46 | dir, 47 | ignoreErrors: true, 48 | }); 49 | } 50 | 51 | /** 52 | * Parses the output of the lint command. Determines the success of the lint process and the 53 | * severity of the identified code style violations 54 | * @param {string} dir - Directory in which the linter has been run 55 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 56 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 57 | */ 58 | static parseOutput(dir, output) { 59 | const lintResult = initLintResult(); 60 | lintResult.error = parseErrorsFromDiff(output.stdout); 61 | lintResult.isSuccess = output.status === 0; 62 | return lintResult; 63 | } 64 | } 65 | 66 | module.exports = Black; 67 | -------------------------------------------------------------------------------- /test/linters/params/gofmt.js: -------------------------------------------------------------------------------- 1 | const Gofmt = require("../../../src/linters/gofmt"); 2 | const { TEST_DATE } = require("../../test-utils"); 3 | 4 | const testName = "gofmt"; 5 | const linter = Gofmt; 6 | const extensions = ["go"]; 7 | 8 | // Linting without auto-fixing 9 | function getLintParams(dir) { 10 | const stdoutFile1 = `diff -u file1.go.orig file1.go\n--- file1.go.orig ${TEST_DATE}\n+++ file1.go ${TEST_DATE}\n@@ -4,7 +4,7 @@\n \n var str = "world"\n \n-func main () { // Whitespace error\n+func main() { // Whitespace error\n fmt.Println("hello " + str)\n }\n \n@@ -17,5 +17,5 @@\n }\n \n func multiply(num1 int, num2 int) int {\n- return num1 * num2 // Indentation error\n+ return num1 * num2 // Indentation error\n }`; 11 | const stdoutFile2 = `diff -u file2.go.orig file2.go\n--- file2.go.orig ${TEST_DATE}\n+++ file2.go ${TEST_DATE}\n@@ -1,5 +1,5 @@\n package main\n \n func divide(num1 int, num2 int) int {\n- return num1 / num2 // Whitespace error\n+ return num1 / num2 // Whitespace error\n }`; 12 | return { 13 | // Expected output of the linting function 14 | cmdOutput: { 15 | status: 0, // gofmt always uses exit code 0 16 | stdoutParts: [stdoutFile1, stdoutFile2], 17 | stdout: `${stdoutFile1}\n${stdoutFile2}`, 18 | }, 19 | // Expected output of the parsing function 20 | lintResult: { 21 | isSuccess: false, 22 | warning: [], 23 | error: [ 24 | { 25 | path: "file1.go", 26 | firstLine: 4, 27 | lastLine: 11, 28 | message: ` \n var str = "world"\n \n-func main () { // Whitespace error\n+func main() { // Whitespace error\n \tfmt.Println("hello " + str)\n }\n `, 29 | }, 30 | { 31 | path: "file1.go", 32 | firstLine: 17, 33 | lastLine: 22, 34 | message: ` }\n \n func multiply(num1 int, num2 int) int {\n- return num1 * num2 // Indentation error\n+\treturn num1 * num2 // Indentation error\n }`, 35 | }, 36 | { 37 | path: "file2.go", 38 | firstLine: 1, 39 | lastLine: 6, 40 | message: ` package main\n \n func divide(num1 int, num2 int) int {\n-\treturn num1 / num2 // Whitespace error\n+\treturn num1 / num2 // Whitespace error\n }`, 41 | }, 42 | ], 43 | }, 44 | }; 45 | } 46 | 47 | // Linting with auto-fixing 48 | function getFixParams(dir) { 49 | return { 50 | // Expected output of the linting function 51 | cmdOutput: { 52 | status: 0, // gofmt always uses exit code 0 53 | stdout: "", 54 | }, 55 | // Expected output of the parsing function 56 | lintResult: { 57 | isSuccess: true, 58 | warning: [], 59 | error: [], 60 | }, 61 | }; 62 | } 63 | 64 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 65 | -------------------------------------------------------------------------------- /src/github/api.js: -------------------------------------------------------------------------------- 1 | const { name: actionName } = require("../../package"); 2 | const { log } = require("../utils/action"); 3 | const request = require("../utils/request"); 4 | const { capitalizeFirstLetter } = require("../utils/string"); 5 | 6 | /** 7 | * Creates a new check on GitHub which annotates the relevant commit with linting errors 8 | * @param {string} linterName - Name of the linter for which a check should be created 9 | * @param {string} sha - SHA of the commit which should be annotated 10 | * @param {import('./context').GithubContext} context - Information about the GitHub repository and 11 | * action trigger event 12 | * @param {{isSuccess: boolean, warning: [], error: []}} lintResult - Parsed lint result 13 | * @param {string} summary - Summary for the GitHub check 14 | */ 15 | async function createCheck(linterName, sha, context, lintResult, summary) { 16 | let annotations = []; 17 | for (const level of ["warning", "error"]) { 18 | annotations = [ 19 | ...annotations, 20 | ...lintResult[level].map((result) => ({ 21 | path: result.path, 22 | start_line: result.firstLine, 23 | end_line: result.lastLine, 24 | annotation_level: level === "warning" ? "warning" : "failure", 25 | message: result.message, 26 | })), 27 | ]; 28 | } 29 | 30 | // Only use the first 50 annotations (limit for a single API request) 31 | if (annotations.length > 50) { 32 | log( 33 | `There are more than 50 errors/warnings from ${linterName}. Annotations are created for the first 50 issues only.`, 34 | ); 35 | annotations = annotations.slice(0, 50); 36 | } 37 | 38 | const body = { 39 | name: linterName, 40 | head_sha: sha, 41 | conclusion: lintResult.isSuccess ? "success" : "failure", 42 | output: { 43 | title: capitalizeFirstLetter(summary), 44 | summary: `${linterName} found ${summary}`, 45 | annotations, 46 | }, 47 | }; 48 | try { 49 | log(`Creating GitHub check with ${annotations.length} annotations for ${linterName}…`); 50 | await request(`https://api.github.com/repos/${context.repository.repoName}/check-runs`, { 51 | method: "POST", 52 | headers: { 53 | "Content-Type": "application/json", 54 | // "Accept" header is required to access Checks API during preview period 55 | Accept: "application/vnd.github.antiope-preview+json", 56 | Authorization: `Bearer ${context.token}`, 57 | "User-Agent": actionName, 58 | }, 59 | body, 60 | }); 61 | log(`${linterName} check created successfully`); 62 | } catch (err) { 63 | log(err, "error"); 64 | throw new Error(`Error trying to create GitHub check for ${linterName}: ${err.message}`); 65 | } 66 | } 67 | 68 | module.exports = { createCheck }; 69 | -------------------------------------------------------------------------------- /test/linters/params/swiftlint.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const Swiftlint = require("../../../src/linters/swiftlint"); 4 | 5 | const testName = "swiftlint"; 6 | const linter = Swiftlint; 7 | const extensions = ["swift"]; 8 | 9 | // Linting without auto-fixing 10 | function getLintParams(dir) { 11 | const stdoutFile1 = `${join( 12 | dir, 13 | "file1.swift", 14 | )}:5:1: warning: Vertical Whitespace Violation: Limit vertical whitespace to a single empty line. Currently 2. (vertical_whitespace)`; 15 | const stdoutFile2 = `${join( 16 | dir, 17 | "file2.swift", 18 | )}:2:22: error: Trailing Semicolon Violation: Lines should not have trailing semicolons. (trailing_semicolon)`; 19 | return { 20 | // Expected output of the linting function 21 | cmdOutput: { 22 | // SwiftLint exit codes: 23 | // - 0: No errors 24 | // - 1: Usage or system error 25 | // - 2: Style violations of severity "Error" 26 | // - 3: No style violations of severity "Error", but severity "Warning" with --strict 27 | status: 2, 28 | stdoutParts: [stdoutFile1, stdoutFile2], 29 | stdout: `${stdoutFile1}\n${stdoutFile2}`, 30 | }, 31 | // Expected output of the parsing function 32 | lintResult: { 33 | isSuccess: false, 34 | warning: [ 35 | { 36 | path: "file1.swift", 37 | firstLine: 5, 38 | lastLine: 5, 39 | message: 40 | "Vertical Whitespace Violation: Limit vertical whitespace to a single empty line. Currently 2. (vertical_whitespace)", 41 | }, 42 | ], 43 | error: [ 44 | { 45 | path: "file2.swift", 46 | firstLine: 2, 47 | lastLine: 2, 48 | message: 49 | "Trailing Semicolon Violation: Lines should not have trailing semicolons. (trailing_semicolon)", 50 | }, 51 | ], 52 | }, 53 | }; 54 | } 55 | 56 | // Linting with auto-fixing 57 | function getFixParams(dir) { 58 | const stdoutFile1 = `${join(dir, "file1.swift")}:4:1 Corrected Vertical Whitespace`; 59 | const stdoutFile2 = `${join(dir, "file2.swift")}:2:22 Corrected Trailing Semicolon`; 60 | return { 61 | // Expected output of the linting function 62 | cmdOutput: { 63 | // SwiftLint exit codes: 64 | // - 0: No errors 65 | // - 1: Usage or system error 66 | // - 2: Style violations of severity "Error" 67 | // - 3: No style violations of severity "Error", but severity "Warning" with --strict 68 | status: 0, 69 | stdoutParts: [stdoutFile1, stdoutFile2], 70 | stdout: `${stdoutFile1}\n${stdoutFile2}`, 71 | }, 72 | // Expected output of the parsing function 73 | lintResult: { 74 | isSuccess: true, 75 | warning: [], 76 | error: [], 77 | }, 78 | }; 79 | } 80 | 81 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 82 | -------------------------------------------------------------------------------- /src/utils/action.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require("child_process"); 2 | 3 | const RUN_OPTIONS_DEFAULTS = { dir: null, ignoreErrors: false, prefix: "" }; 4 | 5 | /** 6 | * Logs to the console 7 | * @param {string} msg - Text to log to the console 8 | * @param {"info" | "warning" | "error"} level - Log level 9 | */ 10 | function log(msg, level = "info") { 11 | switch (level) { 12 | case "error": 13 | console.error(msg); 14 | break; 15 | case "warning": 16 | console.warn(msg); // eslint-disable-line no-console 17 | break; 18 | default: 19 | console.log(msg); // eslint-disable-line no-console 20 | } 21 | } 22 | 23 | /** 24 | * Returns the value for an environment variable. If the variable is required but doesn't have a 25 | * value, an error is thrown 26 | * @param {string} name - Name of the environment variable 27 | * @param {boolean} required - Whether an error should be thrown if the variable doesn't have a 28 | * value 29 | * @returns {string | null} - Value of the environment variable 30 | */ 31 | function getEnv(name, required = false) { 32 | const nameUppercase = name.toUpperCase(); 33 | const value = process.env[nameUppercase]; 34 | if (value == null) { 35 | // Value is either not set (`undefined`) or set to `null` 36 | if (required) { 37 | throw new Error(`Environment variable "${nameUppercase}" is not defined`); 38 | } 39 | return null; 40 | } 41 | return value; 42 | } 43 | 44 | /** 45 | * Returns the value for an input variable. If the variable is required but doesn't have a value, 46 | * an error is thrown 47 | * @param {string} name - Name of the input variable 48 | * @param {boolean} required - Whether an error should be thrown if the variable doesn't have a 49 | * value 50 | * @returns {string | null} - Value of the input variable 51 | */ 52 | function getInput(name, required = false) { 53 | return getEnv(`INPUT_${name}`, required); 54 | } 55 | 56 | /** 57 | * Executes the provided shell command 58 | * @param {string} cmd - Shell command to execute 59 | * @param {{dir: string, ignoreErrors: boolean}} [options] - {@see RUN_OPTIONS_DEFAULTS} 60 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the shell command 61 | */ 62 | function run(cmd, options) { 63 | const optionsWithDefaults = { 64 | ...RUN_OPTIONS_DEFAULTS, 65 | ...options, 66 | }; 67 | 68 | try { 69 | const output = execSync(cmd, { encoding: "utf8", cwd: optionsWithDefaults.dir }); 70 | return { 71 | status: 0, 72 | stdout: output.trim(), 73 | stderr: "", 74 | }; 75 | } catch (err) { 76 | if (optionsWithDefaults.ignoreErrors) { 77 | return { 78 | status: err.status, 79 | stdout: err.stdout.trim(), 80 | stderr: err.stderr.trim(), 81 | }; 82 | } 83 | throw err; 84 | } 85 | } 86 | 87 | module.exports = { 88 | log, 89 | getEnv, 90 | getInput, 91 | run, 92 | }; 93 | -------------------------------------------------------------------------------- /src/linters/swiftlint.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | 5 | const PARSE_REGEX = /^(.*):([0-9]+):[0-9]+: (warning|error): (.*)$/gm; 6 | 7 | /** 8 | * https://github.com/realm/SwiftLint 9 | */ 10 | class SwiftLint { 11 | static get name() { 12 | return "SwiftLint"; 13 | } 14 | 15 | /** 16 | * Verifies that all required programs are installed. Throws an error if programs are missing 17 | * @param {string} dir - Directory to run the linting program in 18 | * @param {string} prefix - Prefix to the lint command 19 | */ 20 | static async verifySetup(dir, prefix = "") { 21 | // Verify that SwiftLint is installed 22 | if (!(await commandExists("swiftlint"))) { 23 | throw new Error(`${this.name} is not installed`); 24 | } 25 | } 26 | 27 | /** 28 | * Runs the linting program and returns the command output 29 | * @param {string} dir - Directory to run the linter in 30 | * @param {string[]} extensions - File extensions which should be linted 31 | * @param {string} args - Additional arguments to pass to the linter 32 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 33 | * @param {string} prefix - Prefix to the lint command 34 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 35 | */ 36 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 37 | if (extensions.length !== 1 || extensions[0] !== "swift") { 38 | throw new Error(`${this.name} error: File extensions are not configurable`); 39 | } 40 | 41 | const fixArg = fix ? "autocorrect" : "lint"; 42 | return run(`${prefix} swiftlint ${fixArg} ${args}`, { 43 | dir, 44 | ignoreErrors: true, 45 | }); 46 | } 47 | 48 | /** 49 | * Parses the output of the lint command. Determines the success of the lint process and the 50 | * severity of the identified code style violations 51 | * @param {string} dir - Directory in which the linter has been run 52 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 53 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 54 | */ 55 | static parseOutput(dir, output) { 56 | const lintResult = initLintResult(); 57 | lintResult.isSuccess = output.status === 0; 58 | 59 | const matches = output.stdout.matchAll(PARSE_REGEX); 60 | for (const match of matches) { 61 | const [_, pathFull, line, level, message] = match; 62 | const path = pathFull.substring(dir.length + 1); 63 | const lineNr = parseInt(line, 10); 64 | lintResult[level].push({ 65 | path, 66 | firstLine: lineNr, 67 | lastLine: lineNr, 68 | message, 69 | }); 70 | } 71 | 72 | return lintResult; 73 | } 74 | } 75 | 76 | module.exports = SwiftLint; 77 | -------------------------------------------------------------------------------- /src/linters/golint.js: -------------------------------------------------------------------------------- 1 | const { log, run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | const { capitalizeFirstLetter } = require("../utils/string"); 5 | 6 | const PARSE_REGEX = /^(.+):([0-9]+):[0-9]+: (.+)$/gm; 7 | 8 | /** 9 | * https://github.com/golang/lint 10 | */ 11 | class Golint { 12 | static get name() { 13 | return "golint"; 14 | } 15 | 16 | /** 17 | * Verifies that all required programs are installed. Throws an error if programs are missing 18 | * @param {string} dir - Directory to run the linting program in 19 | * @param {string} prefix - Prefix to the lint command 20 | */ 21 | static async verifySetup(dir, prefix = "") { 22 | // Verify that golint is installed 23 | if (!(await commandExists("golint"))) { 24 | throw new Error(`${this.name} is not installed`); 25 | } 26 | } 27 | 28 | /** 29 | * Runs the linting program and returns the command output 30 | * @param {string} dir - Directory to run the linter in 31 | * @param {string[]} extensions - File extensions which should be linted 32 | * @param {string} args - Additional arguments to pass to the linter 33 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 34 | * @param {string} prefix - Prefix to the lint command 35 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 36 | */ 37 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 38 | if (extensions.length !== 1 || extensions[0] !== "go") { 39 | throw new Error(`${this.name} error: File extensions are not configurable`); 40 | } 41 | if (fix) { 42 | log(`${this.name} does not support auto-fixing`, "warning"); 43 | } 44 | 45 | return run(`${prefix} golint -set_exit_status ${args} "."`, { 46 | dir, 47 | ignoreErrors: true, 48 | }); 49 | } 50 | 51 | /** 52 | * Parses the output of the lint command. Determines the success of the lint process and the 53 | * severity of the identified code style violations 54 | * @param {string} dir - Directory in which the linter has been run 55 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 56 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 57 | */ 58 | static parseOutput(dir, output) { 59 | const lintResult = initLintResult(); 60 | lintResult.isSuccess = output.status === 0; 61 | 62 | const matches = output.stdout.matchAll(PARSE_REGEX); 63 | for (const match of matches) { 64 | const [_, path, line, text] = match; 65 | const lineNr = parseInt(line, 10); 66 | lintResult.error.push({ 67 | path, 68 | firstLine: lineNr, 69 | lastLine: lineNr, 70 | message: capitalizeFirstLetter(text), 71 | }); 72 | } 73 | 74 | return lintResult; 75 | } 76 | } 77 | 78 | module.exports = Golint; 79 | -------------------------------------------------------------------------------- /src/git.js: -------------------------------------------------------------------------------- 1 | const { log, run } = require("./utils/action"); 2 | 3 | /** 4 | * Fetches and checks out the remote Git branch (if it exists, the fork repository will be used) 5 | * @param {import('./github/context').GithubContext} context - Information about the GitHub 6 | */ 7 | function checkOutRemoteBranch(context) { 8 | if (context.repository.hasFork) { 9 | // Fork: Add fork repo as remote 10 | log(`Adding "${context.repository.forkName}" fork as remote with Git`); 11 | run( 12 | `git remote add fork https://${context.actor}:${context.token}@github.com/${context.repository.forkName}.git`, 13 | ); 14 | } else { 15 | // No fork: Update remote URL to include auth information (so auto-fixes can be pushed) 16 | log(`Adding auth information to Git remote URL`); 17 | run( 18 | `git remote set-url origin https://${context.actor}:${context.token}@github.com/${context.repository.repoName}.git`, 19 | ); 20 | } 21 | 22 | const remote = context.repository.hasFork ? "fork" : "origin"; 23 | 24 | // Fetch remote branch 25 | log(`Fetching remote branch "${context.branch}"`); 26 | run(`git fetch --no-tags --depth=1 ${remote} ${context.branch}`); 27 | 28 | // Switch to remote branch 29 | log(`Switching to the "${context.branch}" branch`); 30 | run(`git branch --force ${context.branch} --track ${remote}/${context.branch}`); 31 | run(`git checkout ${context.branch}`); 32 | } 33 | 34 | /** 35 | * Stages and commits all changes using Git 36 | * @param {string} message - Git commit message 37 | */ 38 | function commitChanges(message) { 39 | log(`Committing changes`); 40 | run(`git commit -am "${message}"`); 41 | } 42 | 43 | /** 44 | * Returns the SHA of the head commit 45 | * @returns {string} - Head SHA 46 | */ 47 | function getHeadSha() { 48 | const sha = run("git rev-parse HEAD").stdout; 49 | log(`SHA of last commit is "${sha}"`); 50 | return sha; 51 | } 52 | 53 | /** 54 | * Checks whether there are differences from HEAD 55 | * @returns {boolean} - Boolean indicating whether changes exist 56 | */ 57 | function hasChanges() { 58 | const res = run("git diff-index --quiet HEAD --", { ignoreErrors: true }).status === 1; 59 | log(`${res ? "Changes" : "No changes"} found with Git`); 60 | return res; 61 | } 62 | 63 | /** 64 | * Pushes all changes to the remote repository 65 | */ 66 | function pushChanges() { 67 | log("Pushing changes with Git"); 68 | run("git push"); 69 | } 70 | 71 | /** 72 | * Updates the global Git configuration with the provided information 73 | * @param {string} name - Git username 74 | * @param {string} email - Git email address 75 | */ 76 | function setUserInfo(name, email) { 77 | log(`Setting Git user information`); 78 | run(`git config --global user.name "${name}"`); 79 | run(`git config --global user.email "${email}"`); 80 | } 81 | 82 | module.exports = { 83 | checkOutRemoteBranch, 84 | commitChanges, 85 | getHeadSha, 86 | hasChanges, 87 | pushChanges, 88 | setUserInfo, 89 | }; 90 | -------------------------------------------------------------------------------- /test/linters/params/stylelint.js: -------------------------------------------------------------------------------- 1 | const Stylelint = require("../../../src/linters/stylelint"); 2 | const { joinDoubleBackslash } = require("../../test-utils"); 3 | 4 | const testName = "stylelint"; 5 | const linter = Stylelint; 6 | const extensions = ["css", "sass", "scss"]; 7 | 8 | // Linting without auto-fixing 9 | function getLintParams(dir) { 10 | const stdoutFile1 = `{"source":"${joinDoubleBackslash( 11 | dir, 12 | "file1.css", 13 | )}","deprecations":[],"invalidOptionWarnings":[],"parseErrors":[],"errored":false,"warnings":[{"line":2,"column":13,"rule":"no-extra-semicolons","severity":"warning","text":"Unexpected extra semicolon (no-extra-semicolons)"}]}`; 14 | const stdoutFile2 = `{"source":"${joinDoubleBackslash( 15 | dir, 16 | "file2.scss", 17 | )}","deprecations":[],"invalidOptionWarnings":[],"parseErrors":[],"errored":true,"warnings":[{"line":1,"column":6,"rule":"block-no-empty","severity":"error","text":"Unexpected empty block (block-no-empty)"}]}`; 18 | return { 19 | // Expected output of the linting function 20 | cmdOutput: { 21 | status: 2, // stylelint exits with the highest severity index found (warning = 1, error = 2) 22 | stdoutParts: [stdoutFile1, stdoutFile2], 23 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 24 | }, 25 | // Expected output of the parsing function 26 | lintResult: { 27 | isSuccess: false, 28 | warning: [ 29 | { 30 | path: "file1.css", 31 | firstLine: 2, 32 | lastLine: 2, 33 | message: "Unexpected extra semicolon (no-extra-semicolons)", 34 | }, 35 | ], 36 | error: [ 37 | { 38 | path: "file2.scss", 39 | firstLine: 1, 40 | lastLine: 1, 41 | message: "Unexpected empty block (block-no-empty)", 42 | }, 43 | ], 44 | }, 45 | }; 46 | } 47 | 48 | // Linting with auto-fixing 49 | function getFixParams(dir) { 50 | const stdoutFile1 = `{"source":"${joinDoubleBackslash( 51 | dir, 52 | "file1.css", 53 | )}","deprecations":[],"invalidOptionWarnings":[],"parseErrors":[],"errored":false,"warnings":[]}`; 54 | const stdoutFile2 = `{"source":"${joinDoubleBackslash( 55 | dir, 56 | "file2.scss", 57 | )}","deprecations":[],"invalidOptionWarnings":[],"parseErrors":[],"errored":true,"warnings":[{"line":1,"column":6,"rule":"block-no-empty","severity":"error","text":"Unexpected empty block (block-no-empty)"}]}`; 58 | return { 59 | // Expected output of the linting function 60 | cmdOutput: { 61 | status: 2, // stylelint exits with the highest severity index found (warning = 1, error = 2) 62 | stdoutParts: [stdoutFile1, stdoutFile2], 63 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 64 | }, 65 | // Expected output of the parsing function 66 | lintResult: { 67 | isSuccess: false, 68 | warning: [], 69 | error: [ 70 | { 71 | path: "file2.scss", 72 | firstLine: 1, 73 | lastLine: 1, 74 | message: "Unexpected empty block (block-no-empty)", 75 | }, 76 | ], 77 | }, 78 | }; 79 | } 80 | 81 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 82 | -------------------------------------------------------------------------------- /src/linters/swift-format-lockwood.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | 5 | const PARSE_REGEX = /^(.*):([0-9]+):[0-9]+: \w+: \((\w+)\) (.*)\.$/gm; 6 | 7 | /** 8 | * https://github.com/nicklockwood/SwiftFormat 9 | */ 10 | class SwiftFormatLockwood { 11 | static get name() { 12 | return "SwiftFormat"; 13 | } 14 | 15 | /** 16 | * Verifies that all required programs are installed. Throws an error if programs are missing 17 | * @param {string} dir - Directory to run the linting program in 18 | * @param {string} prefix - Prefix to the lint command 19 | */ 20 | static async verifySetup(dir, prefix = "") { 21 | // Verify that SwiftFormat is installed 22 | if (!(await commandExists("swiftformat"))) { 23 | throw new Error(`${this.name} is not installed`); 24 | } 25 | } 26 | 27 | /** 28 | * Runs the linting program and returns the command output 29 | * @param {string} dir - Directory to run the linter in 30 | * @param {string[]} extensions - File extensions which should be linted 31 | * @param {string} args - Additional arguments to pass to the linter 32 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 33 | * @param {string} prefix - Prefix to the lint command 34 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 35 | */ 36 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 37 | if (extensions.length !== 1 || extensions[0] !== "swift") { 38 | throw new Error(`${this.name} error: File extensions are not configurable`); 39 | } 40 | 41 | const fixArg = fix ? "" : "--lint"; 42 | return run(`${prefix} swiftformat ${fixArg} ${args} "."`, { 43 | dir, 44 | ignoreErrors: true, 45 | }); 46 | } 47 | 48 | /** 49 | * Parses the output of the lint command. Determines the success of the lint process and the 50 | * severity of the identified code style violations 51 | * @param {string} dir - Directory in which the linter has been run 52 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 53 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 54 | */ 55 | static parseOutput(dir, output) { 56 | const lintResult = initLintResult(); 57 | lintResult.isSuccess = output.status === 0; 58 | 59 | const matches = output.stderr.matchAll(PARSE_REGEX); 60 | for (const match of matches) { 61 | const [_, pathFull, line, rule, message] = match; 62 | const path = pathFull.substring(dir.length + 1); 63 | const lineNr = parseInt(line, 10); 64 | // SwiftFormat only seems to use the "warning" level, which this action will therefore 65 | // categorize as errors 66 | lintResult.error.push({ 67 | path, 68 | firstLine: lineNr, 69 | lastLine: lineNr, 70 | message: `${message} (${rule})`, 71 | }); 72 | } 73 | 74 | return lintResult; 75 | } 76 | } 77 | 78 | module.exports = SwiftFormatLockwood; 79 | -------------------------------------------------------------------------------- /src/linters/swift-format-official.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | 5 | const PARSE_REGEX = /^(.*):([0-9]+):([0-9]+): (warning|error): (.*)$/gm; 6 | 7 | /** 8 | * https://github.com/apple/swift-format 9 | */ 10 | class SwiftFormatOfficial { 11 | static get name() { 12 | return "swift-format"; 13 | } 14 | 15 | /** 16 | * Verifies that all required programs are installed. Throws an error if programs are missing 17 | * @param {string} dir - Directory to run the linting program in 18 | * @param {string} prefix - Prefix to the lint command 19 | */ 20 | static async verifySetup(dir, prefix = "") { 21 | // Verify that swift-format is installed. 22 | if (!(await commandExists("swift-format"))) { 23 | throw new Error(`${this.name} is not installed`); 24 | } 25 | } 26 | 27 | /** 28 | * Runs the linting program and returns the command output 29 | * @param {string} dir - Directory to run the linter in 30 | * @param {string[]} extensions - File extensions which should be linted 31 | * @param {string} args - Additional arguments to pass to the linter 32 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 33 | * @param {string} prefix - Prefix to the lint command 34 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 35 | */ 36 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 37 | if (extensions.length !== 1 || extensions[0] !== "swift") { 38 | throw new Error(`${this.name} error: File extensions are not configurable`); 39 | } 40 | 41 | const mode = fix ? "format -i" : "lint"; 42 | return run(`${prefix} swift-format ${mode} ${args} --recursive "."`, { 43 | dir, 44 | ignoreErrors: true, 45 | }); 46 | } 47 | 48 | /** 49 | * Parses the output of the lint command. Determines the success of the lint process and the 50 | * severity of the identified code style violations 51 | * @param {string} dir - Directory in which the linter has been run 52 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 53 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 54 | */ 55 | static parseOutput(dir, output) { 56 | const lintResult = initLintResult(); 57 | lintResult.isSuccess = output.status === 0; 58 | 59 | const matches = output.stderr.matchAll(PARSE_REGEX); 60 | for (const match of matches) { 61 | const [_line, pathFull, line, _column, _level, message] = match; 62 | const path = pathFull.substring(dir.length + 1); 63 | const lineNr = parseInt(line, 10); 64 | // swift-format only seems to use the "warning" level, which this action will therefore 65 | // categorize as errors 66 | lintResult.error.push({ 67 | path, 68 | firstLine: lineNr, 69 | lastLine: lineNr, 70 | message: `${message}`, 71 | }); 72 | } 73 | 74 | return lintResult; 75 | } 76 | } 77 | 78 | module.exports = SwiftFormatOfficial; 79 | -------------------------------------------------------------------------------- /src/linters/prettier.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | const { getNpmBinCommand } = require("../utils/npm/get-npm-bin-command"); 5 | 6 | /** 7 | * https://prettier.io 8 | */ 9 | class Prettier { 10 | static get name() { 11 | return "Prettier"; 12 | } 13 | 14 | /** 15 | * Verifies that all required programs are installed. Throws an error if programs are missing 16 | * @param {string} dir - Directory to run the linting program in 17 | * @param {string} prefix - Prefix to the lint command 18 | */ 19 | static async verifySetup(dir, prefix = "") { 20 | // Verify that NPM is installed (required to execute Prettier) 21 | if (!(await commandExists("npm"))) { 22 | throw new Error("NPM is not installed"); 23 | } 24 | 25 | // Verify that Prettier is installed 26 | const commandPrefix = prefix || getNpmBinCommand(dir); 27 | try { 28 | run(`${commandPrefix} prettier -v`, { dir }); 29 | } catch (err) { 30 | throw new Error(`${this.name} is not installed`); 31 | } 32 | } 33 | 34 | /** 35 | * Runs the linting program and returns the command output 36 | * @param {string} dir - Directory to run the linter in 37 | * @param {string[]} extensions - File extensions which should be linted 38 | * @param {string} args - Additional arguments to pass to the linter 39 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 40 | * @param {string} prefix - Prefix to the lint command 41 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 42 | */ 43 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 44 | const files = 45 | extensions.length === 1 ? `**/*.${extensions[0]}` : `**/*.{${extensions.join(",")}}`; 46 | const fixArg = fix ? "--write" : "--list-different"; 47 | const commandPrefix = prefix || getNpmBinCommand(dir); 48 | return run(`${commandPrefix} prettier ${fixArg} --no-color ${args} "${files}"`, { 49 | dir, 50 | ignoreErrors: true, 51 | }); 52 | } 53 | 54 | /** 55 | * Parses the output of the lint command. Determines the success of the lint process and the 56 | * severity of the identified code style violations 57 | * @param {string} dir - Directory in which the linter has been run 58 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 59 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 60 | */ 61 | static parseOutput(dir, output) { 62 | const lintResult = initLintResult(); 63 | lintResult.isSuccess = output.status === 0; 64 | if (lintResult.isSuccess || !output) { 65 | return lintResult; 66 | } 67 | 68 | const paths = output.stdout.split(/\r?\n/); 69 | lintResult.error = paths.map((path) => ({ 70 | path, 71 | firstLine: 1, 72 | lastLine: 1, 73 | message: 74 | "There are issues with this file's formatting, please run Prettier to fix the errors", 75 | })); 76 | 77 | return lintResult; 78 | } 79 | } 80 | 81 | module.exports = Prettier; 82 | -------------------------------------------------------------------------------- /vendor/command-exists.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * command-exists 3 | * v1.2.8 4 | * https://github.com/mathisonian/command-exists 5 | * 6 | * The MIT License (MIT) 7 | * 8 | * Copyright (c) 2014 Matthew Conlen 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 11 | * associated documentation files (the "Software"), to deal in the Software without restriction, 12 | * including without limitation the rights to use, copy, modify, merge, publish, distribute, 13 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 14 | * furnished to do so, subject to the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be included in all copies or 17 | * substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 20 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 22 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | */ 25 | 26 | var exec=require("child_process").exec,execSync=require("child_process").execSync,fs=require("fs"),path=require("path"),access=fs.access,accessSync=fs.accessSync,constants=fs.constants||fs,isUsingWindows="win32"==process.platform,fileNotExists=function(n,c){access(n,constants.F_OK,function(n){c(!n)})},fileNotExistsSync=function(n){try{return accessSync(n,constants.F_OK),!1}catch(n){return!0}},localExecutable=function(n,c){access(n,constants.F_OK|constants.X_OK,function(n){c(null,!n)})},localExecutableSync=function(n){try{return accessSync(n,constants.F_OK|constants.X_OK),!0}catch(n){return!1}},commandExistsUnix=function(n,c,e){fileNotExists(n,function(t){if(t)localExecutable(n,e);else exec("command -v "+c+" 2>/dev/null && { echo >&1 "+c+"; exit 0; }",function(n,c,t){e(null,!!c)})})},commandExistsWindows=function(n,c,e){if(/[\x00-\x1f<>:"\|\?\*]/.test(n))e(null,!1);else exec("where "+c,function(n){e(null,null===n)})},commandExistsUnixSync=function(n,c){if(fileNotExistsSync(n))try{return!!execSync("command -v "+c+" 2>/dev/null && { echo >&1 "+c+"; exit 0; }")}catch(n){return!1}return localExecutableSync(n)},commandExistsWindowsSync=function(n,c,e){if(/[\x00-\x1f<>:"\|\?\*]/.test(n))return!1;try{return!!execSync("where "+c,{stdio:[]})}catch(n){return!1}},cleanInput=function(n){return/[^A-Za-z0-9_\/:=-]/.test(n)&&(n=(n="'"+n.replace(/'/g,"'\\''")+"'").replace(/^(?:'')+/g,"").replace(/\\'''/g,"\\'")),n};isUsingWindows&&(cleanInput=function(n){return/[\\]/.test(n)?'"'+path.dirname(n)+'"'+":"+('"'+path.basename(n)+'"'):'"'+n+'"'}),module.exports=function n(c,e){var t=cleanInput(c);if(!e&&"undefined"!=typeof Promise)return new Promise(function(e,t){n(c,function(n,s){s?e(c):t(n)})});isUsingWindows?commandExistsWindows(c,t,e):commandExistsUnix(c,t,e)},module.exports.sync=function(n){var c=cleanInput(n);return isUsingWindows?commandExistsWindowsSync(n,c):commandExistsUnixSync(n,c)}; 27 | -------------------------------------------------------------------------------- /src/linters/flake8.js: -------------------------------------------------------------------------------- 1 | const { sep } = require("path"); 2 | 3 | const { log, run } = require("../utils/action"); 4 | const commandExists = require("../utils/command-exists"); 5 | const { initLintResult } = require("../utils/lint-result"); 6 | const { capitalizeFirstLetter } = require("../utils/string"); 7 | 8 | const PARSE_REGEX = /^(.*):([0-9]+):[0-9]+: (\w*) (.*)$/gm; 9 | 10 | /** 11 | * http://flake8.pycqa.org 12 | */ 13 | class Flake8 { 14 | static get name() { 15 | return "Flake8"; 16 | } 17 | 18 | /** 19 | * Verifies that all required programs are installed. Throws an error if programs are missing 20 | * @param {string} dir - Directory to run the linting program in 21 | * @param {string} prefix - Prefix to the lint command 22 | */ 23 | static async verifySetup(dir, prefix = "") { 24 | // Verify that Python is installed (required to execute Flake8) 25 | if (!(await commandExists("python"))) { 26 | throw new Error("Python is not installed"); 27 | } 28 | 29 | // Verify that Flake8 is installed 30 | try { 31 | run(`${prefix} flake8 --version`, { dir }); 32 | } catch (err) { 33 | throw new Error(`${this.name} is not installed`); 34 | } 35 | } 36 | 37 | /** 38 | * Runs the linting program and returns the command output 39 | * @param {string} dir - Directory to run the linter in 40 | * @param {string[]} extensions - File extensions which should be linted 41 | * @param {string} args - Additional arguments to pass to the linter 42 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 43 | * @param {string} prefix - Prefix to the lint command 44 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 45 | */ 46 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 47 | if (fix) { 48 | log(`${this.name} does not support auto-fixing`, "warning"); 49 | } 50 | 51 | const files = extensions.map((ext) => `"**${sep}*.${ext}"`).join(","); 52 | return run(`${prefix} flake8 --filename ${files} ${args}`, { 53 | dir, 54 | ignoreErrors: true, 55 | }); 56 | } 57 | 58 | /** 59 | * Parses the output of the lint command. Determines the success of the lint process and the 60 | * severity of the identified code style violations 61 | * @param {string} dir - Directory in which the linter has been run 62 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 63 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 64 | */ 65 | static parseOutput(dir, output) { 66 | const lintResult = initLintResult(); 67 | lintResult.isSuccess = output.status === 0; 68 | 69 | const matches = output.stdout.matchAll(PARSE_REGEX); 70 | for (const match of matches) { 71 | const [_, pathFull, line, rule, text] = match; 72 | const path = pathFull.substring(2); // Remove "./" or ".\" from start of path 73 | const lineNr = parseInt(line, 10); 74 | lintResult.error.push({ 75 | path, 76 | firstLine: lineNr, 77 | lastLine: lineNr, 78 | message: `${capitalizeFirstLetter(text)} (${rule})`, 79 | }); 80 | } 81 | 82 | return lintResult; 83 | } 84 | } 85 | 86 | module.exports = Flake8; 87 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: Run tests 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v2 19 | 20 | # Go 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v1 24 | with: 25 | go-version: 1.13 26 | 27 | # TODO: Remove step once actions/setup-go adds $GOPATH/bin to $PATH by default 28 | # See https://github.com/actions/setup-go/issues/14 29 | - name: Add Go to $PATH 30 | run: | 31 | echo "::set-env name=GOPATH::$(go env GOPATH)" 32 | echo "::add-path::$(go env GOPATH)/bin" 33 | 34 | - name: Install Go dependencies 35 | run: | 36 | cd ./test/linters/projects/golint 37 | go get -u golang.org/x/lint/golint 38 | 39 | # Node.js 40 | 41 | - name: Set up Node.js 42 | uses: actions/setup-node@v1 43 | with: 44 | node-version: 12 45 | 46 | - name: Install Node.js dependencies 47 | run: | 48 | cd ./test/linters/projects/eslint/ 49 | yarn install 50 | cd ../eslint-typescript/ 51 | yarn install 52 | cd ../prettier/ 53 | yarn install 54 | cd ../stylelint/ 55 | yarn install 56 | cd ../xo/ 57 | yarn install 58 | 59 | # Python 60 | 61 | - name: Set up Python 62 | uses: actions/setup-python@v1 63 | with: 64 | python-version: 3.8 65 | 66 | - name: Install Python dependencies 67 | run: | 68 | cd ./test/linters/projects/ 69 | pip install -r ./black/requirements.txt -r ./flake8/requirements.txt -r ./mypy/requirements.txt 70 | 71 | # Ruby 72 | 73 | - name: Set up Ruby 74 | uses: actions/setup-ruby@v1 75 | with: 76 | ruby-version: 2.6 77 | 78 | - name: Install Ruby dependencies 79 | run: | 80 | gem install bundler 81 | cd ./test/linters/projects/rubocop/ 82 | bundle install 83 | 84 | # Swift (only on Linux) 85 | 86 | - name: Install Swift dependencies (Linux) 87 | if: startsWith(matrix.os, 'ubuntu') 88 | run: | 89 | git clone --branch 0.50200.0 --depth 1 git://github.com/apple/swift-format 90 | cd swift-format 91 | swift build -c release 92 | echo "::add-path::${PWD}/.build/release" 93 | 94 | # Swift (only on macOS) 95 | 96 | - name: Install Swift dependencies (macOS) 97 | if: startsWith(matrix.os, 'macos') 98 | run: | 99 | brew update 100 | brew install mint 101 | cd ./test/linters/projects/swift-format-lockwood/ 102 | mint bootstrap --link 103 | cd ../swiftlint/ 104 | mint bootstrap --link 105 | 106 | # Tests 107 | 108 | - name: Run tests 109 | run: | 110 | yarn 111 | yarn test 112 | -------------------------------------------------------------------------------- /test/linters/params/rubocop.js: -------------------------------------------------------------------------------- 1 | const RuboCop = require("../../../src/linters/rubocop"); 2 | 3 | const testName = "rubocop"; 4 | const linter = RuboCop; 5 | const extensions = ["rb"]; 6 | 7 | // Linting without auto-fixing 8 | function getLintParams(dir) { 9 | const stdoutFile1 = `{"path":"file1.rb","offenses":[{"severity":"convention","message":"Redundant \`return\` detected.","cop_name":"Style/RedundantReturn","corrected":false,"location":{"start_line":5,"start_column":3,"last_line":5,"last_column":8,"length":6,"line":5,"column":3}}]}`; 10 | const stdoutFile2 = `{"path":"file2.rb","offenses":[{"severity":"warning","message":"Useless assignment to variable - \`x\`.","cop_name":"Lint/UselessAssignment","corrected":false,"location":{"start_line":4,"start_column":1,"last_line":4,"last_column":1,"length":1,"line":4,"column":1}}]}`; 11 | return { 12 | // Expected output of the linting function 13 | cmdOutput: { 14 | status: 1, 15 | stdoutParts: [stdoutFile1, stdoutFile2], 16 | stdout: `{"metadata":{"rubocop_version":"0.71.0","ruby_engine":"ruby","ruby_version":"2.5.3","ruby_patchlevel":"105","ruby_platform":"x86_64-darwin18"},"files":[${stdoutFile1},${stdoutFile2}],"summary":{"offense_count":2,"target_file_count":2,"inspected_file_count":2}}`, 17 | }, 18 | // Expected output of the parsing function 19 | lintResult: { 20 | isSuccess: false, 21 | warning: [ 22 | { 23 | path: "file1.rb", 24 | firstLine: 5, 25 | lastLine: 5, 26 | message: "Redundant `return` detected (Style/RedundantReturn)", 27 | }, 28 | { 29 | path: "file2.rb", 30 | firstLine: 4, 31 | lastLine: 4, 32 | message: "Useless assignment to variable - `x` (Lint/UselessAssignment)", 33 | }, 34 | ], 35 | error: [], 36 | }, 37 | }; 38 | } 39 | 40 | // Linting with auto-fixing 41 | function getFixParams(dir) { 42 | const stdoutFile1 = `{"path":"file1.rb","offenses":[{"severity":"convention","message":"Redundant \`return\` detected.","cop_name":"Style/RedundantReturn","corrected":true,"location":{"start_line":5,"start_column":3,"last_line":5,"last_column":8,"length":6,"line":5,"column":3}}]}`; 43 | const stdoutFile2 = `{"path":"file2.rb","offenses":[{"severity":"warning","message":"Useless assignment to variable - \`x\`.","cop_name":"Lint/UselessAssignment","corrected":false,"location":{"start_line":4,"start_column":1,"last_line":4,"last_column":1,"length":1,"line":4,"column":1}}]}`; 44 | return { 45 | // Expected output of the linting function 46 | cmdOutput: { 47 | status: 1, 48 | stdoutParts: [stdoutFile1, stdoutFile2], 49 | stdout: `{"metadata":{"rubocop_version":"0.71.0","ruby_engine":"ruby","ruby_version":"2.5.3","ruby_patchlevel":"105","ruby_platform":"x86_64-darwin18"},"files":[${stdoutFile1},${stdoutFile2}],"summary":{"offense_count":2,"target_file_count":2,"inspected_file_count":2}}`, 50 | }, 51 | // Expected output of the parsing function 52 | lintResult: { 53 | isSuccess: false, 54 | warning: [ 55 | { 56 | path: "file2.rb", 57 | firstLine: 4, 58 | lastLine: 4, 59 | message: "Useless assignment to variable - `x` (Lint/UselessAssignment)", 60 | }, 61 | ], 62 | error: [], 63 | }, 64 | }; 65 | } 66 | 67 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 68 | -------------------------------------------------------------------------------- /src/linters/stylelint.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { initLintResult } = require("../utils/lint-result"); 4 | const { getNpmBinCommand } = require("../utils/npm/get-npm-bin-command"); 5 | 6 | /** 7 | * https://stylelint.io 8 | */ 9 | class Stylelint { 10 | static get name() { 11 | return "stylelint"; 12 | } 13 | 14 | /** 15 | * Verifies that all required programs are installed. Throws an error if programs are missing 16 | * @param {string} dir - Directory to run the linting program in 17 | * @param {string} prefix - Prefix to the lint command 18 | */ 19 | static async verifySetup(dir, prefix = "") { 20 | // Verify that NPM is installed (required to execute stylelint) 21 | if (!(await commandExists("npm"))) { 22 | throw new Error("NPM is not installed"); 23 | } 24 | 25 | // Verify that stylelint is installed 26 | const commandPrefix = prefix || getNpmBinCommand(dir); 27 | try { 28 | run(`${commandPrefix} stylelint -v`, { dir }); 29 | } catch (err) { 30 | throw new Error(`${this.name} is not installed`); 31 | } 32 | } 33 | 34 | /** 35 | * Runs the linting program and returns the command output 36 | * @param {string} dir - Directory to run the linter in 37 | * @param {string[]} extensions - File extensions which should be linted 38 | * @param {string} args - Additional arguments to pass to the linter 39 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 40 | * @param {string} prefix - Prefix to the lint command 41 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 42 | */ 43 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 44 | const files = 45 | extensions.length === 1 ? `**/*.${extensions[0]}` : `**/*.{${extensions.join(",")}}`; 46 | const fixArg = fix ? "--fix" : ""; 47 | const commandPrefix = prefix || getNpmBinCommand(dir); 48 | return run( 49 | `${commandPrefix} stylelint --no-color --formatter json ${fixArg} ${args} "${files}"`, 50 | { 51 | dir, 52 | ignoreErrors: true, 53 | }, 54 | ); 55 | } 56 | 57 | /** 58 | * Parses the output of the lint command. Determines the success of the lint process and the 59 | * severity of the identified code style violations 60 | * @param {string} dir - Directory in which the linter has been run 61 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 62 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 63 | */ 64 | static parseOutput(dir, output) { 65 | const lintResult = initLintResult(); 66 | lintResult.isSuccess = output.status === 0; 67 | 68 | let outputJson; 69 | try { 70 | outputJson = JSON.parse(output.stdout); 71 | } catch (err) { 72 | throw Error( 73 | `Error parsing ${this.name} JSON output: ${err.message}. Output: "${output.stdout}"`, 74 | ); 75 | } 76 | 77 | for (const violation of outputJson) { 78 | const { source, warnings } = violation; 79 | const path = source.substring(dir.length + 1); 80 | for (const warning of warnings) { 81 | const { line, severity, text } = warning; 82 | if (severity in lintResult) { 83 | lintResult[severity].push({ 84 | path, 85 | firstLine: line, 86 | lastLine: line, 87 | message: text, 88 | }); 89 | } 90 | } 91 | } 92 | 93 | return lintResult; 94 | } 95 | } 96 | 97 | module.exports = Stylelint; 98 | -------------------------------------------------------------------------------- /src/linters/gofmt.js: -------------------------------------------------------------------------------- 1 | const { run } = require("../utils/action"); 2 | const commandExists = require("../utils/command-exists"); 3 | const { parseErrorsFromDiff } = require("../utils/diff"); 4 | const { initLintResult } = require("../utils/lint-result"); 5 | 6 | /** 7 | * https://golang.org/cmd/gofmt 8 | */ 9 | class Gofmt { 10 | static get name() { 11 | return "gofmt"; 12 | } 13 | 14 | /** 15 | * Verifies that all required programs are installed. Throws an error if programs are missing 16 | * @param {string} dir - Directory to run the linting program in 17 | * @param {string} prefix - Prefix to the lint command 18 | */ 19 | static async verifySetup(dir, prefix = "") { 20 | // Verify that gofmt is installed 21 | if (!(await commandExists("gofmt"))) { 22 | throw new Error(`${this.name} is not installed`); 23 | } 24 | } 25 | 26 | /** 27 | * Runs the linting program and returns the command output 28 | * @param {string} dir - Directory to run the linter in 29 | * @param {string[]} extensions - File extensions which should be linted 30 | * @param {string} args - Additional arguments to pass to the linter 31 | * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically 32 | * @param {string} prefix - Prefix to the lint command 33 | * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command 34 | */ 35 | static lint(dir, extensions, args = "", fix = false, prefix = "") { 36 | if (extensions.length !== 1 || extensions[0] !== "go") { 37 | throw new Error(`${this.name} error: File extensions are not configurable`); 38 | } 39 | 40 | // -d: Display diffs instead of rewriting files 41 | // -e: Report all errors (not just the first 10 on different lines) 42 | // -s: Simplify code 43 | // -w: Write result to (source) file instead of stdout 44 | const fixArg = fix ? "-w" : "-d -e"; 45 | return run(`${prefix} gofmt -s ${fixArg} ${args} "."`, { 46 | dir, 47 | ignoreErrors: true, 48 | }); 49 | } 50 | 51 | /** 52 | * Parses the output of the lint command. Determines the success of the lint process and the 53 | * severity of the identified code style violations 54 | * @param {string} dir - Directory in which the linter has been run 55 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 56 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 57 | */ 58 | static parseOutput(dir, output) { 59 | const lintResult = initLintResult(); 60 | 61 | // The gofmt output lines starting with "diff" differ from the ones of tools like Git: 62 | // 63 | // - gofmt: "diff -u file-old.txt file-new.txt" 64 | // - Git: "diff --git a/file-old.txt b/file-new.txt" 65 | // 66 | // The diff parser relies on the "a/" and "b/" strings to be able to tell where file names 67 | // start. Without these strings, this would not be possible, because file names may include 68 | // spaces, which are not escaped in unified diffs. As a workaround, these lines are filtered out 69 | // from the gofmt diff so the diff parser can read the diff without errors 70 | const filteredOutput = output.stdout 71 | .split(/\r?\n/) 72 | .filter((line) => !line.startsWith("diff -u")) 73 | .join("\n"); 74 | lintResult.error = parseErrorsFromDiff(filteredOutput); 75 | 76 | // gofmt exits with 0 even if there are formatting issues. Therefore, this function determines 77 | // the success of the linting process based on the number of parsed errors 78 | lintResult.isSuccess = lintResult.error.length === 0; 79 | 80 | return lintResult; 81 | } 82 | } 83 | 84 | module.exports = Gofmt; 85 | -------------------------------------------------------------------------------- /test/linters/linters.test.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const { copy } = require("fs-extra"); 4 | 5 | const { normalizeDates, testProjectsDir, tmpDir } = require("../test-utils"); 6 | const blackParams = require("./params/black"); 7 | const eslintParams = require("./params/eslint"); 8 | const eslintTypescriptParams = require("./params/eslint-typescript"); 9 | const flake8Params = require("./params/flake8"); 10 | const gofmtParams = require("./params/gofmt"); 11 | const golintParams = require("./params/golint"); 12 | const mypyParams = require("./params/mypy"); 13 | const prettierParams = require("./params/prettier"); 14 | const ruboCopParams = require("./params/rubocop"); 15 | const stylelintParams = require("./params/stylelint"); 16 | const swiftFormatLockwood = require("./params/swift-format-lockwood"); 17 | const swiftFormatOfficial = require("./params/swift-format-official"); 18 | const swiftlintParams = require("./params/swiftlint"); 19 | const xoParams = require("./params/xo"); 20 | 21 | const linterParams = [ 22 | blackParams, 23 | eslintParams, 24 | eslintTypescriptParams, 25 | flake8Params, 26 | gofmtParams, 27 | golintParams, 28 | mypyParams, 29 | prettierParams, 30 | ruboCopParams, 31 | stylelintParams, 32 | xoParams, 33 | ]; 34 | if (process.platform === "linux") { 35 | linterParams.push(swiftFormatOfficial); 36 | } 37 | if (process.platform === "darwin") { 38 | linterParams.push(swiftFormatLockwood, swiftlintParams); 39 | } 40 | 41 | // Copy linter test projects into temporary directory 42 | beforeAll(async () => { 43 | jest.setTimeout(60000); 44 | await copy(testProjectsDir, tmpDir); 45 | }); 46 | 47 | // Test all linters 48 | describe.each(linterParams)( 49 | "%s", 50 | (projectName, linter, extensions, getLintParams, getFixParams) => { 51 | const projectTmpDir = join(tmpDir, projectName); 52 | 53 | beforeAll(async () => { 54 | await expect(linter.verifySetup(projectTmpDir)).resolves.toEqual(undefined); 55 | }); 56 | 57 | // Test lint and auto-fix modes 58 | describe.each([ 59 | ["lint", false], 60 | ["auto-fix", true], 61 | ])("%s", (lintMode, autoFix) => { 62 | const expected = autoFix ? getFixParams(projectTmpDir) : getLintParams(projectTmpDir); 63 | 64 | // Test `lint` function 65 | test(`${linter.name} returns expected ${lintMode} output`, () => { 66 | const cmdOutput = linter.lint(projectTmpDir, extensions, "", autoFix); 67 | 68 | // Exit code 69 | expect(cmdOutput.status).toEqual(expected.cmdOutput.status); 70 | 71 | // stdout 72 | const stdout = normalizeDates(cmdOutput.stdout); 73 | if ("stdoutParts" in expected.cmdOutput) { 74 | expected.cmdOutput.stdoutParts.forEach((stdoutPart) => 75 | expect(stdout).toEqual(expect.stringContaining(stdoutPart)), 76 | ); 77 | } else if ("stdout" in expected.cmdOutput) { 78 | expect(stdout).toEqual(expected.cmdOutput.stdout); 79 | } 80 | 81 | // stderr 82 | const stderr = normalizeDates(cmdOutput.stderr); 83 | if ("stderrParts" in expected.cmdOutput) { 84 | expected.cmdOutput.stderrParts.forEach((stderrParts) => 85 | expect(stderr).toEqual(expect.stringContaining(stderrParts)), 86 | ); 87 | } else if ("stderr" in expected.cmdOutput) { 88 | expect(stderr).toEqual(expected.cmdOutput.stderr); 89 | } 90 | }); 91 | 92 | // Test `parseOutput` function 93 | test(`${linter.name} parses ${lintMode} output correctly`, () => { 94 | const lintResult = linter.parseOutput(projectTmpDir, expected.cmdOutput); 95 | expect(lintResult).toEqual(expected.lintResult); 96 | }); 97 | }); 98 | }, 99 | ); 100 | -------------------------------------------------------------------------------- /vendor/parse-diff.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * parse-diff 3 | * v0.7.0 4 | * https://github.com/sergeyt/parse-diff 5 | * 6 | * The MIT License (MIT) 7 | * 8 | * Copyright (c) 2014 Sergey Todyshev 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 11 | * associated documentation files (the "Software"), to deal in the Software without restriction, 12 | * including without limitation the rights to use, copy, modify, merge, publish, distribute, 13 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 14 | * furnished to do so, subject to the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be included in all copies or 17 | * substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 20 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 22 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | */ 25 | 26 | var defaultToWhiteSpace,escapeRegExp,ltrim,makeString,parseFile,parseFileFallback,trimLeft,slice=[].slice;module.exports=function(e){var n,t,r,i,l,u,a,c,o,s,d,f;if(!e)return[];if(e.match(/^\s+$/))return[];if(0===(u=e.split("\n")).length)return[];for(r=[],t=null,c=0,a=0,n=null,s=function(){if(!t||t.chunks.length)return f()},d=[[/^\s+/,function(e){if(n)return n.changes.push({type:"normal",normal:!0,ln1:c++,ln2:a++,content:e})}],[/^diff\s/,f=function(e){var n;if(t={chunks:[],deletions:0,additions:0},r.push(t),!t.to&&!t.from&&(n=parseFile(e)))return t.from=n[0],t.to=n[1]}],[/^new file mode \d+$/,function(){return s(),t.new=!0,t.from="/dev/null"}],[/^deleted file mode \d+$/,function(){return s(),t.deleted=!0,t.to="/dev/null"}],[/^index\s[\da-zA-Z]+\.\.[\da-zA-Z]+(\s(\d+))?$/,function(e){return s(),t.index=e.split(" ").slice(1)}],[/^---\s/,function(e){return s(),t.from=parseFileFallback(e)}],[/^\+\+\+\s/,function(e){return s(),t.to=parseFileFallback(e)}],[/^@@\s+\-(\d+),?(\d+)?\s+\+(\d+),?(\d+)?\s@@/,function(e,r){var i,l,u,o;return c=o=+r[1],u=+(r[2]||1),a=l=+r[3],i=+(r[4]||1),n={content:e,changes:[],oldStart:o,oldLines:u,newStart:l,newLines:i},t.chunks.push(n)}],[/^-/,function(e){if(n)return n.changes.push({type:"del",del:!0,ln:c++,content:e}),t.deletions++}],[/^\+/,function(e){if(n)return n.changes.push({type:"add",add:!0,ln:a++,content:e}),t.additions++}],[/^\\ No newline at end of file$/,function(e){var t,r;return r=n.changes,[t]=slice.call(r,-1),n.changes.push({type:t.type,[`${t.type}`]:!0,ln1:t.ln1,ln2:t.ln2,ln:t.ln,content:e})}]],o=function(e){var n,t,r,i;for(n=0,t=d.length;n `.${ext}`).join(","); 46 | const fixArg = fix ? "--fix" : ""; 47 | const commandPrefix = prefix || getNpmBinCommand(dir); 48 | return run( 49 | `${commandPrefix} eslint --ext ${extensionsArg} ${fixArg} --no-color --format json ${args} "."`, 50 | { 51 | dir, 52 | ignoreErrors: true, 53 | }, 54 | ); 55 | } 56 | 57 | /** 58 | * Parses the output of the lint command. Determines the success of the lint process and the 59 | * severity of the identified code style violations 60 | * @param {string} dir - Directory in which the linter has been run 61 | * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command 62 | * @returns {{isSuccess: boolean, warning: [], error: []}} - Parsed lint result 63 | */ 64 | static parseOutput(dir, output) { 65 | const lintResult = initLintResult(); 66 | lintResult.isSuccess = output.status === 0; 67 | 68 | let outputJson; 69 | try { 70 | outputJson = JSON.parse(output.stdout); 71 | } catch (err) { 72 | throw Error( 73 | `Error parsing ${this.name} JSON output: ${err.message}. Output: "${output.stdout}"`, 74 | ); 75 | } 76 | 77 | for (const violation of outputJson) { 78 | const { filePath, messages } = violation; 79 | const path = filePath.substring(dir.length + 1); 80 | 81 | for (const msg of messages) { 82 | const { fatal, line, message, ruleId, severity } = msg; 83 | 84 | // Exit if a fatal ESLint error occurred 85 | if (fatal) { 86 | throw Error(`ESLint error: ${message}`); 87 | } 88 | 89 | const entry = { 90 | path, 91 | firstLine: line, 92 | lastLine: line, 93 | message: `${removeTrailingPeriod(message)} (${ruleId})`, 94 | }; 95 | if (severity === 1) { 96 | lintResult.warning.push(entry); 97 | } else if (severity === 2) { 98 | lintResult.error.push(entry); 99 | } 100 | } 101 | } 102 | 103 | return lintResult; 104 | } 105 | } 106 | 107 | module.exports = ESLint; 108 | -------------------------------------------------------------------------------- /test/github/api-responses/check-runs.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123456789, 3 | "node_id": "123456789", 4 | "head_sha": "0000000000000000000000000000000000000000", 5 | "external_id": "", 6 | "url": "https://api.github.com/repos/example/example/check-runs/123456789", 7 | "html_url": "https://github.com/example/example/runs/123456789", 8 | "details_url": "https://help.github.com/en/actions", 9 | "status": "completed", 10 | "conclusion": "failure", 11 | "started_at": "2019-01-01T00:00:00Z", 12 | "completed_at": "2019-01-01T00:00:00Z", 13 | "output": { 14 | "title": "eslint", 15 | "summary": "eslint found 123 errors", 16 | "text": null, 17 | "annotations_count": 1, 18 | "annotations_url": "https://api.github.com/repos/example/example/check-runs/123456789/annotations" 19 | }, 20 | "name": "eslint", 21 | "check_suite": { 22 | "id": 123456789 23 | }, 24 | "app": { 25 | "id": 123456789, 26 | "slug": "github-actions", 27 | "node_id": "123456789", 28 | "owner": { 29 | "login": "github", 30 | "id": 123456789, 31 | "node_id": "123456789", 32 | "avatar_url": "https://avatars1.githubusercontent.com/u/123456789?v=4", 33 | "gravatar_id": "", 34 | "url": "https://api.github.com/users/github", 35 | "html_url": "https://github.com/github", 36 | "followers_url": "https://api.github.com/users/github/followers", 37 | "following_url": "https://api.github.com/users/github/following{/other_user}", 38 | "gists_url": "https://api.github.com/users/github/gists{/gist_id}", 39 | "starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}", 40 | "subscriptions_url": "https://api.github.com/users/github/subscriptions", 41 | "organizations_url": "https://api.github.com/users/github/orgs", 42 | "repos_url": "https://api.github.com/users/github/repos", 43 | "events_url": "https://api.github.com/users/github/events{/privacy}", 44 | "received_events_url": "https://api.github.com/users/github/received_events", 45 | "type": "Organization", 46 | "site_admin": false 47 | }, 48 | "name": "GitHub Actions", 49 | "description": "Automate your workflow from idea to production", 50 | "external_url": "https://help.github.com/en/actions", 51 | "html_url": "https://github.com/apps/github-actions", 52 | "created_at": "2019-01-01T00:00:00Z", 53 | "updated_at": "2019-01-01T00:00:00Z", 54 | "permissions": { 55 | "app_config": "read", 56 | "checks": "write", 57 | "contents": "write", 58 | "deployments": "write", 59 | "issues": "write", 60 | "metadata": "read", 61 | "packages": "write", 62 | "pages": "write", 63 | "pull_requests": "write", 64 | "repository_hooks": "write", 65 | "repository_projects": "write", 66 | "statuses": "write", 67 | "vulnerability_alerts": "read" 68 | }, 69 | "events": [ 70 | "check_run", 71 | "check_suite", 72 | "create", 73 | "delete", 74 | "deployment", 75 | "deployment_status", 76 | "fork", 77 | "gollum", 78 | "issues", 79 | "issue_comment", 80 | "label", 81 | "milestone", 82 | "page_build", 83 | "project", 84 | "project_card", 85 | "project_column", 86 | "public", 87 | "pull_request", 88 | "pull_request_review", 89 | "pull_request_review_comment", 90 | "push", 91 | "registry_package", 92 | "release", 93 | "repository", 94 | "repository_dispatch", 95 | "status", 96 | "watch" 97 | ] 98 | }, 99 | "pull_requests": [ 100 | { 101 | "url": "https://api.github.com/repos/example/example/pulls/1", 102 | "id": 123456789, 103 | "number": 1, 104 | "head": { 105 | "ref": "example-patch-1", 106 | "sha": "0000000000000000000000000000000000000000", 107 | "repo": { 108 | "id": 123456789, 109 | "url": "https://api.github.com/repos/example/example", 110 | "name": "example" 111 | } 112 | }, 113 | "base": { 114 | "ref": "master", 115 | "sha": "0000000000000000000000000000000000000000", 116 | "repo": { 117 | "id": 123456789, 118 | "url": "https://api.github.com/repos/example/example", 119 | "name": "example" 120 | } 121 | } 122 | } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /test/linters/params/eslint.js: -------------------------------------------------------------------------------- 1 | const ESLint = require("../../../src/linters/eslint"); 2 | const { joinDoubleBackslash } = require("../../test-utils"); 3 | 4 | const testName = "eslint"; 5 | const linter = ESLint; 6 | const extensions = ["js"]; 7 | 8 | // Linting without auto-fixing 9 | function getLintParams(dir) { 10 | const stdoutFile1 = `{"filePath":"${joinDoubleBackslash( 11 | dir, 12 | "file1.js", 13 | )}","messages":[{"ruleId":"prefer-const","severity":2,"message":"'str' is never reassigned. Use 'const' instead.","line":1,"column":5,"nodeType":"Identifier","messageId":"useConst","endLine":1,"endColumn":8,"fix":{"range":[0,3],"text":"const"}},{"ruleId":"no-warning-comments","severity":1,"message":"Unexpected 'todo' comment.","line":5,"column":31,"nodeType":"Line","endLine":5,"endColumn":56}],"errorCount":1,"warningCount":1,"fixableErrorCount":1,"fixableWarningCount":0,"source":"let str = 'world'; // \\"prefer-const\\" warning\\n\\nfunction main() {\\n\\t// \\"no-warning-comments\\" error\\n\\tconsole.log('hello ' + str); // TODO: Change something\\n}\\n\\nmain();\\n"}`; 14 | const stdoutFile2 = `{"filePath":"${joinDoubleBackslash( 15 | dir, 16 | "file2.js", 17 | )}","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'str' is assigned a value but never used.","line":1,"column":7,"nodeType":"Identifier","endLine":1,"endColumn":10}],"errorCount":1,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"const str = 'Hello world'; // \\"no-unused-vars\\" error\\n"}`; 18 | return { 19 | // Expected output of the linting function 20 | cmdOutput: { 21 | status: 1, 22 | stdoutParts: [stdoutFile1, stdoutFile2], 23 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 24 | }, 25 | // Expected output of the parsing function 26 | lintResult: { 27 | isSuccess: false, 28 | warning: [ 29 | { 30 | path: "file1.js", 31 | firstLine: 5, 32 | lastLine: 5, 33 | message: "Unexpected 'todo' comment (no-warning-comments)", 34 | }, 35 | ], 36 | error: [ 37 | { 38 | path: "file1.js", 39 | firstLine: 1, 40 | lastLine: 1, 41 | message: "'str' is never reassigned. Use 'const' instead (prefer-const)", 42 | }, 43 | { 44 | path: "file2.js", 45 | firstLine: 1, 46 | lastLine: 1, 47 | message: "'str' is assigned a value but never used (no-unused-vars)", 48 | }, 49 | ], 50 | }, 51 | }; 52 | } 53 | 54 | // Linting with auto-fixing 55 | function getFixParams(dir) { 56 | const stdoutFile1 = `{"filePath":"${joinDoubleBackslash( 57 | dir, 58 | "file1.js", 59 | )}","messages":[{"ruleId":"no-warning-comments","severity":1,"message":"Unexpected 'todo' comment.","line":5,"column":31,"nodeType":"Line","endLine":5,"endColumn":56}],"errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"output":"const str = 'world'; // \\"prefer-const\\" warning\\n\\nfunction main() {\\n\\t// \\"no-warning-comments\\" error\\n\\tconsole.log('hello ' + str); // TODO: Change something\\n}\\n\\nmain();\\n"}`; 60 | const stdoutFile2 = `{"filePath":"${joinDoubleBackslash( 61 | dir, 62 | "file2.js", 63 | )}","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'str' is assigned a value but never used.","line":1,"column":7,"nodeType":"Identifier","endLine":1,"endColumn":10}],"errorCount":1,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"const str = 'Hello world'; // \\"no-unused-vars\\" error\\n"}`; 64 | return { 65 | // Expected output of the linting function 66 | cmdOutput: { 67 | status: 1, 68 | stdoutParts: [stdoutFile1, stdoutFile2], 69 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 70 | }, 71 | // Expected output of the parsing function 72 | lintResult: { 73 | isSuccess: false, 74 | warning: [ 75 | { 76 | path: "file1.js", 77 | firstLine: 5, 78 | lastLine: 5, 79 | message: "Unexpected 'todo' comment (no-warning-comments)", 80 | }, 81 | ], 82 | error: [ 83 | { 84 | path: "file2.js", 85 | firstLine: 1, 86 | lastLine: 1, 87 | message: "'str' is assigned a value but never used (no-unused-vars)", 88 | }, 89 | ], 90 | }, 91 | }; 92 | } 93 | 94 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 95 | -------------------------------------------------------------------------------- /src/github/context.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require("fs"); 2 | 3 | const { name: actionName } = require("../../package"); 4 | const { getEnv, getInput } = require("../utils/action"); 5 | 6 | /** 7 | * GitHub Actions workflow's environment variables 8 | * @typedef {{actor: string, eventName: string, eventPath: string, token: string, workspace: 9 | * string}} ActionEnv 10 | */ 11 | 12 | /** 13 | * Information about the GitHub repository and its fork (if it exists) 14 | * @typedef {{repoName: string, forkName: string, hasFork: boolean}} GithubRepository 15 | */ 16 | 17 | /** 18 | * Information about the GitHub repository and action trigger event 19 | * @typedef {{actor: string, branch: string, event: object, eventName: string, repository: 20 | * GithubRepository, token: string, workspace: string}} GithubContext 21 | */ 22 | 23 | /** 24 | * Returns the GitHub Actions workflow's environment variables 25 | * @returns {ActionEnv} GitHub Actions workflow's environment variables 26 | */ 27 | function parseActionEnv() { 28 | return { 29 | // Information provided by environment 30 | actor: getEnv("github_actor", true), 31 | eventName: getEnv("github_event_name", true), 32 | eventPath: getEnv("github_event_path", true), 33 | workspace: getEnv("github_workspace", true), 34 | 35 | // Information provided by action user 36 | token: getInput("github_token", true), 37 | }; 38 | } 39 | 40 | /** 41 | * Parse `event.json` file (file with the complete webhook event payload, automatically provided by 42 | * GitHub) 43 | * @param {string} eventPath - Path to the `event.json` file 44 | * @returns {object} - Webhook event payload 45 | */ 46 | function parseEnvFile(eventPath) { 47 | const eventBuffer = readFileSync(eventPath); 48 | return JSON.parse(eventBuffer); 49 | } 50 | 51 | /** 52 | * Parses the name of the current branch from the GitHub webhook event 53 | * @param {string} eventName - GitHub event type 54 | * @param {object} event - GitHub webhook event payload 55 | * @returns {string} - Branch name 56 | */ 57 | function parseBranch(eventName, event) { 58 | if (eventName === "push") { 59 | return event.ref.substring(11); // Remove "refs/heads/" from start of string 60 | } 61 | if (eventName === "pull_request") { 62 | return event.pull_request.head.ref; 63 | } 64 | throw Error(`${actionName} does not support "${eventName}" GitHub events`); 65 | } 66 | 67 | /** 68 | * Parses the name of the current repository and determines whether it has a corresponding fork. 69 | * Fork detection is only supported for the "pull_request" event 70 | * @param {string} eventName - GitHub event type 71 | * @param {object} event - GitHub webhook event payload 72 | * @returns {GithubRepository} - Information about the GitHub repository and its fork (if it exists) 73 | */ 74 | function parseRepository(eventName, event) { 75 | const repoName = event.repository.full_name; 76 | let forkName; 77 | if (eventName === "pull_request") { 78 | // "pull_request" events are triggered on the repository where the PR is made. The PR branch can 79 | // be on the same repository (`forkRepository` is set to `null`) or on a fork (`forkRepository` 80 | // is defined) 81 | const headRepoName = event.pull_request.head.repo.full_name; 82 | forkName = repoName === headRepoName ? undefined : headRepoName; 83 | } 84 | return { 85 | repoName, 86 | forkName, 87 | hasFork: forkName != null && forkName !== repoName, 88 | }; 89 | } 90 | 91 | /** 92 | * Returns information about the GitHub repository and action trigger event 93 | * @returns {GithubContext} context - Information about the GitHub repository and action trigger 94 | * event 95 | */ 96 | function getContext() { 97 | const { actor, eventName, eventPath, token, workspace } = parseActionEnv(); 98 | const event = parseEnvFile(eventPath); 99 | return { 100 | actor, 101 | branch: parseBranch(eventName, event), 102 | event, 103 | eventName, 104 | repository: parseRepository(eventName, event), 105 | token, 106 | workspace, 107 | }; 108 | } 109 | 110 | module.exports = { 111 | getContext, 112 | parseActionEnv, 113 | parseBranch, 114 | parseEnvFile, 115 | parseRepository, 116 | }; 117 | -------------------------------------------------------------------------------- /test/linters/params/eslint-typescript.js: -------------------------------------------------------------------------------- 1 | const ESLint = require("../../../src/linters/eslint"); 2 | const { joinDoubleBackslash } = require("../../test-utils"); 3 | 4 | const testName = "eslint-typescript"; 5 | const linter = ESLint; 6 | const extensions = ["js", "ts"]; 7 | 8 | // Linting without auto-fixing 9 | function getLintParams(dir) { 10 | const stdoutFile1 = `{"filePath":"${joinDoubleBackslash( 11 | dir, 12 | "file1.ts", 13 | )}","messages":[{"ruleId":"prefer-const","severity":2,"message":"'str' is never reassigned. Use 'const' instead.","line":1,"column":5,"nodeType":"Identifier","messageId":"useConst","endLine":1,"endColumn":8,"fix":{"range":[0,3],"text":"const"}},{"ruleId":"no-warning-comments","severity":1,"message":"Unexpected 'todo' comment.","line":5,"column":31,"nodeType":"Line","endLine":5,"endColumn":56}],"errorCount":1,"warningCount":1,"fixableErrorCount":1,"fixableWarningCount":0,"source":"let str = 'world'; // \\"prefer-const\\" warning\\n\\nfunction main(): void {\\n\\t// \\"no-warning-comments\\" error\\n\\tconsole.log('hello ' + str); // TODO: Change something\\n}\\n\\nmain();\\n"}`; 14 | const stdoutFile2 = `{"filePath":"${joinDoubleBackslash( 15 | dir, 16 | "file2.js", 17 | )}","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'str' is assigned a value but never used.","line":1,"column":7,"nodeType":"Identifier","endLine":1,"endColumn":10}],"errorCount":1,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"const str = 'Hello world'; // \\"no-unused-vars\\" error\\n"}`; 18 | return { 19 | // Expected output of the linting function 20 | cmdOutput: { 21 | status: 1, 22 | stdoutParts: [stdoutFile1, stdoutFile2], 23 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 24 | }, 25 | // Expected output of the parsing function 26 | lintResult: { 27 | isSuccess: false, 28 | warning: [ 29 | { 30 | path: "file1.ts", 31 | firstLine: 5, 32 | lastLine: 5, 33 | message: "Unexpected 'todo' comment (no-warning-comments)", 34 | }, 35 | ], 36 | error: [ 37 | { 38 | path: "file1.ts", 39 | firstLine: 1, 40 | lastLine: 1, 41 | message: "'str' is never reassigned. Use 'const' instead (prefer-const)", 42 | }, 43 | { 44 | path: "file2.js", 45 | firstLine: 1, 46 | lastLine: 1, 47 | message: "'str' is assigned a value but never used (no-unused-vars)", 48 | }, 49 | ], 50 | }, 51 | }; 52 | } 53 | 54 | // Linting with auto-fixing 55 | function getFixParams(dir) { 56 | const stdoutFile1 = `{"filePath":"${joinDoubleBackslash( 57 | dir, 58 | "file1.ts", 59 | )}","messages":[{"ruleId":"no-warning-comments","severity":1,"message":"Unexpected 'todo' comment.","line":5,"column":31,"nodeType":"Line","endLine":5,"endColumn":56}],"errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"output":"const str = 'world'; // \\"prefer-const\\" warning\\n\\nfunction main(): void {\\n\\t// \\"no-warning-comments\\" error\\n\\tconsole.log('hello ' + str); // TODO: Change something\\n}\\n\\nmain();\\n"}`; 60 | const stdoutFile2 = `{"filePath":"${joinDoubleBackslash( 61 | dir, 62 | "file2.js", 63 | )}","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'str' is assigned a value but never used.","line":1,"column":7,"nodeType":"Identifier","endLine":1,"endColumn":10}],"errorCount":1,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"const str = 'Hello world'; // \\"no-unused-vars\\" error\\n"}`; 64 | return { 65 | // Expected output of the linting function 66 | cmdOutput: { 67 | status: 1, 68 | stdoutParts: [stdoutFile1, stdoutFile2], 69 | stdout: `[${stdoutFile1},${stdoutFile2}]`, 70 | }, 71 | // Expected output of the parsing function 72 | lintResult: { 73 | isSuccess: false, 74 | warning: [ 75 | { 76 | path: "file1.ts", 77 | firstLine: 5, 78 | lastLine: 5, 79 | message: "Unexpected 'todo' comment (no-warning-comments)", 80 | }, 81 | ], 82 | error: [ 83 | { 84 | path: "file2.js", 85 | firstLine: 1, 86 | lastLine: 1, 87 | message: "'str' is assigned a value but never used (no-unused-vars)", 88 | }, 89 | ], 90 | }, 91 | }; 92 | } 93 | 94 | module.exports = [testName, linter, extensions, getLintParams, getFixParams]; 95 | -------------------------------------------------------------------------------- /test/github/context.test.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const { 4 | parseActionEnv, 5 | parseBranch, 6 | parseEnvFile, 7 | parseRepository, 8 | } = require("../../src/github/context"); 9 | const prOpenEvent = require("./events/pull-request-open.json"); 10 | const prSyncEvent = require("./events/pull-request-sync.json"); 11 | const pushEvent = require("./events/push.json"); 12 | const { 13 | BRANCH, 14 | EVENT_NAME, 15 | EVENT_PATH, 16 | FORK_REPOSITORY, 17 | REPOSITORY, 18 | REPOSITORY_DIR, 19 | TOKEN, 20 | USERNAME, 21 | } = require("./test-constants"); 22 | 23 | const invalidEventPath = "/path/to/invalid/event.json"; 24 | const prOpenEventPath = join(__dirname, "events", "pull-request-open.json"); 25 | const prSyncEventPath = join(__dirname, "events", "pull-request-sync.json"); 26 | const pushEventPath = join(__dirname, "events", "push.json"); 27 | 28 | describe("parseActionEnv()", () => { 29 | // Environment variables provided by GitHub Actions 30 | const ENV = { 31 | GITHUB_ACTOR: USERNAME, 32 | GITHUB_EVENT_NAME: EVENT_NAME, 33 | GITHUB_EVENT_PATH: EVENT_PATH, 34 | GITHUB_WORKSPACE: REPOSITORY_DIR, 35 | INPUT_GITHUB_TOKEN: TOKEN, 36 | }; 37 | 38 | // Expected result from parsing the environment variables 39 | const EXPECTED = { 40 | actor: USERNAME, 41 | eventName: EVENT_NAME, 42 | eventPath: EVENT_PATH, 43 | token: TOKEN, 44 | workspace: REPOSITORY_DIR, 45 | }; 46 | 47 | // Add GitHub environment variables 48 | beforeEach(() => { 49 | process.env = { 50 | ...process.env, 51 | ...ENV, 52 | }; 53 | }); 54 | 55 | // Remove GitHub environment variables 56 | afterEach(() => { 57 | Object.keys(ENV).forEach((varName) => delete process.env[varName]); 58 | }); 59 | 60 | test("works when token is provided", () => { 61 | expect(parseActionEnv()).toEqual(EXPECTED); 62 | }); 63 | 64 | test("throws error when token is missing", () => { 65 | delete process.env.INPUT_GITHUB_TOKEN; 66 | expect(() => parseActionEnv()).toThrow(); 67 | process.env.INPUT_GITHUB_TOKEN = TOKEN; 68 | }); 69 | }); 70 | 71 | describe("parseEnvFile()", () => { 72 | test('parses "push" event successfully', () => { 73 | expect(parseEnvFile(pushEventPath)).toEqual(pushEvent); 74 | }); 75 | 76 | test('parses "pull_request" ("opened") event successfully', () => { 77 | expect(parseEnvFile(prOpenEventPath)).toEqual(prOpenEvent); 78 | }); 79 | 80 | test('parses "pull_request" ("synchronize") event successfully', () => { 81 | expect(parseEnvFile(prSyncEventPath)).toEqual(prSyncEvent); 82 | }); 83 | 84 | test("throws error for invalid `event.json` path", () => { 85 | expect(() => parseEnvFile(invalidEventPath)).toThrow(); 86 | }); 87 | }); 88 | 89 | describe("parseBranch()", () => { 90 | test('works with "push" event', () => { 91 | expect(parseBranch("push", pushEvent)).toEqual(BRANCH); 92 | }); 93 | 94 | test('works with "pull_request" event', () => { 95 | expect(parseBranch("pull_request", prOpenEvent)).toEqual(BRANCH); 96 | expect(parseBranch("pull_request", prSyncEvent)).toEqual(BRANCH); 97 | }); 98 | 99 | test("throws error for unsupported event", () => { 100 | expect(() => parseBranch("other_event", pushEvent)).toThrow(); 101 | }); 102 | }); 103 | 104 | describe("parseRepository()", () => { 105 | test('works with "push" event', () => { 106 | // Fork detection is not supported for "push" events 107 | expect(parseRepository("push", pushEvent)).toEqual({ 108 | repoName: REPOSITORY, 109 | forkName: undefined, 110 | hasFork: false, 111 | }); 112 | }); 113 | 114 | test('works with "pull_request" event on repository without fork', () => { 115 | expect(parseRepository("pull_request", prOpenEvent)).toEqual({ 116 | repoName: REPOSITORY, 117 | forkName: undefined, 118 | hasFork: false, 119 | }); 120 | 121 | expect(parseRepository("pull_request", prSyncEvent)).toEqual({ 122 | repoName: REPOSITORY, 123 | forkName: undefined, 124 | hasFork: false, 125 | }); 126 | }); 127 | 128 | test('works with "pull_request" event on repository with fork', () => { 129 | const prOpenEventMod = { ...prOpenEvent }; 130 | prOpenEventMod.pull_request.head.repo.full_name = FORK_REPOSITORY; 131 | expect(parseRepository("pull_request", prOpenEventMod)).toEqual({ 132 | repoName: REPOSITORY, 133 | forkName: FORK_REPOSITORY, 134 | hasFork: true, 135 | }); 136 | 137 | const prSyncEventMod = { ...prSyncEvent }; 138 | prSyncEventMod.pull_request.head.repo.full_name = FORK_REPOSITORY; 139 | expect(parseRepository("pull_request", prSyncEventMod)).toEqual({ 140 | repoName: REPOSITORY, 141 | forkName: FORK_REPOSITORY, 142 | hasFork: true, 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const git = require("./git"); 4 | const { createCheck } = require("./github/api"); 5 | const { getContext } = require("./github/context"); 6 | const linters = require("./linters"); 7 | const { getInput, log } = require("./utils/action"); 8 | const { getSummary } = require("./utils/lint-result"); 9 | 10 | // Abort action on unhandled promise rejections 11 | process.on("unhandledRejection", (err) => { 12 | log(err, "error"); 13 | throw new Error(`Exiting because of unhandled promise rejection`); 14 | }); 15 | 16 | /** 17 | * Parses the action configuration and runs all enabled linters on matching files 18 | */ 19 | async function runAction() { 20 | const context = getContext(); 21 | const autoFix = getInput("auto_fix") === "true"; 22 | const gitName = getInput("git_name", true); 23 | const gitEmail = getInput("git_email", true); 24 | const commitMessage = getInput("commit_message", true); 25 | 26 | // If on a PR from fork: Display messages regarding action limitations 27 | if (context.eventName === "pull_request" && context.repository.hasFork) { 28 | log( 29 | "This action does not have permission to create annotations on forks. You may want to run it only on `push` events. See https://github.com/samuelmeuli/lint-action/issues/13 for details", 30 | "error", 31 | ); 32 | if (autoFix) { 33 | log( 34 | "This action does not have permission to push to forks. You may want to run it only on `push` events. See https://github.com/samuelmeuli/lint-action/issues/13 for details", 35 | "error", 36 | ); 37 | } 38 | } 39 | 40 | if (autoFix) { 41 | // Set Git committer username and password 42 | git.setUserInfo(gitName, gitEmail); 43 | } 44 | if (context.eventName === "pull_request") { 45 | // Fetch and check out PR branch: 46 | // - "push" event: Already on correct branch 47 | // - "pull_request" event on origin, for code on origin: The Checkout Action 48 | // (https://github.com/actions/checkout) checks out the PR's test merge commit instead of the 49 | // PR branch. Git is therefore in detached head state. To be able to push changes, the branch 50 | // needs to be fetched and checked out first 51 | // - "pull_request" event on origin, for code on fork: Same as above, but the repo/branch where 52 | // changes need to be pushed is not yet available. The fork needs to be added as a Git remote 53 | // first 54 | git.checkOutRemoteBranch(context); 55 | } 56 | 57 | const checks = []; 58 | 59 | // Loop over all available linters 60 | for (const [linterId, linter] of Object.entries(linters)) { 61 | // Determine whether the linter should be executed on the commit 62 | if (getInput(linterId) === "true") { 63 | const fileExtensions = getInput(`${linterId}_extensions`, true); 64 | const args = getInput(`${linterId}_args`) || ""; 65 | const lintDirRel = getInput(`${linterId}_dir`) || "."; 66 | const prefix = getInput(`${linterId}_command_prefix`) || ""; 67 | const lintDirAbs = join(context.workspace, lintDirRel); 68 | 69 | // Check that the linter and its dependencies are installed 70 | log(`\nVerifying setup for ${linter.name}…`); 71 | await linter.verifySetup(lintDirAbs, prefix); 72 | log(`Verified ${linter.name} setup`); 73 | 74 | // Determine which files should be linted 75 | const fileExtList = fileExtensions.split(","); 76 | log(`Will use ${linter.name} to check the files with extensions ${fileExtList}`); 77 | 78 | // Lint and optionally auto-fix the matching files, parse code style violations 79 | log( 80 | `Linting ${autoFix ? "and auto-fixing " : ""}files in ${lintDirAbs} with ${linter.name}…`, 81 | ); 82 | const lintOutput = linter.lint(lintDirAbs, fileExtList, args, autoFix, prefix); 83 | 84 | // Parse output of linting command 85 | const lintResult = linter.parseOutput(context.workspace, lintOutput); 86 | const summary = getSummary(lintResult); 87 | log(`${linter.name} found ${summary} (${lintResult.isSuccess ? "success" : "failure"})`); 88 | 89 | if (autoFix) { 90 | // Commit and push auto-fix changes 91 | if (git.hasChanges()) { 92 | git.commitChanges(commitMessage.replace(/\${linter}/g, linter.name)); 93 | git.pushChanges(); 94 | } 95 | } 96 | 97 | checks.push({ checkName: linter.name, lintResult, summary }); 98 | } 99 | } 100 | 101 | // Add commit annotations after running all linters. To be displayed on pull requests, the 102 | // annotations must be added to the last commit on the branch. This can either be a user commit or 103 | // one of the auto-fix commits 104 | log(""); // Create empty line in logs 105 | const headSha = git.getHeadSha(); 106 | await Promise.all( 107 | checks.map(({ checkName, lintResult, summary }) => 108 | createCheck(checkName, headSha, context, lintResult, summary), 109 | ), 110 | ); 111 | } 112 | 113 | runAction(); 114 | -------------------------------------------------------------------------------- /test/github/events/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "after": "b2a3295e4bb488deec97238f0af95a9fb0abb40c", 3 | "base_ref": null, 4 | "before": "0000000000000000000000000000000000000000", 5 | "commits": [ 6 | { 7 | "author": { 8 | "email": "test@example.com", 9 | "name": "Test User", 10 | "username": "test-user" 11 | }, 12 | "committer": { 13 | "email": "test@example.com", 14 | "name": "Test User", 15 | "username": "test-user" 16 | }, 17 | "distinct": true, 18 | "id": "b2a3295e4bb488deec97238f0af95a9fb0abb40c", 19 | "message": "Test commit", 20 | "timestamp": "2020-01-06T17:31:04+01:00", 21 | "tree_id": "0f4ac955a3d227b4243de3500f4ec0ab3cc5e1cc", 22 | "url": "https://github.com/test-user/test-repo/commit/b2a3295e4bb488deec97238f0af95a9fb0abb40c" 23 | } 24 | ], 25 | "compare": "https://github.com/test-user/test-repo/commit/b2a3295e4bb4", 26 | "created": true, 27 | "deleted": false, 28 | "forced": false, 29 | "head_commit": { 30 | "author": { 31 | "email": "test@example.com", 32 | "name": "Test User", 33 | "username": "test-user" 34 | }, 35 | "committer": { 36 | "email": "test@example.com", 37 | "name": "Test User", 38 | "username": "test-user" 39 | }, 40 | "distinct": true, 41 | "id": "b2a3295e4bb488deec97238f0af95a9fb0abb40c", 42 | "message": "Test commit", 43 | "timestamp": "2020-01-06T17:31:04+01:00", 44 | "tree_id": "0f4ac955a3d227b4243de3500f4ec0ab3cc5e1cc", 45 | "url": "https://github.com/test-user/test-repo/commit/b2a3295e4bb488deec97238f0af95a9fb0abb40c" 46 | }, 47 | "pusher": { 48 | "email": "test@example.com", 49 | "name": "test-user" 50 | }, 51 | "ref": "refs/heads/test-branch", 52 | "repository": { 53 | "archive_url": "https://api.github.com/repos/test-user/test-repo/{archive_format}{/ref}", 54 | "archived": false, 55 | "assignees_url": "https://api.github.com/repos/test-user/test-repo/assignees{/user}", 56 | "blobs_url": "https://api.github.com/repos/test-user/test-repo/git/blobs{/sha}", 57 | "branches_url": "https://api.github.com/repos/test-user/test-repo/branches{/branch}", 58 | "clone_url": "https://github.com/test-user/test-repo.git", 59 | "collaborators_url": "https://api.github.com/repos/test-user/test-repo/collaborators{/collaborator}", 60 | "comments_url": "https://api.github.com/repos/test-user/test-repo/comments{/number}", 61 | "commits_url": "https://api.github.com/repos/test-user/test-repo/commits{/sha}", 62 | "compare_url": "https://api.github.com/repos/test-user/test-repo/compare/{base}...{head}", 63 | "contents_url": "https://api.github.com/repos/test-user/test-repo/contents/{+path}", 64 | "contributors_url": "https://api.github.com/repos/test-user/test-repo/contributors", 65 | "created_at": 1578328011, 66 | "default_branch": "master", 67 | "deployments_url": "https://api.github.com/repos/test-user/test-repo/deployments", 68 | "description": null, 69 | "disabled": false, 70 | "downloads_url": "https://api.github.com/repos/test-user/test-repo/downloads", 71 | "events_url": "https://api.github.com/repos/test-user/test-repo/events", 72 | "fork": false, 73 | "forks": 0, 74 | "forks_count": 0, 75 | "forks_url": "https://api.github.com/repos/test-user/test-repo/forks", 76 | "full_name": "test-user/test-repo", 77 | "git_commits_url": "https://api.github.com/repos/test-user/test-repo/git/commits{/sha}", 78 | "git_refs_url": "https://api.github.com/repos/test-user/test-repo/git/refs{/sha}", 79 | "git_tags_url": "https://api.github.com/repos/test-user/test-repo/git/tags{/sha}", 80 | "git_url": "git://github.com/test-user/test-repo.git", 81 | "has_downloads": true, 82 | "has_issues": true, 83 | "has_pages": false, 84 | "has_projects": true, 85 | "has_wiki": true, 86 | "homepage": null, 87 | "hooks_url": "https://api.github.com/repos/test-user/test-repo/hooks", 88 | "html_url": "https://github.com/test-user/test-repo", 89 | "id": 123456789, 90 | "issue_comment_url": "https://api.github.com/repos/test-user/test-repo/issues/comments{/number}", 91 | "issue_events_url": "https://api.github.com/repos/test-user/test-repo/issues/events{/number}", 92 | "issues_url": "https://api.github.com/repos/test-user/test-repo/issues{/number}", 93 | "keys_url": "https://api.github.com/repos/test-user/test-repo/keys{/key_id}", 94 | "labels_url": "https://api.github.com/repos/test-user/test-repo/labels{/name}", 95 | "language": null, 96 | "languages_url": "https://api.github.com/repos/test-user/test-repo/languages", 97 | "license": null, 98 | "master_branch": "master", 99 | "merges_url": "https://api.github.com/repos/test-user/test-repo/merges", 100 | "milestones_url": "https://api.github.com/repos/test-user/test-repo/milestones{/number}", 101 | "mirror_url": null, 102 | "name": "test-repo", 103 | "node_id": "abc123", 104 | "notifications_url": "https://api.github.com/repos/test-user/test-repo/notifications{?since,all,participating}", 105 | "open_issues": 0, 106 | "open_issues_count": 0, 107 | "owner": { 108 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 109 | "email": "test@example.com", 110 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 111 | "followers_url": "https://api.github.com/users/test-user/followers", 112 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 113 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 114 | "gravatar_id": "", 115 | "html_url": "https://github.com/test-user", 116 | "id": 123456789, 117 | "login": "test-user", 118 | "name": "test-user", 119 | "node_id": "abc123", 120 | "organizations_url": "https://api.github.com/users/test-user/orgs", 121 | "received_events_url": "https://api.github.com/users/test-user/received_events", 122 | "repos_url": "https://api.github.com/users/test-user/repos", 123 | "site_admin": false, 124 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 125 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 126 | "type": "User", 127 | "url": "https://api.github.com/users/test-user" 128 | }, 129 | "private": true, 130 | "pulls_url": "https://api.github.com/repos/test-user/test-repo/pulls{/number}", 131 | "pushed_at": 1578328271, 132 | "releases_url": "https://api.github.com/repos/test-user/test-repo/releases{/id}", 133 | "size": 0, 134 | "ssh_url": "git@github.com:test-user/test-repo.git", 135 | "stargazers": 0, 136 | "stargazers_count": 0, 137 | "stargazers_url": "https://api.github.com/repos/test-user/test-repo/stargazers", 138 | "statuses_url": "https://api.github.com/repos/test-user/test-repo/statuses/{sha}", 139 | "subscribers_url": "https://api.github.com/repos/test-user/test-repo/subscribers", 140 | "subscription_url": "https://api.github.com/repos/test-user/test-repo/subscription", 141 | "svn_url": "https://github.com/test-user/test-repo", 142 | "tags_url": "https://api.github.com/repos/test-user/test-repo/tags", 143 | "teams_url": "https://api.github.com/repos/test-user/test-repo/teams", 144 | "trees_url": "https://api.github.com/repos/test-user/test-repo/git/trees{/sha}", 145 | "updated_at": "2020-01-06T16:30:17Z", 146 | "url": "https://github.com/test-user/test-repo", 147 | "watchers": 0, 148 | "watchers_count": 0 149 | }, 150 | "sender": { 151 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 152 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 153 | "followers_url": "https://api.github.com/users/test-user/followers", 154 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 155 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 156 | "gravatar_id": "", 157 | "html_url": "https://github.com/test-user", 158 | "id": 123456789, 159 | "login": "test-user", 160 | "node_id": "abc123", 161 | "organizations_url": "https://api.github.com/users/test-user/orgs", 162 | "received_events_url": "https://api.github.com/users/test-user/received_events", 163 | "repos_url": "https://api.github.com/users/test-user/repos", 164 | "site_admin": false, 165 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 166 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 167 | "type": "User", 168 | "url": "https://api.github.com/users/test-user" 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ✨ Lint Action 2 | 3 | - **Shows linting errors** on GitHub commits and PRs 4 | - Allows **auto-fixing** issues 5 | - Supports [many linters and formatters](#supported-tools) 6 | 7 | _**Note:** The behavior of actions like this one is currently limited in the context of forks. See [Limitations](#limitations)._ 8 | 9 | ## Screenshots 10 | 11 | - Checks on pull requests: 12 | 13 | Screenshot of check runs 14 | 15 | - Commit annotations: 16 | 17 | Screenshot of ESLint annotations 18 | 19 | ## Supported tools 20 | 21 | - **CSS:** 22 | - [stylelint](https://stylelint.io) 23 | - **Go:** 24 | - [gofmt](https://golang.org/cmd/gofmt) 25 | - [golint](https://github.com/golang/lint) 26 | - **JavaScript:** 27 | - [ESLint](https://eslint.org) 28 | - [Prettier](https://prettier.io) 29 | - [XO](https://github.com/xojs/xo) 30 | - **Python:** 31 | - [Black](https://black.readthedocs.io) 32 | - [Flake8](http://flake8.pycqa.org) 33 | - [Mypy](https://mypy.readthedocs.io/) 34 | - **Ruby:** 35 | - [RuboCop](https://rubocop.readthedocs.io) 36 | - **Swift:** 37 | - [swift-format](https://github.com/apple/swift-format) (official) 38 | - [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) (by Nick Lockwood) 39 | - [SwiftLint](https://github.com/realm/SwiftLint) 40 | 41 | ## Usage 42 | 43 | Create a new GitHub Actions workflow in your project, e.g. at `.github/workflows/lint.yml`. The content of the file should be in the following format: 44 | 45 | ```yml 46 | name: Lint 47 | 48 | on: push 49 | 50 | jobs: 51 | run-linters: 52 | name: Run linters 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Check out Git repository 57 | uses: actions/checkout@v2 58 | 59 | # Install your linters here 60 | 61 | - name: Run linters 62 | uses: samuelmeuli/lint-action@v1 63 | with: 64 | github_token: ${{ secrets.github_token }} 65 | # Enable your linters here 66 | ``` 67 | 68 | ## Examples 69 | 70 | All linters are disabled by default. To enable a linter, simply set the option with its name to `true`, e.g. `eslint: true`. 71 | 72 | The action doesn't install the linters for you; you are responsible for installing them in your CI environment. 73 | 74 | ### JavaScript example (ESLint and Prettier) 75 | 76 | ```yml 77 | name: Lint 78 | 79 | on: push 80 | 81 | jobs: 82 | run-linters: 83 | name: Run linters 84 | runs-on: ubuntu-latest 85 | 86 | steps: 87 | - name: Check out Git repository 88 | uses: actions/checkout@v2 89 | 90 | - name: Set up Node.js 91 | uses: actions/setup-node@v1 92 | with: 93 | node-version: 12 94 | 95 | # ESLint and Prettier must be in `package.json` 96 | - name: Install Node.js dependencies 97 | run: npm install 98 | 99 | - name: Run linters 100 | uses: samuelmeuli/lint-action@v1 101 | with: 102 | github_token: ${{ secrets.github_token }} 103 | # Enable linters 104 | eslint: true 105 | prettier: true 106 | ``` 107 | 108 | ### Python example (Flake8 and Black) 109 | 110 | ```yml 111 | name: Lint 112 | 113 | on: push 114 | 115 | jobs: 116 | run-linters: 117 | name: Run linters 118 | runs-on: ubuntu-latest 119 | 120 | steps: 121 | - name: Check out Git repository 122 | uses: actions/checkout@v2 123 | 124 | - name: Set up Python 125 | uses: actions/setup-python@v1 126 | with: 127 | python-version: 3.8 128 | 129 | - name: Install Python dependencies 130 | run: pip install black flake8 131 | 132 | - name: Run linters 133 | uses: samuelmeuli/lint-action@v1 134 | with: 135 | github_token: ${{ secrets.github_token }} 136 | # Enable linters 137 | black: true 138 | flake8: true 139 | ``` 140 | 141 | ## Configuration 142 | 143 | ### Linter-specific options 144 | 145 | `[linter]` can be one of `black`, `eslint`, `flake8`, `gofmt`, `golint`, `mypy`, `prettier`, `rubocop`, `stylelint`, `swift_format_official`, `swift_format_lockwood`, `swiftlint` and `xo`: 146 | 147 | - **`[linter]`:** Enables the linter in your repository. Default: `false` 148 | - **`[linter]_args`**: Additional arguments to pass to the linter. Example: `eslint_args: "--max-warnings 0"` if ESLint checks should fail even if there are no errors and only warnings. Default: `""` 149 | - **`[linter]_dir`**: Directory where the linting command should be run. Example: `eslint_dir: server/` if ESLint is installed in the `server` subdirectory. Default: Repository root 150 | - **`[linter]_extensions`:** Extensions of files to check with the linter. Example: `eslint_extensions: js,ts` to lint JavaScript and TypeScript files with ESLint. Default: Varies by linter, see [`action.yml`](./action.yml) 151 | - **`[linter]_command_prefix`:** Command prefix to be run before the linter command. Default: `""`. 152 | 153 | ### General options 154 | 155 | - **`auto_fix`:** Whether linters should try to fix code style issues automatically. If some issues can be fixed, the action will commit and push the changes to the corresponding branch. Default: `false` 156 | 157 |

158 | Screenshot of auto-fix commit 159 |

160 | 161 | - **`git_name`**: Username for auto-fix commits. Default: `"Lint Action"` 162 | 163 | - **`git_email`**: Email address for auto-fix commits. Default: `"lint-action@samuelmeuli.com"` 164 | 165 | - **`commit_message`**: Template for auto-fix commit messages. The `${linter}` variable can be used to insert the name of the linter. Default: `"Fix code style issues with ${linter}"` 166 | 167 | ## Limitations 168 | 169 | There are currently some limitations as to how this action (or any other action) can be used in the context of `pull_request` events from forks: 170 | 171 | - The action doesn't have permission to push auto-fix changes to the fork. This is because the `pull_request` event runs on the upstream repo, where the `github_token` is lacking permissions for the fork. [Source](https://github.community/t5/GitHub-Actions/Can-t-push-to-forked-repository-on-the-original-repository-s/m-p/35916/highlight/true#M2372) 172 | - The action doesn't have permission to create annotations for commits on forks and can therefore not display linting errors. [Source 1](https://github.community/t5/GitHub-Actions/Token-permissions-for-forks-once-again/m-p/33839), [source 2](https://github.com/actions/labeler/issues/12) 173 | 174 | For details and comments, please refer to [#13](https://github.com/samuelmeuli/lint-action/issues/13). 175 | 176 | ## Development 177 | 178 | ### Contributing 179 | 180 | Suggestions and contributions are always welcome! Please discuss larger changes via issue before submitting a pull request. 181 | 182 | ### Adding a new linter 183 | 184 | If you want to add support for an additional linter, please open an issue to discuss its inclusion in the project. Afterwards, you can follow these steps to add support for your linter: 185 | 186 | - Clone the repository and install its dependencies with `yarn install`. 187 | - Create a new class for the linter, e.g. `src/linters/my-linter.js`. Have a look at the other files in that directory to see what functions the class needs to implement. 188 | - Import your class in the [`src/linters/index.js`](./src/linters/index.js) file. 189 | - Provide a sample project for the linter under `test/linters/projects/my-linter/`. It should be simple and contain a few linting errors which your tests will detect. 190 | - Provide the expected linting output for your sample project in a `test/linters/params/my-linter.js` file. Import this file in [`test/linters/linters.test.js`](./test/linters/linters.test.js). You can run the tests with `yarn test`. 191 | - Update the [`action.yml`](./action.yml) file with the options provided by the new linter. 192 | - Mention your linter in the [`README.md`](./README.md) file. 193 | - Update the [test workflow file](./.github/workflows/test.yml). 194 | 195 | ## Related 196 | 197 | - [Electron Builder Action](https://github.com/samuelmeuli/action-electron-builder) – GitHub Action for building and releasing Electron apps 198 | - [Maven Publish Action](https://github.com/samuelmeuli/action-maven-publish) – GitHub Action for automatically publishing Maven packages 199 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Lint Action 2 | author: Samuel Meuli 3 | description: GitHub Action for detecting and fixing linting errors 4 | 5 | inputs: 6 | github_token: 7 | description: The GITHUB_TOKEN secret 8 | required: true 9 | auto_fix: 10 | description: Whether linters should try to fix code style issues automatically 11 | required: false 12 | default: false 13 | git_name: 14 | description: Username for auto-fix commits 15 | required: false 16 | default: Lint Action 17 | git_email: 18 | description: Email address for auto-fix commits 19 | required: false 20 | default: "lint-action@samuelmeuli.com" 21 | commit_message: 22 | description: 'Template for auto-fix commit messages. The "${linter}" variable can be used to insert the name of the linter which has created the auto-fix' 23 | required: false 24 | default: "Fix code style issues with ${linter}" 25 | 26 | # CSS 27 | 28 | stylelint: 29 | description: Enable or disable stylelint checks 30 | required: false 31 | default: false 32 | stylelint_args: 33 | description: Additional arguments to pass to the linter 34 | required: false 35 | default: "" 36 | stylelint_dir: 37 | description: Directory where the stylelint command should be run 38 | required: false 39 | stylelint_extensions: 40 | description: Extensions of files to check with stylelint 41 | required: false 42 | default: "css,sass,scss" 43 | stylelint_command_prefix: 44 | description: Shell command to prepend to the linter command 45 | required: false 46 | default: "" 47 | 48 | # Go 49 | 50 | gofmt: 51 | description: Enable or disable gofmt checks 52 | required: false 53 | default: false 54 | gofmt_args: 55 | description: Additional arguments to pass to the linter 56 | required: false 57 | default: "" 58 | gofmt_dir: 59 | description: Directory where the gofmt command should be run 60 | required: false 61 | gofmt_extensions: 62 | description: Extensions of files to check with gofmt 63 | required: false 64 | default: "go" 65 | gofmt_command_prefix: 66 | description: Shell command to prepend to the linter command 67 | required: false 68 | default: "" 69 | 70 | golint: 71 | description: Enable or disable golint checks 72 | required: false 73 | default: false 74 | golint_args: 75 | description: Additional arguments to pass to the linter 76 | required: false 77 | default: "" 78 | golint_dir: 79 | description: Directory where the golint command should be run 80 | required: false 81 | golint_extensions: 82 | description: Extensions of files to check with golint 83 | required: false 84 | default: "go" 85 | golint_command_prefix: 86 | description: Shell command to prepend to the linter command 87 | required: false 88 | default: "" 89 | 90 | # JavaScript 91 | 92 | eslint: 93 | description: Enable or disable ESLint checks 94 | required: false 95 | default: false 96 | eslint_args: 97 | description: Additional arguments to pass to the linter 98 | required: false 99 | default: "" 100 | eslint_dir: 101 | description: Directory where the ESLint command should be run 102 | required: false 103 | eslint_extensions: 104 | description: Extensions of files to check with ESLint 105 | required: false 106 | default: "js" 107 | eslint_command_prefix: 108 | description: Shell command to prepend to the linter command. Will default to `npx --no-install` for NPM and `yarn run --silent` for Yarn. 109 | required: false 110 | default: "" 111 | 112 | prettier: 113 | description: Enable or disable Prettier checks 114 | required: false 115 | default: false 116 | prettier_args: 117 | description: Additional arguments to pass to the linter 118 | required: false 119 | default: "" 120 | prettier_dir: 121 | description: Directory where the Prettier command should be run 122 | required: false 123 | prettier_extensions: 124 | description: Extensions of files to check with Prettier 125 | required: false 126 | default: "css,html,js,json,jsx,md,sass,scss,ts,tsx,vue,yaml,yml" 127 | prettier_command_prefix: 128 | description: Shell command to prepend to the linter command. Will default to `npx --no-install` for NPM and `yarn run --silent` for Yarn. 129 | required: false 130 | default: "" 131 | 132 | xo: 133 | description: Enable or disable XO checks 134 | required: false 135 | default: false 136 | xo_args: 137 | description: Additional arguments to pass to the linter 138 | required: false 139 | default: "" 140 | xo_dir: 141 | description: Directory where the XO command should be run 142 | required: false 143 | xo_extensions: 144 | description: Extensions of files to check with XO 145 | required: false 146 | default: "js" 147 | xo_command_prefix: 148 | description: Shell command to prepend to the linter command. Will default to `npx --no-install` for NPM and `yarn run --silent` for Yarn. 149 | required: false 150 | default: "" 151 | 152 | # Python 153 | 154 | black: 155 | description: Enable or disable Black checks 156 | required: false 157 | default: false 158 | black_args: 159 | description: Additional arguments to pass to the linter 160 | required: false 161 | default: "" 162 | black_dir: 163 | description: Directory where the Black command should be run 164 | required: false 165 | black_extensions: 166 | description: Extensions of files to check with Black 167 | required: false 168 | default: "py" 169 | black_command_prefix: 170 | description: Shell command to prepend to the linter command 171 | required: false 172 | default: "" 173 | 174 | flake8: 175 | description: Enable or disable Flake8 checks 176 | required: false 177 | default: false 178 | flake8_args: 179 | description: Additional arguments to pass to the linter 180 | required: false 181 | default: "" 182 | flake8_dir: 183 | description: Directory where the Flake8 command should be run 184 | required: false 185 | flake8_extensions: 186 | description: Extensions of files to check with Flake8 187 | required: false 188 | default: "py" 189 | flake8_command_prefix: 190 | description: Shell command to prepend to the linter command 191 | required: false 192 | default: "" 193 | 194 | mypy: 195 | description: Enable or disable Mypy checks 196 | required: false 197 | default: false 198 | mypy_args: 199 | description: Additional arguments to pass to the linter 200 | required: false 201 | default: "" 202 | mypy_dir: 203 | description: Directory where the Mypy command should be run 204 | required: false 205 | mypy_extensions: 206 | description: Extensions of files to check with Mypy 207 | required: false 208 | default: "py" 209 | mypy_command_prefix: 210 | description: Shell command to prepend to the linter command 211 | required: false 212 | default: "" 213 | 214 | # Ruby 215 | 216 | rubocop: 217 | description: Enable or disable RuboCop checks 218 | required: false 219 | default: false 220 | rubocop_args: 221 | description: Additional arguments to pass to the linter 222 | required: false 223 | default: "" 224 | rubocop_dir: 225 | description: Directory where the RuboCop command should be run 226 | required: false 227 | rubocop_extensions: 228 | description: Extensions of files to check with RuboCop 229 | required: false 230 | default: "rb" 231 | rubocop_command_prefix: 232 | description: Shell command to prepend to the linter command 233 | required: false 234 | default: "" 235 | 236 | # Swift 237 | 238 | # Alias of `swift_format_lockwood` (for backward compatibility) 239 | # TODO: Remove alias in v2 240 | swiftformat: 241 | description: Enable or disable SwiftFormat checks 242 | required: false 243 | default: false 244 | swiftformat_args: 245 | description: Additional arguments to pass to the linter 246 | required: false 247 | default: "" 248 | swiftformat_dir: 249 | description: Directory where the SwiftFormat command should be run 250 | required: false 251 | swiftformat_extensions: 252 | description: Extensions of files to check with SwiftFormat 253 | required: false 254 | default: "swift" 255 | swiftformat_command_prefix: 256 | description: Shell command to prepend to the linter command 257 | required: false 258 | default: "" 259 | 260 | swift_format_lockwood: 261 | description: Enable or disable SwiftFormat checks 262 | required: false 263 | default: false 264 | swift_format_lockwood_args: 265 | description: Additional arguments to pass to the linter 266 | required: false 267 | default: "" 268 | swift_format_lockwood_dir: 269 | description: Directory where the SwiftFormat command should be run 270 | required: false 271 | swift_format_lockwood_extensions: 272 | description: Extensions of files to check with SwiftFormat 273 | required: false 274 | default: "swift" 275 | swift_format_lockwood_command_prefix: 276 | description: Shell command to prepend to the linter command 277 | required: false 278 | default: "" 279 | 280 | swift_format_official: 281 | description: Enable or disable swift-format checks 282 | required: false 283 | default: false 284 | swift_format_official_args: 285 | description: Additional arguments to pass to the linter 286 | required: false 287 | default: "" 288 | swift_format_official_dir: 289 | description: Directory where the swift-format command should be run 290 | required: false 291 | swift_format_official_extensions: 292 | description: Extrensions of files to check with swift-format 293 | required: false 294 | default: "swift" 295 | swift_format_official_command_prefix: 296 | description: Shell command to prepend to the linter command 297 | required: false 298 | default: "" 299 | 300 | swiftlint: 301 | description: Enable or disable SwiftLint checks 302 | required: false 303 | default: false 304 | swiftlint_args: 305 | description: Additional arguments to pass to the linter 306 | required: false 307 | default: "" 308 | swiftlint_dir: 309 | description: Directory where the SwiftLint command should be run 310 | required: false 311 | swiftlint_extensions: 312 | description: Extensions of files to check with SwiftLint 313 | required: false 314 | default: "swift" 315 | swiftlint_command_prefix: 316 | description: Shell command to prepend to the linter command 317 | required: false 318 | default: "" 319 | 320 | runs: 321 | using: node12 322 | main: ./src/index.js 323 | 324 | branding: 325 | icon: check 326 | color: green 327 | -------------------------------------------------------------------------------- /test/github/events/pull-request-open.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 1, 4 | "pull_request": { 5 | "_links": { 6 | "comments": { 7 | "href": "https://api.github.com/repos/test-user/test-repo/issues/1/comments" 8 | }, 9 | "commits": { 10 | "href": "https://api.github.com/repos/test-user/test-repo/pulls/1/commits" 11 | }, 12 | "html": { 13 | "href": "https://github.com/test-user/test-repo/pull/1" 14 | }, 15 | "issue": { 16 | "href": "https://api.github.com/repos/test-user/test-repo/issues/1" 17 | }, 18 | "review_comment": { 19 | "href": "https://api.github.com/repos/test-user/test-repo/pulls/comments{/number}" 20 | }, 21 | "review_comments": { 22 | "href": "https://api.github.com/repos/test-user/test-repo/pulls/1/comments" 23 | }, 24 | "self": { 25 | "href": "https://api.github.com/repos/test-user/test-repo/pulls/1" 26 | }, 27 | "statuses": { 28 | "href": "https://api.github.com/repos/test-user/test-repo/statuses/b2a3295e4bb488deec97238f0af95a9fb0abb40c" 29 | } 30 | }, 31 | "additions": 0, 32 | "assignee": null, 33 | "assignees": [], 34 | "author_association": "OWNER", 35 | "base": { 36 | "label": "test-user:master", 37 | "ref": "master", 38 | "repo": { 39 | "archive_url": "https://api.github.com/repos/test-user/test-repo/{archive_format}{/ref}", 40 | "archived": false, 41 | "assignees_url": "https://api.github.com/repos/test-user/test-repo/assignees{/user}", 42 | "blobs_url": "https://api.github.com/repos/test-user/test-repo/git/blobs{/sha}", 43 | "branches_url": "https://api.github.com/repos/test-user/test-repo/branches{/branch}", 44 | "clone_url": "https://github.com/test-user/test-repo.git", 45 | "collaborators_url": "https://api.github.com/repos/test-user/test-repo/collaborators{/collaborator}", 46 | "comments_url": "https://api.github.com/repos/test-user/test-repo/comments{/number}", 47 | "commits_url": "https://api.github.com/repos/test-user/test-repo/commits{/sha}", 48 | "compare_url": "https://api.github.com/repos/test-user/test-repo/compare/{base}...{head}", 49 | "contents_url": "https://api.github.com/repos/test-user/test-repo/contents/{+path}", 50 | "contributors_url": "https://api.github.com/repos/test-user/test-repo/contributors", 51 | "created_at": "2020-01-06T16:26:51Z", 52 | "default_branch": "master", 53 | "deployments_url": "https://api.github.com/repos/test-user/test-repo/deployments", 54 | "description": null, 55 | "disabled": false, 56 | "downloads_url": "https://api.github.com/repos/test-user/test-repo/downloads", 57 | "events_url": "https://api.github.com/repos/test-user/test-repo/events", 58 | "fork": false, 59 | "forks": 0, 60 | "forks_count": 0, 61 | "forks_url": "https://api.github.com/repos/test-user/test-repo/forks", 62 | "full_name": "test-user/test-repo", 63 | "git_commits_url": "https://api.github.com/repos/test-user/test-repo/git/commits{/sha}", 64 | "git_refs_url": "https://api.github.com/repos/test-user/test-repo/git/refs{/sha}", 65 | "git_tags_url": "https://api.github.com/repos/test-user/test-repo/git/tags{/sha}", 66 | "git_url": "git://github.com/test-user/test-repo.git", 67 | "has_downloads": true, 68 | "has_issues": true, 69 | "has_pages": false, 70 | "has_projects": true, 71 | "has_wiki": true, 72 | "homepage": null, 73 | "hooks_url": "https://api.github.com/repos/test-user/test-repo/hooks", 74 | "html_url": "https://github.com/test-user/test-repo", 75 | "id": 123456789, 76 | "issue_comment_url": "https://api.github.com/repos/test-user/test-repo/issues/comments{/number}", 77 | "issue_events_url": "https://api.github.com/repos/test-user/test-repo/issues/events{/number}", 78 | "issues_url": "https://api.github.com/repos/test-user/test-repo/issues{/number}", 79 | "keys_url": "https://api.github.com/repos/test-user/test-repo/keys{/key_id}", 80 | "labels_url": "https://api.github.com/repos/test-user/test-repo/labels{/name}", 81 | "language": null, 82 | "languages_url": "https://api.github.com/repos/test-user/test-repo/languages", 83 | "license": null, 84 | "merges_url": "https://api.github.com/repos/test-user/test-repo/merges", 85 | "milestones_url": "https://api.github.com/repos/test-user/test-repo/milestones{/number}", 86 | "mirror_url": null, 87 | "name": "test-repo", 88 | "node_id": "abc123", 89 | "notifications_url": "https://api.github.com/repos/test-user/test-repo/notifications{?since,all,participating}", 90 | "open_issues": 1, 91 | "open_issues_count": 1, 92 | "owner": { 93 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 94 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 95 | "followers_url": "https://api.github.com/users/test-user/followers", 96 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 97 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 98 | "gravatar_id": "", 99 | "html_url": "https://github.com/test-user", 100 | "id": 123456789, 101 | "login": "test-user", 102 | "node_id": "abc123", 103 | "organizations_url": "https://api.github.com/users/test-user/orgs", 104 | "received_events_url": "https://api.github.com/users/test-user/received_events", 105 | "repos_url": "https://api.github.com/users/test-user/repos", 106 | "site_admin": false, 107 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 108 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 109 | "type": "User", 110 | "url": "https://api.github.com/users/test-user" 111 | }, 112 | "private": true, 113 | "pulls_url": "https://api.github.com/repos/test-user/test-repo/pulls{/number}", 114 | "pushed_at": "2020-01-06T16:31:11Z", 115 | "releases_url": "https://api.github.com/repos/test-user/test-repo/releases{/id}", 116 | "size": 0, 117 | "ssh_url": "git@github.com:test-user/test-repo.git", 118 | "stargazers_count": 0, 119 | "stargazers_url": "https://api.github.com/repos/test-user/test-repo/stargazers", 120 | "statuses_url": "https://api.github.com/repos/test-user/test-repo/statuses/{sha}", 121 | "subscribers_url": "https://api.github.com/repos/test-user/test-repo/subscribers", 122 | "subscription_url": "https://api.github.com/repos/test-user/test-repo/subscription", 123 | "svn_url": "https://github.com/test-user/test-repo", 124 | "tags_url": "https://api.github.com/repos/test-user/test-repo/tags", 125 | "teams_url": "https://api.github.com/repos/test-user/test-repo/teams", 126 | "trees_url": "https://api.github.com/repos/test-user/test-repo/git/trees{/sha}", 127 | "updated_at": "2020-01-06T16:30:17Z", 128 | "url": "https://api.github.com/repos/test-user/test-repo", 129 | "watchers": 0, 130 | "watchers_count": 0 131 | }, 132 | "sha": "775e4fe1053c5fa1188b8df037bc56b284541c2d", 133 | "user": { 134 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 135 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 136 | "followers_url": "https://api.github.com/users/test-user/followers", 137 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 138 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 139 | "gravatar_id": "", 140 | "html_url": "https://github.com/test-user", 141 | "id": 123456789, 142 | "login": "test-user", 143 | "node_id": "abc123", 144 | "organizations_url": "https://api.github.com/users/test-user/orgs", 145 | "received_events_url": "https://api.github.com/users/test-user/received_events", 146 | "repos_url": "https://api.github.com/users/test-user/repos", 147 | "site_admin": false, 148 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 149 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 150 | "type": "User", 151 | "url": "https://api.github.com/users/test-user" 152 | } 153 | }, 154 | "body": "", 155 | "changed_files": 1, 156 | "closed_at": null, 157 | "comments": 0, 158 | "comments_url": "https://api.github.com/repos/test-user/test-repo/issues/1/comments", 159 | "commits": 1, 160 | "commits_url": "https://api.github.com/repos/test-user/test-repo/pulls/1/commits", 161 | "created_at": "2020-01-06T16:31:24Z", 162 | "deletions": 0, 163 | "diff_url": "https://github.com/test-user/test-repo/pull/1.diff", 164 | "draft": false, 165 | "head": { 166 | "label": "test-user:test-branch", 167 | "ref": "test-branch", 168 | "repo": { 169 | "archive_url": "https://api.github.com/repos/test-user/test-repo/{archive_format}{/ref}", 170 | "archived": false, 171 | "assignees_url": "https://api.github.com/repos/test-user/test-repo/assignees{/user}", 172 | "blobs_url": "https://api.github.com/repos/test-user/test-repo/git/blobs{/sha}", 173 | "branches_url": "https://api.github.com/repos/test-user/test-repo/branches{/branch}", 174 | "clone_url": "https://github.com/test-user/test-repo.git", 175 | "collaborators_url": "https://api.github.com/repos/test-user/test-repo/collaborators{/collaborator}", 176 | "comments_url": "https://api.github.com/repos/test-user/test-repo/comments{/number}", 177 | "commits_url": "https://api.github.com/repos/test-user/test-repo/commits{/sha}", 178 | "compare_url": "https://api.github.com/repos/test-user/test-repo/compare/{base}...{head}", 179 | "contents_url": "https://api.github.com/repos/test-user/test-repo/contents/{+path}", 180 | "contributors_url": "https://api.github.com/repos/test-user/test-repo/contributors", 181 | "created_at": "2020-01-06T16:26:51Z", 182 | "default_branch": "master", 183 | "deployments_url": "https://api.github.com/repos/test-user/test-repo/deployments", 184 | "description": null, 185 | "disabled": false, 186 | "downloads_url": "https://api.github.com/repos/test-user/test-repo/downloads", 187 | "events_url": "https://api.github.com/repos/test-user/test-repo/events", 188 | "fork": false, 189 | "forks": 0, 190 | "forks_count": 0, 191 | "forks_url": "https://api.github.com/repos/test-user/test-repo/forks", 192 | "full_name": "test-user/test-repo", 193 | "git_commits_url": "https://api.github.com/repos/test-user/test-repo/git/commits{/sha}", 194 | "git_refs_url": "https://api.github.com/repos/test-user/test-repo/git/refs{/sha}", 195 | "git_tags_url": "https://api.github.com/repos/test-user/test-repo/git/tags{/sha}", 196 | "git_url": "git://github.com/test-user/test-repo.git", 197 | "has_downloads": true, 198 | "has_issues": true, 199 | "has_pages": false, 200 | "has_projects": true, 201 | "has_wiki": true, 202 | "homepage": null, 203 | "hooks_url": "https://api.github.com/repos/test-user/test-repo/hooks", 204 | "html_url": "https://github.com/test-user/test-repo", 205 | "id": 123456789, 206 | "issue_comment_url": "https://api.github.com/repos/test-user/test-repo/issues/comments{/number}", 207 | "issue_events_url": "https://api.github.com/repos/test-user/test-repo/issues/events{/number}", 208 | "issues_url": "https://api.github.com/repos/test-user/test-repo/issues{/number}", 209 | "keys_url": "https://api.github.com/repos/test-user/test-repo/keys{/key_id}", 210 | "labels_url": "https://api.github.com/repos/test-user/test-repo/labels{/name}", 211 | "language": null, 212 | "languages_url": "https://api.github.com/repos/test-user/test-repo/languages", 213 | "license": null, 214 | "merges_url": "https://api.github.com/repos/test-user/test-repo/merges", 215 | "milestones_url": "https://api.github.com/repos/test-user/test-repo/milestones{/number}", 216 | "mirror_url": null, 217 | "name": "test-repo", 218 | "node_id": "abc123", 219 | "notifications_url": "https://api.github.com/repos/test-user/test-repo/notifications{?since,all,participating}", 220 | "open_issues": 1, 221 | "open_issues_count": 1, 222 | "owner": { 223 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 224 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 225 | "followers_url": "https://api.github.com/users/test-user/followers", 226 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 227 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 228 | "gravatar_id": "", 229 | "html_url": "https://github.com/test-user", 230 | "id": 123456789, 231 | "login": "test-user", 232 | "node_id": "abc123", 233 | "organizations_url": "https://api.github.com/users/test-user/orgs", 234 | "received_events_url": "https://api.github.com/users/test-user/received_events", 235 | "repos_url": "https://api.github.com/users/test-user/repos", 236 | "site_admin": false, 237 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 238 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 239 | "type": "User", 240 | "url": "https://api.github.com/users/test-user" 241 | }, 242 | "private": true, 243 | "pulls_url": "https://api.github.com/repos/test-user/test-repo/pulls{/number}", 244 | "pushed_at": "2020-01-06T16:31:11Z", 245 | "releases_url": "https://api.github.com/repos/test-user/test-repo/releases{/id}", 246 | "size": 0, 247 | "ssh_url": "git@github.com:test-user/test-repo.git", 248 | "stargazers_count": 0, 249 | "stargazers_url": "https://api.github.com/repos/test-user/test-repo/stargazers", 250 | "statuses_url": "https://api.github.com/repos/test-user/test-repo/statuses/{sha}", 251 | "subscribers_url": "https://api.github.com/repos/test-user/test-repo/subscribers", 252 | "subscription_url": "https://api.github.com/repos/test-user/test-repo/subscription", 253 | "svn_url": "https://github.com/test-user/test-repo", 254 | "tags_url": "https://api.github.com/repos/test-user/test-repo/tags", 255 | "teams_url": "https://api.github.com/repos/test-user/test-repo/teams", 256 | "trees_url": "https://api.github.com/repos/test-user/test-repo/git/trees{/sha}", 257 | "updated_at": "2020-01-06T16:30:17Z", 258 | "url": "https://api.github.com/repos/test-user/test-repo", 259 | "watchers": 0, 260 | "watchers_count": 0 261 | }, 262 | "sha": "b2a3295e4bb488deec97238f0af95a9fb0abb40c", 263 | "user": { 264 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 265 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 266 | "followers_url": "https://api.github.com/users/test-user/followers", 267 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 268 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 269 | "gravatar_id": "", 270 | "html_url": "https://github.com/test-user", 271 | "id": 123456789, 272 | "login": "test-user", 273 | "node_id": "abc123", 274 | "organizations_url": "https://api.github.com/users/test-user/orgs", 275 | "received_events_url": "https://api.github.com/users/test-user/received_events", 276 | "repos_url": "https://api.github.com/users/test-user/repos", 277 | "site_admin": false, 278 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 279 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 280 | "type": "User", 281 | "url": "https://api.github.com/users/test-user" 282 | } 283 | }, 284 | "html_url": "https://github.com/test-user/test-repo/pull/1", 285 | "id": 123456789, 286 | "issue_url": "https://api.github.com/repos/test-user/test-repo/issues/1", 287 | "labels": [], 288 | "locked": false, 289 | "maintainer_can_modify": false, 290 | "merge_commit_sha": null, 291 | "mergeable": null, 292 | "mergeable_state": "unknown", 293 | "merged": false, 294 | "merged_at": null, 295 | "merged_by": null, 296 | "milestone": null, 297 | "node_id": "abc123", 298 | "number": 1, 299 | "patch_url": "https://github.com/test-user/test-repo/pull/1.patch", 300 | "rebaseable": null, 301 | "requested_reviewers": [], 302 | "requested_teams": [], 303 | "review_comment_url": "https://api.github.com/repos/test-user/test-repo/pulls/comments{/number}", 304 | "review_comments": 0, 305 | "review_comments_url": "https://api.github.com/repos/test-user/test-repo/pulls/1/comments", 306 | "state": "open", 307 | "statuses_url": "https://api.github.com/repos/test-user/test-repo/statuses/b2a3295e4bb488deec97238f0af95a9fb0abb40c", 308 | "title": "Test PR", 309 | "updated_at": "2020-01-06T16:31:24Z", 310 | "url": "https://api.github.com/repos/test-user/test-repo/pulls/1", 311 | "user": { 312 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 313 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 314 | "followers_url": "https://api.github.com/users/test-user/followers", 315 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 316 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 317 | "gravatar_id": "", 318 | "html_url": "https://github.com/test-user", 319 | "id": 123456789, 320 | "login": "test-user", 321 | "node_id": "abc123", 322 | "organizations_url": "https://api.github.com/users/test-user/orgs", 323 | "received_events_url": "https://api.github.com/users/test-user/received_events", 324 | "repos_url": "https://api.github.com/users/test-user/repos", 325 | "site_admin": false, 326 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 327 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 328 | "type": "User", 329 | "url": "https://api.github.com/users/test-user" 330 | } 331 | }, 332 | "repository": { 333 | "archive_url": "https://api.github.com/repos/test-user/test-repo/{archive_format}{/ref}", 334 | "archived": false, 335 | "assignees_url": "https://api.github.com/repos/test-user/test-repo/assignees{/user}", 336 | "blobs_url": "https://api.github.com/repos/test-user/test-repo/git/blobs{/sha}", 337 | "branches_url": "https://api.github.com/repos/test-user/test-repo/branches{/branch}", 338 | "clone_url": "https://github.com/test-user/test-repo.git", 339 | "collaborators_url": "https://api.github.com/repos/test-user/test-repo/collaborators{/collaborator}", 340 | "comments_url": "https://api.github.com/repos/test-user/test-repo/comments{/number}", 341 | "commits_url": "https://api.github.com/repos/test-user/test-repo/commits{/sha}", 342 | "compare_url": "https://api.github.com/repos/test-user/test-repo/compare/{base}...{head}", 343 | "contents_url": "https://api.github.com/repos/test-user/test-repo/contents/{+path}", 344 | "contributors_url": "https://api.github.com/repos/test-user/test-repo/contributors", 345 | "created_at": "2020-01-06T16:26:51Z", 346 | "default_branch": "master", 347 | "deployments_url": "https://api.github.com/repos/test-user/test-repo/deployments", 348 | "description": null, 349 | "disabled": false, 350 | "downloads_url": "https://api.github.com/repos/test-user/test-repo/downloads", 351 | "events_url": "https://api.github.com/repos/test-user/test-repo/events", 352 | "fork": false, 353 | "forks": 0, 354 | "forks_count": 0, 355 | "forks_url": "https://api.github.com/repos/test-user/test-repo/forks", 356 | "full_name": "test-user/test-repo", 357 | "git_commits_url": "https://api.github.com/repos/test-user/test-repo/git/commits{/sha}", 358 | "git_refs_url": "https://api.github.com/repos/test-user/test-repo/git/refs{/sha}", 359 | "git_tags_url": "https://api.github.com/repos/test-user/test-repo/git/tags{/sha}", 360 | "git_url": "git://github.com/test-user/test-repo.git", 361 | "has_downloads": true, 362 | "has_issues": true, 363 | "has_pages": false, 364 | "has_projects": true, 365 | "has_wiki": true, 366 | "homepage": null, 367 | "hooks_url": "https://api.github.com/repos/test-user/test-repo/hooks", 368 | "html_url": "https://github.com/test-user/test-repo", 369 | "id": 123456789, 370 | "issue_comment_url": "https://api.github.com/repos/test-user/test-repo/issues/comments{/number}", 371 | "issue_events_url": "https://api.github.com/repos/test-user/test-repo/issues/events{/number}", 372 | "issues_url": "https://api.github.com/repos/test-user/test-repo/issues{/number}", 373 | "keys_url": "https://api.github.com/repos/test-user/test-repo/keys{/key_id}", 374 | "labels_url": "https://api.github.com/repos/test-user/test-repo/labels{/name}", 375 | "language": null, 376 | "languages_url": "https://api.github.com/repos/test-user/test-repo/languages", 377 | "license": null, 378 | "merges_url": "https://api.github.com/repos/test-user/test-repo/merges", 379 | "milestones_url": "https://api.github.com/repos/test-user/test-repo/milestones{/number}", 380 | "mirror_url": null, 381 | "name": "test-repo", 382 | "node_id": "abc123", 383 | "notifications_url": "https://api.github.com/repos/test-user/test-repo/notifications{?since,all,participating}", 384 | "open_issues": 1, 385 | "open_issues_count": 1, 386 | "owner": { 387 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 388 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 389 | "followers_url": "https://api.github.com/users/test-user/followers", 390 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 391 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 392 | "gravatar_id": "", 393 | "html_url": "https://github.com/test-user", 394 | "id": 123456789, 395 | "login": "test-user", 396 | "node_id": "abc123", 397 | "organizations_url": "https://api.github.com/users/test-user/orgs", 398 | "received_events_url": "https://api.github.com/users/test-user/received_events", 399 | "repos_url": "https://api.github.com/users/test-user/repos", 400 | "site_admin": false, 401 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 402 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 403 | "type": "User", 404 | "url": "https://api.github.com/users/test-user" 405 | }, 406 | "private": true, 407 | "pulls_url": "https://api.github.com/repos/test-user/test-repo/pulls{/number}", 408 | "pushed_at": "2020-01-06T16:31:11Z", 409 | "releases_url": "https://api.github.com/repos/test-user/test-repo/releases{/id}", 410 | "size": 0, 411 | "ssh_url": "git@github.com:test-user/test-repo.git", 412 | "stargazers_count": 0, 413 | "stargazers_url": "https://api.github.com/repos/test-user/test-repo/stargazers", 414 | "statuses_url": "https://api.github.com/repos/test-user/test-repo/statuses/{sha}", 415 | "subscribers_url": "https://api.github.com/repos/test-user/test-repo/subscribers", 416 | "subscription_url": "https://api.github.com/repos/test-user/test-repo/subscription", 417 | "svn_url": "https://github.com/test-user/test-repo", 418 | "tags_url": "https://api.github.com/repos/test-user/test-repo/tags", 419 | "teams_url": "https://api.github.com/repos/test-user/test-repo/teams", 420 | "trees_url": "https://api.github.com/repos/test-user/test-repo/git/trees{/sha}", 421 | "updated_at": "2020-01-06T16:30:17Z", 422 | "url": "https://api.github.com/repos/test-user/test-repo", 423 | "watchers": 0, 424 | "watchers_count": 0 425 | }, 426 | "sender": { 427 | "avatar_url": "https://avatars0.githubusercontent.com/u/123456789?v=4", 428 | "events_url": "https://api.github.com/users/test-user/events{/privacy}", 429 | "followers_url": "https://api.github.com/users/test-user/followers", 430 | "following_url": "https://api.github.com/users/test-user/following{/other_user}", 431 | "gists_url": "https://api.github.com/users/test-user/gists{/gist_id}", 432 | "gravatar_id": "", 433 | "html_url": "https://github.com/test-user", 434 | "id": 123456789, 435 | "login": "test-user", 436 | "node_id": "abc123", 437 | "organizations_url": "https://api.github.com/users/test-user/orgs", 438 | "received_events_url": "https://api.github.com/users/test-user/received_events", 439 | "repos_url": "https://api.github.com/users/test-user/repos", 440 | "site_admin": false, 441 | "starred_url": "https://api.github.com/users/test-user/starred{/owner}{/repo}", 442 | "subscriptions_url": "https://api.github.com/users/test-user/subscriptions", 443 | "type": "User", 444 | "url": "https://api.github.com/users/test-user" 445 | } 446 | } 447 | --------------------------------------------------------------------------------