├── .gitattributes ├── .github ├── bors.toml ├── dependabot.yml └── workflows │ └── check.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── LICENSE ├── README.md ├── __tests__ ├── formatted │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── needs-formatting │ ├── Cargo.toml │ └── src │ │ ├── a.rs │ │ ├── lib.rs │ │ └── main.rs └── rustfmt.test.ts ├── action.yml ├── dist ├── check.d.ts ├── check.js ├── main.d.ts ├── main.js ├── main.js.map ├── rustfmt.d.ts └── rustfmt.js ├── eslint.config.mjs ├── images ├── commit.png ├── pull.png └── review.png ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── check.ts ├── main.ts └── rustfmt.ts ├── tsconfig.base.json ├── tsconfig.eslint.json └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/bors.toml: -------------------------------------------------------------------------------- 1 | status = [ 2 | "Pack", 3 | "Test", 4 | "Format", 5 | "Lint" 6 | ] 7 | delete_merged_branches = true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: npm 5 | directory: '/' 6 | schedule: 7 | interval: daily 8 | groups: 9 | eslint: 10 | applies-to: version-updates 11 | patterns: 12 | - '*eslint*' 13 | 14 | - package-ecosystem: github-actions 15 | directory: '/' 16 | schedule: 17 | interval: daily 18 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - staging 8 | - trying 9 | pull_request: 10 | 11 | jobs: 12 | pack: 13 | name: Pack 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4.4.0 18 | with: 19 | node-version: '23' 20 | - run: npm ci --ignore-scripts 21 | - run: npm run build 22 | - run: npm run pack 23 | - if: github.event_name == 'push' && github.ref == 'refs/heads/master' 24 | run: git diff --exit-code 25 | - if: 26 | github.event_name == 'push' && github.ref == 'refs/heads/master' && 27 | failure() 28 | run: | 29 | git config user.name github-actions[bot] 30 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 31 | git commit -am "Create dist package" 32 | git push 33 | 34 | test: 35 | name: Test 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-node@v4.4.0 40 | with: 41 | node-version: '23' 42 | - run: npm ci --ignore-scripts 43 | - run: npm run build 44 | - uses: dtolnay/rust-toolchain@nightly 45 | with: 46 | components: rustfmt 47 | - run: npm test 48 | 49 | format: 50 | name: Format 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-node@v4.4.0 55 | with: 56 | node-version: '23' 57 | - run: npm ci --ignore-scripts 58 | - run: npm run format-check 59 | 60 | lint: 61 | name: Lint 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: actions/setup-node@v4.4.0 66 | with: 67 | node-version: '23' 68 | - run: npm ci --ignore-scripts 69 | - run: npm run lint 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /target 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # See: https://prettier.io/docs/en/configuration 2 | 3 | printWidth: 80 4 | tabWidth: 2 5 | useTabs: false 6 | semi: false 7 | singleQuote: true 8 | quoteProps: as-needed 9 | jsxSingleQuote: false 10 | trailingComma: none 11 | bracketSpacing: true 12 | bracketSameLine: true 13 | arrowParens: always 14 | proseWrap: always 15 | htmlWhitespaceSensitivity: css 16 | endOfLine: lf 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matthijs Brobbel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rustfmt-check 2 | 3 | GitHub Action to format [Rust] code using [rustfmt]. 4 | 5 | This action can be used to keep [Rust] code formatted correctly. 6 | 7 | ## Modes 8 | 9 | This action supports three different modes. The `commit` mode is the default 10 | mode. 11 | 12 | ### Commit 13 | 14 | A commit is pushed when formatting is required. 15 | 16 | #### Example 17 | 18 | ``` 19 | on: push 20 | 21 | name: Rustfmt 22 | 23 | jobs: 24 | format: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: dtolnay/rust-toolchain@stable 29 | with: 30 | components: rustfmt 31 | - uses: mbrobbel/rustfmt-check@master 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | ``` 35 | 36 | ![Commit mode](images/commit.png) 37 | 38 | ### Review 39 | 40 | The action reviews the PR, either requesting formatting changes, or approving if 41 | no formatting is required. 42 | 43 | #### Example 44 | 45 | ``` 46 | on: pull_request 47 | 48 | name: Rustfmt 49 | 50 | jobs: 51 | format: 52 | runs-on: ubuntu-latest 53 | permissions: 54 | pull-requests: write 55 | steps: 56 | - uses: actions/checkout@v4 57 | - uses: dtolnay/rust-toolchain@nightly 58 | with: 59 | components: rustfmt 60 | - uses: mbrobbel/rustfmt-check@master 61 | with: 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | mode: review 64 | ``` 65 | 66 | ![Review mode](images/review.png) 67 | 68 | Please note that this mode requires: 69 | 70 | - A nightly Rust toolchain 71 | - [Allowing GitHub Actions to create or approve pull reqeuests](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#preventing-github-actions-from-creating-or-approving-pull-requests). 72 | 73 | ### Pull request 74 | 75 | The action creates a pull request with the formatting changes. 76 | 77 | #### Example 78 | 79 | ``` 80 | on: pull_request 81 | 82 | name: Rustfmt 83 | 84 | jobs: 85 | format: 86 | runs-on: ubuntu-latest 87 | permissions: 88 | contents: write 89 | pull-requests: write 90 | steps: 91 | - uses: actions/checkout@v4 92 | - uses: dtolnay/rust-toolchain@stable 93 | with: 94 | components: rustfmt 95 | - uses: mbrobbel/rustfmt-check@master 96 | with: 97 | token: ${{ secrets.GITHUB_TOKEN }} 98 | mode: pull 99 | ``` 100 | 101 | ![Pull request mode](images/pull.png) 102 | 103 | Please note that this mode requires: 104 | 105 | - [Allowing GitHub Actions to create or approve pull reqeuests](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#preventing-github-actions-from-creating-or-approving-pull-requests). 106 | 107 | ## Arguments 108 | 109 | See [action.yml](./action.yml). 110 | 111 | [rust]: https://github.com/rust-lang/rust 112 | [rustfmt]: https://github.com/rust-lang/rustfmt 113 | -------------------------------------------------------------------------------- /__tests__/formatted/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "formatted" 3 | version = "0.0.0" 4 | -------------------------------------------------------------------------------- /__tests__/formatted/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() {} 2 | -------------------------------------------------------------------------------- /__tests__/needs-formatting/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "needs-formatting" 3 | version = "0.0.0" 4 | -------------------------------------------------------------------------------- /__tests__/needs-formatting/src/a.rs: -------------------------------------------------------------------------------- 1 | fn a() {} 2 | -------------------------------------------------------------------------------- /__tests__/needs-formatting/src/lib.rs: -------------------------------------------------------------------------------- 1 | fn a ( ) { 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | } -------------------------------------------------------------------------------- /__tests__/needs-formatting/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | 3 | println!( "Hello, {}!", 4 | world() 5 | ); 6 | } 7 | 8 | fn world( ) -> &'static str { 9 | "world" 10 | 11 | } -------------------------------------------------------------------------------- /__tests__/rustfmt.test.ts: -------------------------------------------------------------------------------- 1 | import rustfmt from '../src/rustfmt' 2 | import check from '../src/check' 3 | 4 | jest.setTimeout(30000) 5 | 6 | test('rustfmt check output is empty when nothing is required', async () => { 7 | expect( 8 | await rustfmt( 9 | ['-l', '--check'], 10 | '--manifest-path __tests__/formatted/Cargo.toml' 11 | ) 12 | ).toEqual([]) 13 | }) 14 | 15 | test('rustfmt check output fails if formatting is required', async () => { 16 | await expect( 17 | rustfmt( 18 | ['-l', '--check'], 19 | '--manifest-path __tests__/needs-formatting/Cargo.toml' 20 | ) 21 | ).rejects.toThrow(".cargo/bin/cargo' failed with exit code 1") 22 | }) 23 | 24 | test('rustfmt check output lists files to be formatted', async () => { 25 | expect( 26 | await rustfmt( 27 | ['-l', '--emit', 'stdout'], 28 | '--manifest-path __tests__/needs-formatting/Cargo.toml' 29 | ) 30 | ).toEqual( 31 | expect.arrayContaining([ 32 | expect.stringContaining('__tests__/needs-formatting/src/main.rs'), 33 | expect.stringContaining('__tests__/needs-formatting/src/lib.rs') 34 | ]) 35 | ) 36 | }) 37 | 38 | test('rustfmt check set config', async () => { 39 | expect( 40 | await rustfmt( 41 | ['-l', '--emit', 'stdout'].concat([ 42 | '--config', 43 | 'disable_all_formatting=true' 44 | ]), 45 | '--manifest-path __tests__/needs-formatting/Cargo.toml' 46 | ) 47 | ).toEqual([]) 48 | }) 49 | 50 | test('rustfmt check mode outputs lists of changes', async () => { 51 | const output = await check( 52 | '--manifest-path __tests__/needs-formatting/Cargo.toml' 53 | ) 54 | expect(output.map((result) => result.path)).toEqual( 55 | expect.arrayContaining([ 56 | expect.stringContaining('__tests__/needs-formatting/src/main.rs'), 57 | expect.stringContaining('__tests__/needs-formatting/src/lib.rs') 58 | ]) 59 | ) 60 | expect( 61 | output 62 | .map((result) => result.mismatch) 63 | .map((mismatch) => mismatch.original_begin_line) 64 | ).toEqual([1, 1, 8, 10]) 65 | expect( 66 | output 67 | .map((result) => result.mismatch) 68 | .map((mismatch) => mismatch.expected_begin_line) 69 | ).toEqual([1, 1, 5, 7]) 70 | }) 71 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: rust-rustfmt-check 2 | description: Format Rust code using rustfmt 3 | author: mbrobbel 4 | branding: 5 | icon: feather 6 | color: yellow 7 | 8 | inputs: 9 | token: 10 | description: GitHub token 11 | required: true 12 | args: 13 | description: Arguments for cargo. 14 | required: false 15 | rustfmt-args: 16 | description: Arguments for rustfmt. 17 | required: false 18 | mode: 19 | description: Output mode (commit, review or pull) 20 | default: commit 21 | required: false 22 | commit-message: 23 | description: Commit message for formatting commits. 24 | required: false 25 | default: Format Rust code using rustfmt 26 | 27 | runs: 28 | using: node20 29 | main: dist/main.js 30 | -------------------------------------------------------------------------------- /dist/check.d.ts: -------------------------------------------------------------------------------- 1 | interface Result { 2 | path: string; 3 | mismatch: Mismatch; 4 | } 5 | interface Mismatch { 6 | original_begin_line: number; 7 | original_end_line: number; 8 | expected_begin_line: number; 9 | expected_end_line: number; 10 | original: string; 11 | expected: string; 12 | } 13 | declare const check: (args?: string, rustfmt_args?: string) => Promise; 14 | export default check; 15 | -------------------------------------------------------------------------------- /dist/check.js: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import stringArgv from 'string-argv'; 4 | const check = async (args = core.getInput('args'), rustfmt_args = core.getInput('rustfmt-args')) => { 5 | let buffer = ''; 6 | return exec 7 | .exec('cargo', ['+nightly', 'fmt'] 8 | .concat(stringArgv(args)) 9 | .concat(['--', '--emit', 'json'].concat(stringArgv(rustfmt_args))), { 10 | listeners: { 11 | stdout: (data) => { 12 | buffer += data.toString().trim(); 13 | } 14 | } 15 | }) 16 | .then(() => JSON.parse(buffer).flatMap((output) => output.mismatches.map((mismatch) => ({ 17 | path: output.name, 18 | mismatch 19 | })))); 20 | }; 21 | export default check; 22 | -------------------------------------------------------------------------------- /dist/main.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/rustfmt.d.ts: -------------------------------------------------------------------------------- 1 | declare const rustfmt: (options?: string[], args?: string) => Promise; 2 | export default rustfmt; 3 | -------------------------------------------------------------------------------- /dist/rustfmt.js: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import stringArgv from 'string-argv'; 4 | import { EOL } from 'os'; 5 | const rustfmt = async (options = [], args = core.getInput('args')) => { 6 | let output = ''; 7 | return exec 8 | .exec('cargo', ['fmt'].concat(stringArgv(args)).concat(['--']).concat(options), { 9 | listeners: { 10 | stdout: (data) => { 11 | output += data.toString(); 12 | } 13 | } 14 | }) 15 | .then(() => output.trim().split(EOL).filter(Boolean)); 16 | }; 17 | export default rustfmt; 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // See: https://eslint.org/docs/latest/use/configure/configuration-files 2 | 3 | import { fixupPluginRules } from '@eslint/compat' 4 | import { FlatCompat } from '@eslint/eslintrc' 5 | import js from '@eslint/js' 6 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 7 | import tsParser from '@typescript-eslint/parser' 8 | import _import from 'eslint-plugin-import' 9 | import jest from 'eslint-plugin-jest' 10 | import prettier from 'eslint-plugin-prettier' 11 | import globals from 'globals' 12 | import path from 'node:path' 13 | import { fileURLToPath } from 'node:url' 14 | 15 | const __filename = fileURLToPath(import.meta.url) 16 | const __dirname = path.dirname(__filename) 17 | const compat = new FlatCompat({ 18 | baseDirectory: __dirname, 19 | recommendedConfig: js.configs.recommended, 20 | allConfig: js.configs.all 21 | }) 22 | 23 | export default [ 24 | { 25 | ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules'] 26 | }, 27 | ...compat.extends( 28 | 'eslint:recommended', 29 | 'plugin:@typescript-eslint/eslint-recommended', 30 | 'plugin:@typescript-eslint/recommended', 31 | 'plugin:jest/recommended', 32 | 'plugin:prettier/recommended' 33 | ), 34 | { 35 | plugins: { 36 | import: fixupPluginRules(_import), 37 | jest, 38 | prettier, 39 | '@typescript-eslint': typescriptEslint 40 | }, 41 | 42 | languageOptions: { 43 | globals: { 44 | ...globals.node, 45 | ...globals.jest, 46 | Atomics: 'readonly', 47 | SharedArrayBuffer: 'readonly' 48 | }, 49 | 50 | parser: tsParser, 51 | ecmaVersion: 2023, 52 | sourceType: 'module', 53 | 54 | parserOptions: { 55 | project: ['tsconfig.eslint.json'], 56 | tsconfigRootDir: '.' 57 | } 58 | }, 59 | 60 | settings: { 61 | 'import/resolver': { 62 | typescript: { 63 | alwaysTryTypes: true, 64 | project: 'tsconfig.eslint.json' 65 | } 66 | } 67 | }, 68 | 69 | rules: { 70 | camelcase: 'off', 71 | 'eslint-comments/no-use': 'off', 72 | 'eslint-comments/no-unused-disable': 'off', 73 | 'i18n-text/no-en': 'off', 74 | 'import/no-namespace': 'off', 75 | 'no-console': 'off', 76 | 'no-shadow': 'off', 77 | 'no-unused-vars': 'off', 78 | 'prettier/prettier': 'error' 79 | } 80 | } 81 | ] 82 | -------------------------------------------------------------------------------- /images/commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbrobbel/rustfmt-check/0bb8dd7e59f25b7f583c1513df5fb143af14a535/images/commit.png -------------------------------------------------------------------------------- /images/pull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbrobbel/rustfmt-check/0bb8dd7e59f25b7f583c1513df5fb143af14a535/images/pull.png -------------------------------------------------------------------------------- /images/review.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbrobbel/rustfmt-check/0bb8dd7e59f25b7f583c1513df5fb143af14a535/images/review.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // See: https://jestjs.io/docs/configuration 2 | 3 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 4 | export default { 5 | clearMocks: true, 6 | collectCoverage: true, 7 | collectCoverageFrom: ['./src/**'], 8 | coverageDirectory: './coverage', 9 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], 10 | coverageReporters: ['json-summary', 'text', 'lcov'], 11 | // Uncomment the below lines if you would like to enforce a coverage threshold 12 | // for your action. This will fail the build if the coverage is below the 13 | // specified thresholds. 14 | // coverageThreshold: { 15 | // global: { 16 | // branches: 100, 17 | // functions: 100, 18 | // lines: 100, 19 | // statements: 100 20 | // } 21 | // }, 22 | extensionsToTreatAsEsm: ['.ts'], 23 | moduleFileExtensions: ['ts', 'js'], 24 | preset: 'ts-jest', 25 | reporters: ['default'], 26 | resolver: 'ts-jest-resolver', 27 | testEnvironment: 'node', 28 | testMatch: ['**/*.test.ts'], 29 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 30 | transform: { 31 | '^.+\\.ts$': [ 32 | 'ts-jest', 33 | { 34 | tsconfig: 'tsconfig.eslint.json', 35 | useESM: true 36 | } 37 | ] 38 | }, 39 | verbose: true 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rust-rustfmt-check", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Format Rust code using rustfmt", 6 | "exports": { 7 | ".": "./dist/main.js" 8 | }, 9 | "type": "module", 10 | "engines": { 11 | "node": ">=20" 12 | }, 13 | "scripts": { 14 | "build": "tsc", 15 | "format": "prettier --write .", 16 | "format-check": "prettier --check .", 17 | "lint": "eslint .", 18 | "pack": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 19 | "test": "jest --runInBand", 20 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm test" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/mbrobbel/rustfmt-check.git" 25 | }, 26 | "keywords": [ 27 | "actions", 28 | "node", 29 | "setup" 30 | ], 31 | "author": "mbrobbel", 32 | "license": "MIT", 33 | "dependencies": { 34 | "@actions/core": "^1.11.1", 35 | "@actions/exec": "^1.1.1", 36 | "@actions/github": "^6.0.1", 37 | "string-argv": "^0.3.2" 38 | }, 39 | "devDependencies": { 40 | "@eslint/compat": "^1.2.9", 41 | "@jest/globals": "^29.7.0", 42 | "@rollup/plugin-commonjs": "^28.0.3", 43 | "@rollup/plugin-node-resolve": "^16.0.1", 44 | "@rollup/plugin-typescript": "^12.1.1", 45 | "@types/jest": "^29.5.14", 46 | "@types/node": "^22.15.30", 47 | "@typescript-eslint/eslint-plugin": "^8.33.1", 48 | "@typescript-eslint/parser": "^8.32.1", 49 | "eslint": "^9.28.0", 50 | "eslint-config-prettier": "^10.1.5", 51 | "eslint-import-resolver-typescript": "^4.4.3", 52 | "eslint-plugin-import": "^2.31.0", 53 | "eslint-plugin-jest": "^28.12.0", 54 | "eslint-plugin-prettier": "^5.4.1", 55 | "globals": "^16.2.0", 56 | "jest": "^29.7.0", 57 | "prettier": "^3.5.3", 58 | "prettier-eslint": "^16.4.2", 59 | "rollup": "^4.41.1", 60 | "ts-jest": "^29.3.4", 61 | "ts-jest-resolver": "^2.0.1", 62 | "ts-node": "^10.9.2", 63 | "typescript": "^5.8.3" 64 | }, 65 | "optionalDependencies": { 66 | "@rollup/rollup-linux-x64-gnu": "*" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | // See: https://rollupjs.org/introduction/ 2 | 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import nodeResolve from '@rollup/plugin-node-resolve' 5 | import typescript from '@rollup/plugin-typescript' 6 | 7 | const config = { 8 | input: 'src/main.ts', 9 | output: { 10 | esModule: true, 11 | file: 'dist/main.js', 12 | format: 'es', 13 | sourcemap: true 14 | }, 15 | plugins: [typescript(), nodeResolve(), commonjs()] 16 | } 17 | 18 | export default config 19 | -------------------------------------------------------------------------------- /src/check.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | import stringArgv from 'string-argv' 4 | 5 | interface Output { 6 | name: string 7 | mismatches: Mismatch[] 8 | } 9 | 10 | interface Result { 11 | path: string 12 | mismatch: Mismatch 13 | } 14 | 15 | interface Mismatch { 16 | original_begin_line: number 17 | original_end_line: number 18 | expected_begin_line: number 19 | expected_end_line: number 20 | original: string 21 | expected: string 22 | } 23 | 24 | const check = async ( 25 | args: string = core.getInput('args'), 26 | rustfmt_args: string = core.getInput('rustfmt-args') 27 | ): Promise => { 28 | let buffer = '' 29 | return exec 30 | .exec( 31 | 'cargo', 32 | ['+nightly', 'fmt'] 33 | .concat(stringArgv(args)) 34 | .concat(['--', '--emit', 'json'].concat(stringArgv(rustfmt_args))), 35 | { 36 | listeners: { 37 | stdout: (data: Buffer) => { 38 | buffer += data.toString().trim() 39 | } 40 | } 41 | } 42 | ) 43 | .then(() => 44 | JSON.parse(buffer).flatMap((output: Output) => 45 | output.mismatches.map((mismatch) => ({ 46 | path: output.name, 47 | mismatch 48 | })) 49 | ) 50 | ) 51 | } 52 | 53 | export default check 54 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import check from './check.js' 4 | import rustfmt from './rustfmt.js' 5 | import stringArgv from 'string-argv' 6 | import { normalize } from 'path' 7 | import { promisify } from 'util' 8 | import { readFile as readFileCallback } from 'fs' 9 | 10 | const readFile = promisify(readFileCallback) 11 | 12 | async function run(): Promise { 13 | try { 14 | const token = core.getInput('token', { required: true }) 15 | const octokit = github.getOctokit(token) 16 | const context = github.context 17 | const mode = core.getInput('mode') 18 | const rustfmt_args = stringArgv(core.getInput('rustfmt-args')) 19 | const message = core.getInput('commit-message') 20 | 21 | switch (mode) { 22 | case 'commit': 23 | { 24 | const head = 25 | context.eventName === 'pull_request' && context.payload.pull_request 26 | ? { 27 | sha: context.payload.pull_request.head.sha, 28 | ref: `refs/heads/${context.payload.pull_request.head.ref}` 29 | } 30 | : { sha: context.sha, ref: context.ref } 31 | 32 | await rustfmt(['-l'].concat(rustfmt_args)).then(async (paths) => 33 | paths.length === 0 34 | ? // No formatting required 35 | Promise.resolve() 36 | : octokit.rest.git 37 | .createTree({ 38 | ...context.repo, 39 | tree: await Promise.all( 40 | paths.map(async (path) => ({ 41 | path: normalize( 42 | path.replace(`${process.env.GITHUB_WORKSPACE}/`, '') 43 | ), 44 | mode: '100644', 45 | type: 'blob', 46 | content: await readFile(path, 'utf8') 47 | })) 48 | ), 49 | base_tree: head.sha 50 | }) 51 | .then(async ({ data: { sha } }) => 52 | octokit.rest.git.createCommit({ 53 | ...context.repo, 54 | message, 55 | tree: sha, 56 | parents: [head.sha] 57 | }) 58 | ) 59 | .then(async ({ data: { sha } }) => 60 | octokit.rest.git.updateRef({ 61 | ...context.repo, 62 | ref: head.ref.replace('refs/', ''), 63 | sha 64 | }) 65 | ) 66 | ) 67 | } 68 | break 69 | case 'review': 70 | { 71 | if (!context.payload.pull_request) { 72 | throw new Error('Review mode requires a pull_request event trigger') 73 | } 74 | // Dismiss exisiting (open) reviews 75 | const reviews = await octokit.rest.pulls.listReviews({ 76 | ...context.repo, 77 | pull_number: context.issue.number 78 | }) 79 | const review_id = reviews.data 80 | .reverse() 81 | .find( 82 | ({ user, state }) => 83 | user?.id === 41898282 && state === 'CHANGES_REQUESTED' 84 | )?.id 85 | if (review_id !== undefined) { 86 | core.debug(`Removing review: ${review_id}.`) 87 | // Delete outdated comments 88 | const review_comments = 89 | await octokit.rest.pulls.listCommentsForReview({ 90 | ...context.repo, 91 | pull_number: context.issue.number, 92 | review_id 93 | }) 94 | await Promise.all( 95 | review_comments.data.map(({ id }) => { 96 | core.debug(`Removing review comment: ${id}.`) 97 | octokit.rest.pulls.deleteReviewComment({ 98 | ...context.repo, 99 | comment_id: id 100 | }) 101 | }) 102 | ) 103 | // Dismiss review 104 | core.debug(`Dismiss review: ${review_id}.`) 105 | await octokit.rest.pulls.dismissReview({ 106 | ...context.repo, 107 | pull_number: context.issue.number, 108 | review_id, 109 | message: 'Removing outdated review.' 110 | }) 111 | } else { 112 | core.debug(`No existing reviews found.`) 113 | } 114 | // Check current state 115 | const output = await check() 116 | if (output.length === 0) { 117 | // Approve 118 | core.debug('Approve review') 119 | await octokit.rest.pulls.createReview({ 120 | ...context.repo, 121 | pull_number: context.issue.number, 122 | event: 'APPROVE' 123 | }) 124 | Promise.resolve() 125 | } else { 126 | // Request changes 127 | core.debug('Request changes') 128 | await octokit.rest.pulls.createReview({ 129 | ...context.repo, 130 | pull_number: context.issue.number, 131 | body: `Please format your code using [rustfmt](https://github.com/rust-lang/rustfmt): \`cargo fmt\``, 132 | event: 'REQUEST_CHANGES', 133 | comments: output.map((result) => ({ 134 | path: result.path.replace( 135 | `${process.env.GITHUB_WORKSPACE}/`, 136 | '' 137 | ), 138 | body: `\`\`\`suggestion 139 | ${result.mismatch.expected}\`\`\``, 140 | start_line: 141 | result.mismatch.original_end_line === 142 | result.mismatch.original_begin_line 143 | ? undefined 144 | : result.mismatch.original_begin_line, 145 | line: 146 | result.mismatch.original_end_line === 147 | result.mismatch.original_begin_line 148 | ? result.mismatch.original_begin_line 149 | : result.mismatch.original_end_line, 150 | side: 'RIGHT' 151 | })) 152 | }) 153 | } 154 | } 155 | break 156 | case 'pull': 157 | // Open a pull request from a new branch with the formatted code 158 | { 159 | const head = 160 | context.eventName === 'pull_request' && context.payload.pull_request 161 | ? { 162 | sha: context.payload.pull_request.head.sha, 163 | ref: `refs/heads/${context.payload.pull_request.head.ref}` 164 | } 165 | : { sha: context.sha, ref: context.ref } 166 | const ref = `refs/heads/rustfmt-${head.sha}` 167 | await rustfmt(['-l'].concat(rustfmt_args)).then(async (paths) => 168 | paths.length === 0 169 | ? // No formatting required 170 | Promise.resolve() 171 | : octokit.rest.git 172 | .createRef({ 173 | ...context.repo, 174 | ref, 175 | sha: head.sha 176 | }) 177 | .then(async () => 178 | octokit.rest.git 179 | .createTree({ 180 | ...context.repo, 181 | tree: await Promise.all( 182 | paths.map(async (path) => ({ 183 | path: normalize( 184 | path.replace( 185 | `${process.env.GITHUB_WORKSPACE}/`, 186 | '' 187 | ) 188 | ), 189 | mode: '100644', 190 | type: 'blob', 191 | content: await readFile(path, 'utf8') 192 | })) 193 | ), 194 | base_tree: head.sha 195 | }) 196 | .then(async ({ data: { sha } }) => 197 | octokit.rest.git.createCommit({ 198 | ...context.repo, 199 | message, 200 | tree: sha, 201 | parents: [head.sha] 202 | }) 203 | ) 204 | .then(async ({ data: { sha } }) => 205 | octokit.rest.git.updateRef({ 206 | ...context.repo, 207 | ref: ref.replace('refs/', ''), 208 | sha 209 | }) 210 | ) 211 | .then(async () => { 212 | const title = `Format code using rustfmt for ${head.sha}` 213 | const body = `The code for commit \`${head.sha}\` on \`${head.ref.replace('refs/heads/', '')}\` has been formatted automatically using [rustfmt](https://github.com/rust-lang/rustfmt). 214 | Please review the changes and merge if everything looks good. 215 | 216 | --- 217 | 218 | Delete the \`${ref.replace('refs/heads/', '')}\` branch after merging or closing the pull request.` 219 | return octokit.rest.pulls.create({ 220 | ...context.repo, 221 | title, 222 | head: ref.replace('refs/heads/', ''), 223 | base: head.ref.replace('refs/heads/', ''), 224 | body 225 | }) 226 | }) 227 | ) 228 | ) 229 | } 230 | break 231 | default: 232 | throw new Error(`Unsupported mode: ${mode}`) 233 | } 234 | } catch (error) { 235 | const message = error instanceof Error ? error.message : String(error) 236 | core.setFailed(message) 237 | } 238 | } 239 | 240 | run() 241 | -------------------------------------------------------------------------------- /src/rustfmt.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | import stringArgv from 'string-argv' 4 | import { EOL } from 'os' 5 | 6 | const rustfmt = async ( 7 | options: string[] = [], 8 | args: string = core.getInput('args') 9 | ): Promise => { 10 | let output = '' 11 | return exec 12 | .exec( 13 | 'cargo', 14 | ['fmt'].concat(stringArgv(args)).concat(['--']).concat(options), 15 | { 16 | listeners: { 17 | stdout: (data: Buffer) => { 18 | output += data.toString() 19 | } 20 | } 21 | } 22 | ) 23 | .then(() => output.trim().split(EOL).filter(Boolean)) 24 | } 25 | 26 | export default rustfmt 27 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "declarationMap": false, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["ES2022"], 10 | "module": "NodeNext", 11 | "moduleResolution": "NodeNext", 12 | "newLine": "lf", 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": false, 16 | "pretty": true, 17 | "resolveJsonModule": true, 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2022" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "noEmit": true 7 | }, 8 | "exclude": ["dist", "node_modules"], 9 | "include": [ 10 | "__fixtures__", 11 | "__tests__", 12 | "src", 13 | "eslint.config.mjs", 14 | "jest.config.js", 15 | "rollup.config.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "outDir": "./dist" 8 | }, 9 | "exclude": ["__fixtures__", "__tests__", "coverage", "dist", "node_modules"], 10 | "include": ["src"] 11 | } 12 | --------------------------------------------------------------------------------