├── .github ├── dependabot.yml └── workflows │ ├── actions-tagger.yaml │ ├── black.yaml │ ├── github-release.yaml │ ├── pull-request-tests.yaml │ ├── pylint.yaml │ └── shellcheck.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── action.yml ├── action_launcher.bash ├── requirements.txt ├── run_action.py └── tests ├── .clang-tidy ├── CMakeLists.txt └── main.cpp /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | -------------------------------------------------------------------------------- /.github/workflows/actions-tagger.yaml: -------------------------------------------------------------------------------- 1 | name: actions-tagger 2 | 3 | on: 4 | release: 5 | types: [ published, edited ] 6 | 7 | jobs: 8 | actions-tagger: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: Actions-R-Us/actions-tagger@v2 12 | -------------------------------------------------------------------------------- /.github/workflows/black.yaml: -------------------------------------------------------------------------------- 1 | name: black 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | black: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: psf/black@stable 15 | -------------------------------------------------------------------------------- /.github/workflows/github-release.yaml: -------------------------------------------------------------------------------- 1 | name: github-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | github-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v4 14 | - name: Release to GitHub 15 | run: | 16 | gh release create "${{ github.ref_name }}" \ 17 | --draft \ 18 | --title "Release ${{ github.ref_name }}" \ 19 | --notes "" 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Test Action in CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CLANG_TIDY_VERSION: 17.0.1 11 | CMAKE_VERSION: 3.21.3 12 | # cannot use > 3.11 due to https://github.com/ssciwr/clang-tidy-wheel/issues/66 13 | PYTHON_VERSION: 3.11 14 | 15 | jobs: 16 | check-action: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ env.PYTHON_VERSION }} 23 | - name: Install dependencies 24 | run: | 25 | pip install clang-tidy==${CLANG_TIDY_VERSION} cmake==${CMAKE_VERSION} 26 | - name: Configure Test project 27 | run: | 28 | mkdir build 29 | cd build 30 | cmake ../tests 31 | - name: Run clang-tidy 32 | run: | 33 | cd build 34 | cmake --build . --target clang-tidy 35 | - name: Test Suggest Fixes 36 | uses: ./ 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | request_changes: false 40 | clang_tidy_fixes: build/fixes.yaml 41 | check-action-with-custom-python: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/setup-python@v5 46 | id: setup-python 47 | with: 48 | python-version: ${{ env.PYTHON_VERSION }} 49 | - name: Install dependencies 50 | run: | 51 | pip install clang-tidy==${CLANG_TIDY_VERSION} cmake==${CMAKE_VERSION} 52 | - name: Configure Test project 53 | run: | 54 | mkdir build 55 | cd build 56 | cmake ../tests 57 | - name: Run clang-tidy 58 | run: | 59 | cd build 60 | cmake --build . --target clang-tidy 61 | - name: Test Suggest Fixes 62 | uses: ./ 63 | with: 64 | # Use existing Python installation instead of installing a new one 65 | python_path: ${{ steps.setup-python.outputs.python-path }} 66 | github_token: ${{ secrets.GITHUB_TOKEN }} 67 | request_changes: false 68 | clang_tidy_fixes: build/fixes.yaml 69 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yaml: -------------------------------------------------------------------------------- 1 | name: pylint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | pylint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r requirements.txt 21 | pip install pylint 22 | - name: Analyze the code with pylint 23 | run: | 24 | pylint --fail-under=10.0 $(git ls-files '*.py') 25 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yaml: -------------------------------------------------------------------------------- 1 | name: shellcheck 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | shellcheck: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install ShellCheck 15 | run: | 16 | sudo apt-get -y update 17 | sudo apt-get -y install shellcheck 18 | - name: Analyze the scripts with ShellCheck 19 | run: | 20 | shellcheck action_launcher.bash 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.json 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "ci(pre-commit): autofix" 3 | autoupdate_commit_msg: "ci(pre-commit): autoupdate" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-merge-conflict 10 | - id: check-yaml 11 | - id: detect-private-key 12 | - id: end-of-file-fixer 13 | - id: mixed-line-ending 14 | - id: trailing-whitespace 15 | args: [--markdown-linebreak-ext=md] 16 | - repo: https://github.com/psf/black 17 | rev: 23.12.1 18 | hooks: 19 | - id: black 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dimitris Platis 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 | # clang-tidy pull request comments 2 | 3 | [![clang-tidy-8 support]](https://releases.llvm.org/8.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 4 | [![clang-tidy-9 support]](https://releases.llvm.org/9.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 5 | [![clang-tidy-10 support]](https://releases.llvm.org/10.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 6 | [![clang-tidy-11 support]](https://releases.llvm.org/11.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 7 | [![clang-tidy-12 support]](https://releases.llvm.org/12.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 8 | [![clang-tidy-13 support]](https://releases.llvm.org/13.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 9 | [![clang-tidy-14 support]](https://releases.llvm.org/14.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 10 | [![clang-tidy-15 support]](https://releases.llvm.org/15.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 11 | [![clang-tidy-16 support]](https://releases.llvm.org/16.0.0/tools/clang/tools/extra/docs/clang-tidy/index.html) 12 | [![clang-tidy-17 support]](https://releases.llvm.org/17.0.1/tools/clang/tools/extra/docs/clang-tidy/index.html) 13 | [![clang-tidy-18 support]](https://releases.llvm.org/18.1.1/tools/clang/tools/extra/docs/clang-tidy/index.html) 14 | 15 | A GitHub Action to post `clang-tidy` warnings and suggestions as review comments on your pull request. 16 | 17 | ![action preview](https://i.imgur.com/lQiFdT9.png) 18 | 19 | * [What](#what) 20 | * [Supported clang-tidy versions](#supported-clang-tidy-versions) 21 | * [How](#how) 22 | * [Basic configuration example](#basic-configuration-example) 23 | * [Triggering this Action manually](#triggering-this-action-manually) 24 | * [Using this Action to safely perform analysis of pull requests from forks](#using-this-action-to-safely-perform-analysis-of-pull-requests-from-forks) 25 | * [Who's using this action?](#whos-using-this-action) 26 | 27 | ## What 28 | 29 | `platisd/clang-tidy-pr-comments` is a GitHub Action that utilizes the *exported fixes* of 30 | `clang-tidy` for your C++ project and posts them as **code review comments** in the related **pull request**. 31 | 32 | If `clang-tidy` has a concrete recommendation on how you should modify your code to fix the issue that's detected, 33 | then it will be presented as a *suggested change* that can be committed directly. Alternatively, 34 | the offending line will be highlighted along with a description of the warning. 35 | 36 | The GitHub Action can be configured to *request changes* if `clang-tidy` warnings are found or merely 37 | leave a comment without blocking the pull request from being merged. It should fail only if it has been 38 | misconfigured by you, due to a bug (please contact me if that's the case) or the GitHub API acting up. 39 | 40 | Please note the following: 41 | 42 | * It will **not** run `clang-tidy` for you. You are responsible for doing that and then supply the Action with 43 | the path to your generated report (see [examples](#how) below). You can generate a 44 | YAML report that includes *fixes* for a pull request using the following methods: 45 | 46 | * Using the `run-clang-tidy` utility script with the `-export-fixes` argument. This script usually 47 | comes with the `clang-tidy` packages. You can use it to run checks *for the entire codebase* of a 48 | project at once. 49 | 50 | * Using the `clang-tidy-diff` utility script with the `-export-fixes` argument. This script also usually 51 | comes with the `clang-tidy` packages, and and it can be used to run checks only for code fragments that 52 | *have been changed* in a specific pull request. 53 | 54 | * Alternatively, you may use `--export-fixes` with `clang-tidy` itself in your own script. 55 | 56 | In any case, specify the path where you would like the report to be exported. The very same path should 57 | be supplied to this Action. 58 | 59 | * It will *only* comment on files and lines changed in the pull request. This is due to GitHub not allowing 60 | comments on other files outside the pull request `diff`. 61 | This means that there may be more warnings in your project. Make sure you fix them *before* starting to 62 | use this Action to ensure new warnings will not be introduced in the future. 63 | 64 | * This Action *respects* existing comments and doesn't repeat the same warnings for the same line (no spam). 65 | 66 | * This Action allows analysis to be performed *separately* from the posting of the analysis results (using 67 | separate workflows with different privileges), which 68 | [allows you to safely analyze pull requests from forks](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) 69 | (see [example](#using-this-action-to-safely-perform-analysis-of-pull-requests-from-forks) below). 70 | 71 | ### Supported clang-tidy versions 72 | 73 | YAML files containing generated fixes by the following `clang-tidy` versions are currently supported: 74 | * `clang-tidy-8` 75 | * `clang-tidy-9` 76 | * `clang-tidy-10` 77 | * `clang-tidy-11` 78 | * `clang-tidy-12` 79 | * `clang-tidy-13` 80 | * `clang-tidy-14` 81 | * `clang-tidy-15` 82 | * `clang-tidy-16` 83 | * `clang-tidy-17` 84 | * `clang-tidy-18` 85 | 86 | ## How 87 | 88 | Since this action comments on files changed in pull requests, naturally, it can be only run 89 | on `pull_request` events. That being said, if it happens to be triggered in a different context, 90 | e.g. a `push` event, it will **not** run and fail *softly* by returning a *success* code. 91 | 92 | ### Basic configuration example 93 | 94 | A basic configuration for the `platisd/clang-tidy-pr-comments` action (for a `CMake`-based project 95 | using the `clang-tidy-diff` script) can be seen below: 96 | 97 | ```yaml 98 | name: Static analysis 99 | 100 | on: pull_request 101 | 102 | jobs: 103 | clang-tidy: 104 | runs-on: ubuntu-22.04 105 | permissions: 106 | pull-requests: write 107 | # OPTIONAL: auto-closing conversations requires the `contents` permission 108 | contents: write 109 | steps: 110 | - uses: actions/checkout@v4 111 | with: 112 | ref: ${{ github.event.pull_request.head.sha }} 113 | fetch-depth: 0 114 | - name: Fetch base branch 115 | run: | 116 | git remote add upstream "https://github.com/${{ github.event.pull_request.base.repo.full_name }}" 117 | git fetch --no-tags --no-recurse-submodules upstream "${{ github.event.pull_request.base.ref }}" 118 | - name: Install clang-tidy 119 | run: | 120 | sudo apt-get update 121 | sudo apt-get install -y clang-tidy 122 | - name: Prepare compile_commands.json 123 | run: | 124 | cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 125 | - name: Create results directory 126 | run: | 127 | mkdir clang-tidy-result 128 | - name: Analyze 129 | run: | 130 | git diff -U0 "$(git merge-base HEAD "upstream/${{ github.event.pull_request.base.ref }}")" | clang-tidy-diff -p1 -path build -export-fixes clang-tidy-result/fixes.yml 131 | - name: Run clang-tidy-pr-comments action 132 | uses: platisd/clang-tidy-pr-comments@v1 133 | with: 134 | # The GitHub token (or a personal access token) 135 | github_token: ${{ secrets.GITHUB_TOKEN }} 136 | # The path to the clang-tidy fixes generated previously 137 | clang_tidy_fixes: clang-tidy-result/fixes.yml 138 | # Optionally set to true if you want the Action to request 139 | # changes in case warnings are found 140 | request_changes: true 141 | # Optionally set the number of comments per review 142 | # to avoid GitHub API timeouts for heavily loaded 143 | # pull requests 144 | suggestions_per_comment: 10 145 | ``` 146 | 147 | ### Triggering this Action manually 148 | 149 | If you want to trigger this Action manually, i.e. by leaving a comment with a particular *keyword* 150 | in the pull request, then you can try the following: 151 | 152 | ```yaml 153 | name: Static analysis 154 | 155 | # Don't trigger it on pull_request events but issue_comment instead 156 | on: issue_comment 157 | 158 | jobs: 159 | clang-tidy: 160 | # Trigger the job only when someone comments: run_clang_tidy 161 | if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, 'run_clang_tidy') }} 162 | runs-on: ubuntu-22.04 163 | permissions: 164 | pull-requests: write 165 | # OPTIONAL: auto-closing conversations requires the `contents` permission 166 | contents: write 167 | steps: 168 | - uses: actions/checkout@v4 169 | with: 170 | ref: ${{ github.event.pull_request.head.sha }} 171 | fetch-depth: 0 172 | - name: Fetch base branch 173 | run: | 174 | git remote add upstream "https://github.com/${{ github.event.pull_request.base.repo.full_name }}" 175 | git fetch --no-tags --no-recurse-submodules upstream "${{ github.event.pull_request.base.ref }}" 176 | - name: Install clang-tidy 177 | run: | 178 | sudo apt-get update 179 | sudo apt-get install -y clang-tidy 180 | - name: Prepare compile_commands.json 181 | run: | 182 | cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 183 | - name: Create results directory 184 | run: | 185 | mkdir clang-tidy-result 186 | - name: Analyze 187 | run: | 188 | git diff -U0 "$(git merge-base HEAD "upstream/${{ github.event.pull_request.base.ref }}")" | clang-tidy-diff -p1 -path build -export-fixes clang-tidy-result/fixes.yml 189 | - name: Run clang-tidy-pr-comments action 190 | uses: platisd/clang-tidy-pr-comments@v1 191 | with: 192 | github_token: ${{ secrets.GITHUB_TOKEN }} 193 | clang_tidy_fixes: clang-tidy-result/fixes.yml 194 | ``` 195 | 196 | ### Using this Action to safely perform analysis of pull requests from forks 197 | 198 | If you want to trigger this Action using the `workflow_run` event to run analysis on pull requests 199 | from forks in a 200 | [secure manner](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/), 201 | then you can use the following combination of workflows: 202 | 203 | ```yaml 204 | # Insecure workflow with limited permissions that should provide analysis results through an artifact 205 | name: Static analysis 206 | 207 | on: pull_request 208 | 209 | jobs: 210 | clang-tidy: 211 | runs-on: ubuntu-22.04 212 | steps: 213 | - uses: actions/checkout@v4 214 | with: 215 | ref: ${{ github.event.pull_request.head.sha }} 216 | fetch-depth: 0 217 | - name: Fetch base branch 218 | run: | 219 | git remote add upstream "https://github.com/${{ github.event.pull_request.base.repo.full_name }}" 220 | git fetch --no-tags --no-recurse-submodules upstream "${{ github.event.pull_request.base.ref }}" 221 | - name: Install clang-tidy 222 | run: | 223 | sudo apt-get update 224 | sudo apt-get install -y clang-tidy 225 | - name: Prepare compile_commands.json 226 | run: | 227 | cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 228 | - name: Create results directory 229 | run: | 230 | mkdir clang-tidy-result 231 | - name: Analyze 232 | run: | 233 | git diff -U0 "$(git merge-base HEAD "upstream/${{ github.event.pull_request.base.ref }}")" | clang-tidy-diff -p1 -path build -export-fixes clang-tidy-result/fixes.yml 234 | - name: Save PR metadata 235 | run: | 236 | echo "${{ github.event.number }}" > clang-tidy-result/pr-id.txt 237 | echo "${{ github.event.pull_request.head.repo.full_name }}" > clang-tidy-result/pr-head-repo.txt 238 | echo "${{ github.event.pull_request.head.sha }}" > clang-tidy-result/pr-head-sha.txt 239 | - uses: actions/upload-artifact@v4 240 | with: 241 | name: clang-tidy-result 242 | path: clang-tidy-result/ 243 | ``` 244 | 245 | ```yaml 246 | # Secure workflow with access to repository secrets and GitHub token for posting analysis results 247 | name: Post the static analysis results 248 | 249 | on: 250 | workflow_run: 251 | workflows: [ "Static analysis" ] 252 | types: [ completed ] 253 | 254 | jobs: 255 | clang-tidy-results: 256 | # Trigger the job only if the previous (insecure) workflow completed successfully 257 | if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} 258 | runs-on: ubuntu-22.04 259 | permissions: 260 | pull-requests: write 261 | # OPTIONAL: auto-closing conversations requires the `contents` permission 262 | contents: write 263 | steps: 264 | - name: Download analysis results 265 | uses: actions/github-script@v7 266 | with: 267 | script: | 268 | const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ 269 | owner: context.repo.owner, 270 | repo: context.repo.repo, 271 | run_id: ${{ github.event.workflow_run.id }}, 272 | }); 273 | const matchArtifact = artifacts.data.artifacts.filter((artifact) => { 274 | return artifact.name == "clang-tidy-result" 275 | })[0]; 276 | const download = await github.rest.actions.downloadArtifact({ 277 | owner: context.repo.owner, 278 | repo: context.repo.repo, 279 | artifact_id: matchArtifact.id, 280 | archive_format: "zip", 281 | }); 282 | const fs = require("fs"); 283 | fs.writeFileSync("${{ github.workspace }}/clang-tidy-result.zip", Buffer.from(download.data)); 284 | - name: Extract analysis results 285 | run: | 286 | mkdir clang-tidy-result 287 | unzip -j clang-tidy-result.zip -d clang-tidy-result 288 | - name: Set environment variables 289 | uses: actions/github-script@v7 290 | with: 291 | script: | 292 | const assert = require("node:assert").strict; 293 | const fs = require("fs"); 294 | function exportVar(varName, fileName, regEx) { 295 | const val = fs.readFileSync("${{ github.workspace }}/clang-tidy-result/" + fileName, { 296 | encoding: "ascii" 297 | }).trimEnd(); 298 | assert.ok(regEx.test(val), "Invalid value format for " + varName); 299 | core.exportVariable(varName, val); 300 | } 301 | exportVar("PR_ID", "pr-id.txt", /^[0-9]+$/); 302 | exportVar("PR_HEAD_REPO", "pr-head-repo.txt", /^[-./0-9A-Z_a-z]+$/); 303 | exportVar("PR_HEAD_SHA", "pr-head-sha.txt", /^[0-9A-Fa-f]+$/); 304 | - uses: actions/checkout@v4 305 | with: 306 | repository: ${{ env.PR_HEAD_REPO }} 307 | ref: ${{ env.PR_HEAD_SHA }} 308 | persist-credentials: false 309 | - name: Redownload analysis results 310 | uses: actions/github-script@v7 311 | with: 312 | script: | 313 | const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ 314 | owner: context.repo.owner, 315 | repo: context.repo.repo, 316 | run_id: ${{ github.event.workflow_run.id }}, 317 | }); 318 | const matchArtifact = artifacts.data.artifacts.filter((artifact) => { 319 | return artifact.name == "clang-tidy-result" 320 | })[0]; 321 | const download = await github.rest.actions.downloadArtifact({ 322 | owner: context.repo.owner, 323 | repo: context.repo.repo, 324 | artifact_id: matchArtifact.id, 325 | archive_format: "zip", 326 | }); 327 | const fs = require("fs"); 328 | fs.writeFileSync("${{ github.workspace }}/clang-tidy-result.zip", Buffer.from(download.data)); 329 | - name: Extract analysis results 330 | run: | 331 | mkdir clang-tidy-result 332 | unzip -j clang-tidy-result.zip -d clang-tidy-result 333 | - name: Run clang-tidy-pr-comments action 334 | uses: platisd/clang-tidy-pr-comments@v1 335 | with: 336 | github_token: ${{ secrets.GITHUB_TOKEN }} 337 | clang_tidy_fixes: clang-tidy-result/fixes.yml 338 | pull_request_id: ${{ env.PR_ID }} 339 | ``` 340 | 341 | ## Who's using this action? 342 | 343 | See the [Action dependency graph](https://github.com/platisd/clang-tidy-pr-comments/network/dependents). 344 | 345 | [clang-tidy-8 support]: https://img.shields.io/badge/clang--tidy-8-green 346 | [clang-tidy-9 support]: https://img.shields.io/badge/clang--tidy-9-green 347 | [clang-tidy-10 support]: https://img.shields.io/badge/clang--tidy-10-green 348 | [clang-tidy-11 support]: https://img.shields.io/badge/clang--tidy-11-green 349 | [clang-tidy-12 support]: https://img.shields.io/badge/clang--tidy-12-green 350 | [clang-tidy-13 support]: https://img.shields.io/badge/clang--tidy-13-green 351 | [clang-tidy-14 support]: https://img.shields.io/badge/clang--tidy-14-green 352 | [clang-tidy-15 support]: https://img.shields.io/badge/clang--tidy-15-green 353 | [clang-tidy-16 support]: https://img.shields.io/badge/clang--tidy-16-green 354 | [clang-tidy-17 support]: https://img.shields.io/badge/clang--tidy-17-green 355 | [clang-tidy-18 support]: https://img.shields.io/badge/clang--tidy-18-green 356 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Pull request comments from clang-tidy reports' 2 | description: 'Post clang-tidy warnings as review comments on pull requests' 3 | inputs: 4 | github_token: 5 | description: 'The GitHub token' 6 | required: true 7 | clang_tidy_fixes: 8 | description: 'Path to the clang-tidy fixes YAML file' 9 | required: true 10 | pull_request_id: 11 | description: 'Pull request id (otherwise attempt to extract it from the GitHub metadata)' 12 | required: false 13 | default: '' 14 | request_changes: 15 | description: 'Request changes if there are clang-tidy issues (otherwise leave a comment)' 16 | required: false 17 | default: 'false' 18 | suggestions_per_comment: 19 | description: 'The number of suggestions per comment (smaller numbers work better for heavy pull requests)' 20 | required: false 21 | default: '10' 22 | repo_path_prefix: 23 | description: 'The path to repo when code is analyzed with clang-tidy; may set to "/__w" for users who run clang-tidy in a docker container' 24 | required: false 25 | default: '/home/runner/work' 26 | auto_resolve_conversations: 27 | description: 'Automatically resolve conversations when the clang-tidy issues are fixed' 28 | required: false 29 | default: 'false' 30 | python_path: 31 | description: 'Path to a Python executable to use; if not set Python will be installed locally' 32 | required: false 33 | default: '' 34 | runs: 35 | using: 'composite' 36 | steps: 37 | - name: Setup Python 38 | if: ${{ !inputs.python_path }} 39 | uses: actions/setup-python@v5 40 | id: setup-python 41 | with: 42 | python-version: 3.11 43 | update-environment: false 44 | - name: Setup venv 45 | run: | 46 | "${{ steps.setup-python.outputs.python-path || inputs.python_path }}" -m venv "${GITHUB_ACTION_PATH}/venv" 47 | shell: bash 48 | - name: Install dependencies 49 | run: | 50 | "${GITHUB_ACTION_PATH}/venv/bin/python" -m pip install -r "${GITHUB_ACTION_PATH}/requirements.txt" 51 | shell: bash 52 | - name: Run action 53 | run: | 54 | "${GITHUB_ACTION_PATH}/action_launcher.bash" 55 | shell: bash 56 | env: 57 | INPUT_GITHUB_TOKEN: ${{ inputs.github_token }} 58 | INPUT_CLANG_TIDY_FIXES: ${{ inputs.clang_tidy_fixes }} 59 | INPUT_PULL_REQUEST_ID: ${{ inputs.pull_request_id }} 60 | INPUT_REQUEST_CHANGES: ${{ inputs.request_changes }} 61 | INPUT_SUGGESTIONS_PER_COMMENT: ${{ inputs.suggestions_per_comment }} 62 | INPUT_REPO_PATH_PREFIX: ${{ inputs.repo_path_prefix }} 63 | INPUT_AUTO_RESOLVE_CONVERSATIONS: ${{ inputs.auto_resolve_conversations }} 64 | PULL_REQUEST_ID: ${{ github.event.issue.number || github.event.number || '' }} 65 | branding: 66 | icon: 'cpu' 67 | color: 'green' 68 | -------------------------------------------------------------------------------- /action_launcher.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | if [ -n "$INPUT_PULL_REQUEST_ID" ]; then 6 | pull_request_id="$INPUT_PULL_REQUEST_ID" 7 | elif [ -n "$PULL_REQUEST_ID" ]; then 8 | pull_request_id="$PULL_REQUEST_ID" 9 | else 10 | echo "Could not find the pull request ID. Is this a pull request?" 11 | exit 0 12 | fi 13 | 14 | repository_name="$(basename "$GITHUB_REPOSITORY")" 15 | recreated_runner_dir="$INPUT_REPO_PATH_PREFIX/$repository_name" 16 | mkdir -p "$recreated_runner_dir" 17 | recreated_repo_dir="$recreated_runner_dir/$repository_name" 18 | 19 | ln -s "$(pwd)" "$recreated_repo_dir" 20 | 21 | cd "$recreated_repo_dir" 22 | 23 | "${GITHUB_ACTION_PATH}/venv/bin/python" "${GITHUB_ACTION_PATH}/run_action.py" \ 24 | --clang-tidy-fixes "$INPUT_CLANG_TIDY_FIXES" \ 25 | --pull-request-id "$pull_request_id" \ 26 | --repository "$GITHUB_REPOSITORY" \ 27 | --repository-root "$recreated_repo_dir" \ 28 | --request-changes "$INPUT_REQUEST_CHANGES" \ 29 | --suggestions-per-comment "$INPUT_SUGGESTIONS_PER_COMMENT" \ 30 | --auto-resolve-conversations "$INPUT_AUTO_RESOLVE_CONVERSATIONS" 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml>=5.4 2 | requests>=2.18 3 | -------------------------------------------------------------------------------- /run_action.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Runner of the 'pull request comments from Clang-Tidy reports' action""" 4 | 5 | import argparse 6 | import difflib 7 | import json 8 | import os 9 | import posixpath 10 | import re 11 | import sys 12 | import time 13 | 14 | import requests 15 | import yaml 16 | 17 | 18 | def get_diff_line_ranges_per_file(pr_files): 19 | """Generates and returns a list of line ranges affected by the corresponding patch hunks for 20 | each file that has been modified by the processed PR""" 21 | 22 | def change_to_line_range(change): 23 | split_change = change.split(",") 24 | start = int(split_change[0]) 25 | 26 | if len(split_change) > 1: 27 | size = int(split_change[1]) 28 | else: 29 | size = 1 30 | 31 | return range(start, start + size) 32 | 33 | result = {} 34 | 35 | for pr_file in pr_files: 36 | # Not all PR file metadata entries may contain a patch section 37 | # For example, entries related to removed binary files may not contain it 38 | if "patch" not in pr_file: 39 | continue 40 | 41 | file_name = pr_file["filename"] 42 | 43 | # The result is something like ['@@ -101,8 +102,11 @@', '@@ -123,9 +127,7 @@'] 44 | git_line_tags = re.findall(r"^@@ -.*? +.*? @@", pr_file["patch"], re.MULTILINE) 45 | 46 | # We need to get it to a state like this: ['102,11', '127,7'] 47 | changes = [ 48 | tag.replace("@@", "").strip().split()[1].replace("+", "") 49 | for tag in git_line_tags 50 | ] 51 | 52 | result[file_name] = [] 53 | for line_range in [change_to_line_range(change) for change in changes]: 54 | result[file_name].append(line_range) 55 | 56 | return result 57 | 58 | 59 | def get_pull_request_files( 60 | github_api_url, github_token, github_api_timeout, repo, pull_request_id 61 | ): 62 | """Generator of GitHub metadata about files modified by the processed PR""" 63 | 64 | # Request a maximum of 100 pages (3000 items) 65 | for page in range(1, 101): 66 | result = requests.get( 67 | f"{github_api_url}/repos/{repo}/pulls/{pull_request_id:d}/files?page={page:d}", 68 | headers={ 69 | "Accept": "application/vnd.github.v3+json", 70 | "Authorization": f"token {github_token}", 71 | }, 72 | timeout=github_api_timeout, 73 | ) 74 | 75 | assert result.status_code == requests.codes.ok # pylint: disable=no-member 76 | 77 | chunk = json.loads(result.text) 78 | 79 | if not chunk: 80 | break 81 | 82 | yield from chunk 83 | 84 | 85 | def get_pull_request_comments( 86 | github_api_url, github_token, github_api_timeout, repo, pull_request_id 87 | ): 88 | """Generator of GitHub metadata about comments to the processed PR""" 89 | 90 | # Request a maximum of 100 pages (3000 items) 91 | for page in range(1, 101): 92 | result = requests.get( 93 | f"{github_api_url}/repos/{repo}/pulls/{pull_request_id:d}/comments?page={page:d}", 94 | headers={ 95 | "Accept": "application/vnd.github.v3+json", 96 | "Authorization": f"token {github_token}", 97 | }, 98 | timeout=github_api_timeout, 99 | ) 100 | 101 | assert result.status_code == requests.codes.ok # pylint: disable=no-member 102 | 103 | chunk = json.loads(result.text) 104 | 105 | if not chunk: 106 | break 107 | 108 | yield from chunk 109 | 110 | 111 | def generate_review_comments( 112 | clang_tidy_fixes, repository_root, diff_line_ranges_per_file, single_comment_markers 113 | ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements 114 | """Generator of the Clang-Tidy review comments""" 115 | 116 | def get_line_by_offset(repository_root, file_path, offset): 117 | # Clang-Tidy doesn't support multibyte encodings and measures offsets in bytes 118 | with open(repository_root + file_path, encoding="latin_1") as file: 119 | source_file = file.read() 120 | 121 | return source_file[:offset].count("\n") + 1 122 | 123 | def validate_warning_applicability( 124 | diff_line_ranges_per_file, file_path, start_line_num, end_line_num 125 | ): 126 | assert end_line_num >= start_line_num 127 | 128 | for line_range in diff_line_ranges_per_file[file_path]: 129 | assert line_range.step == 1 130 | 131 | if line_range.start <= start_line_num and end_line_num < line_range.stop: 132 | return True 133 | 134 | return False 135 | 136 | def calculate_replacements_diff(repository_root, file_path, replacements): 137 | # Apply the replacements in reverse order so that subsequent offsets are not shifted 138 | replacements.sort(key=lambda item: (-item["Offset"])) 139 | 140 | # Clang-Tidy doesn't support multibyte encodings and measures offsets in bytes 141 | with open(repository_root + file_path, encoding="latin_1") as file: 142 | source_file = file.read() 143 | 144 | changed_file = source_file 145 | 146 | for replacement in replacements: 147 | changed_file = ( 148 | changed_file[: replacement["Offset"]] 149 | + replacement["ReplacementText"] 150 | + changed_file[replacement["Offset"] + replacement["Length"] :] 151 | ) 152 | 153 | # Create and return the diff between the original version of the file and the version 154 | # with the applied replacements 155 | return difflib.Differ().compare( 156 | source_file.splitlines(keepends=True), 157 | changed_file.splitlines(keepends=True), 158 | ) 159 | 160 | def markdown(s): 161 | md_chars = "\\`*_{}[]<>()#+-.!|" 162 | 163 | def escape_chars(s): 164 | for ch in md_chars: 165 | s = s.replace(ch, "\\" + ch) 166 | 167 | return s 168 | 169 | def unescape_chars(s): 170 | for ch in md_chars: 171 | s = s.replace("\\" + ch, ch) 172 | 173 | return s 174 | 175 | # Escape markdown characters 176 | s = escape_chars(s) 177 | # Decorate quoted symbols as code 178 | s = re.sub( 179 | "'([^']*)'", lambda match: "`` " + unescape_chars(match.group(1)) + " ``", s 180 | ) 181 | 182 | return s 183 | 184 | def generate_single_comment( 185 | file_path, 186 | start_line_num, 187 | end_line_num, 188 | name, 189 | message, 190 | single_comment_marker, 191 | replacement_text=None, 192 | ): # pylint: disable=too-many-arguments,too-many-positional-arguments 193 | result = { 194 | "path": file_path, 195 | "line": end_line_num, 196 | "side": "RIGHT", 197 | "body": f"{single_comment_marker} **{markdown(name)}** {single_comment_marker}\n" 198 | + markdown(message), 199 | } 200 | 201 | if start_line_num != end_line_num: 202 | result["start_line"] = start_line_num 203 | result["start_side"] = "RIGHT" 204 | 205 | if replacement_text is not None: 206 | # Make sure the code suggestion ends with a newline character 207 | if not replacement_text or replacement_text[-1] != "\n": 208 | replacement_text += "\n" 209 | 210 | result["body"] += f"\n```suggestion\n{replacement_text}```" 211 | 212 | return result 213 | 214 | for diag in clang_tidy_fixes[ # pylint: disable=too-many-nested-blocks 215 | "Diagnostics" 216 | ]: 217 | # If we have a Clang-Tidy 8 format, then upconvert it to the Clang-Tidy 9+ 218 | if "DiagnosticMessage" not in diag: 219 | diag["DiagnosticMessage"] = { 220 | "FileOffset": diag["FileOffset"], 221 | "FilePath": diag["FilePath"], 222 | "Message": diag["Message"], 223 | "Replacements": diag["Replacements"], 224 | } 225 | 226 | diag_message = diag["DiagnosticMessage"] 227 | 228 | # Normalize paths 229 | diag_message["FilePath"] = posixpath.normpath( 230 | diag_message["FilePath"].replace(repository_root, "") 231 | ) 232 | for replacement in diag_message["Replacements"]: 233 | replacement["FilePath"] = posixpath.normpath( 234 | replacement["FilePath"].replace(repository_root, "") 235 | ) 236 | 237 | diag_name = diag["DiagnosticName"] 238 | diag_message_msg = diag_message["Message"] 239 | 240 | if diag["Level"] in single_comment_markers: 241 | single_comment_marker = single_comment_markers[diag["Level"]] 242 | else: 243 | single_comment_marker = single_comment_markers["fallback"] 244 | 245 | if not diag_message["Replacements"]: 246 | file_path = diag_message["FilePath"] 247 | offset = diag_message["FileOffset"] 248 | 249 | if file_path not in diff_line_ranges_per_file: 250 | print( 251 | f"'{diag_name}' for {file_path} does not apply to the files changed in this PR" 252 | ) 253 | continue 254 | 255 | line_num = get_line_by_offset(repository_root, file_path, offset) 256 | 257 | print(f"Processing '{diag_name}' at line {line_num:d} of {file_path}...") 258 | 259 | if validate_warning_applicability( 260 | diff_line_ranges_per_file, file_path, line_num, line_num 261 | ): 262 | yield generate_single_comment( 263 | file_path, 264 | line_num, 265 | line_num, 266 | diag_name, 267 | diag_message_msg, 268 | single_comment_marker=single_comment_marker, 269 | ) 270 | else: 271 | print("This warning does not apply to the lines changed in this PR") 272 | else: 273 | diag_message_replacements = diag_message["Replacements"] 274 | 275 | for file_path in {item["FilePath"] for item in diag_message_replacements}: 276 | if file_path not in diff_line_ranges_per_file: 277 | # pylint: disable=line-too-long 278 | print( 279 | f"'{diag_name}' for {file_path} does not apply to the files changed in this PR" 280 | ) 281 | continue 282 | 283 | line_num = 1 284 | start_line_num = None 285 | end_line_num = None 286 | replacement_text = None 287 | 288 | for line in calculate_replacements_diff( 289 | repository_root, 290 | file_path, 291 | [ 292 | item 293 | for item in diag_message_replacements 294 | if item["FilePath"] == file_path 295 | ], 296 | ): 297 | # The comment line in the diff, ignore it 298 | if line.startswith("? "): 299 | continue 300 | 301 | # A string belonging only to the original version is the beginning or 302 | # continuation of the section of the file that should be replaced 303 | if line.startswith("- "): 304 | if start_line_num is None: 305 | assert end_line_num is None 306 | 307 | start_line_num = line_num 308 | end_line_num = line_num 309 | else: 310 | assert end_line_num is not None 311 | 312 | end_line_num = line_num 313 | 314 | if replacement_text is None: 315 | replacement_text = "" 316 | 317 | line_num += 1 318 | # A string belonging only to the modified version is part of the 319 | # replacement text 320 | elif line.startswith("+ "): 321 | if replacement_text is None: 322 | replacement_text = line[2:] 323 | else: 324 | replacement_text += line[2:] 325 | # A string belonging to both original and modified versions is the 326 | # end of the section to replace 327 | elif line.startswith(" "): 328 | if replacement_text is not None: 329 | # If there is a replacement text, but there is no information about 330 | # the section to replace, then this is not a replacement, but a pure 331 | # addition of text. Add the current line to the end of the replacement 332 | # text and "replace" it with the replacement text. 333 | if start_line_num is None: 334 | assert end_line_num is None 335 | 336 | start_line_num = line_num 337 | end_line_num = line_num 338 | replacement_text += line[2:] 339 | else: 340 | assert end_line_num is not None 341 | 342 | print( 343 | # pylint: disable=line-too-long 344 | f"Processing '{diag_name}' at lines {start_line_num:d}-{end_line_num:d} of {file_path}..." 345 | ) 346 | 347 | if validate_warning_applicability( 348 | diff_line_ranges_per_file, 349 | file_path, 350 | start_line_num, 351 | end_line_num, 352 | ): 353 | yield generate_single_comment( 354 | file_path, 355 | start_line_num, 356 | end_line_num, 357 | diag_name, 358 | diag_message_msg, 359 | single_comment_marker=single_comment_marker, 360 | replacement_text=replacement_text, 361 | ) 362 | else: 363 | print( 364 | "This warning does not apply to the lines changed in this PR" 365 | ) 366 | 367 | start_line_num = None 368 | end_line_num = None 369 | replacement_text = None 370 | 371 | line_num += 1 372 | # Unknown prefix, this should not happen 373 | else: 374 | assert False, "Please report this to the repository maintainer" 375 | 376 | # The end of the file is reached, but there is a section to replace 377 | if replacement_text is not None: 378 | # Pure addition of text to the end of the file is not currently supported. If 379 | # you have an example of a Clang-Tidy replacement of this kind, please contact 380 | # the repository maintainer. 381 | assert ( 382 | start_line_num is not None and end_line_num is not None 383 | ), "Please report this to the repository maintainer" 384 | 385 | print( 386 | # pylint: disable=line-too-long 387 | f"Processing '{diag_name}' at lines {start_line_num:d}-{end_line_num:d} of {file_path}..." 388 | ) 389 | 390 | if validate_warning_applicability( 391 | diff_line_ranges_per_file, 392 | file_path, 393 | start_line_num, 394 | end_line_num, 395 | ): 396 | yield generate_single_comment( 397 | file_path, 398 | start_line_num, 399 | end_line_num, 400 | diag_name, 401 | diag_message_msg, 402 | single_comment_marker=single_comment_marker, 403 | replacement_text=replacement_text, 404 | ) 405 | else: 406 | print( 407 | "This warning does not apply to the lines changed in this PR" 408 | ) 409 | 410 | 411 | def post_review_comments( 412 | github_api_url, 413 | github_token, 414 | github_api_timeout, 415 | repo, 416 | pull_request_id, 417 | warning_comment_prefix, 418 | review_event, 419 | review_comments, 420 | suggestions_per_comment, 421 | ): # pylint: disable=too-many-arguments,too-many-positional-arguments 422 | """Sending the Clang-Tidy review comments to GitHub""" 423 | 424 | def split_into_chunks(lst, n): 425 | # Copied from: https://stackoverflow.com/a/312464 426 | """Yield successive n-sized chunks from lst.""" 427 | for i in range(0, len(lst), n): 428 | yield lst[i : i + n] 429 | 430 | # Split the comments in chunks to avoid overloading the server 431 | # and getting 502 server errors as a response for large reviews 432 | review_comments = list(split_into_chunks(review_comments, suggestions_per_comment)) 433 | 434 | total_reviews = len(review_comments) 435 | current_review = 1 436 | 437 | for comments_chunk in review_comments: 438 | warning_comment = ( 439 | warning_comment_prefix + f" ({current_review:d}/{total_reviews:d})" 440 | ) 441 | current_review += 1 442 | 443 | result = requests.post( 444 | f"{github_api_url}/repos/{repo}/pulls/{pull_request_id:d}/reviews", 445 | json={ 446 | "body": warning_comment, 447 | "event": review_event, 448 | "comments": comments_chunk, 449 | }, 450 | headers={ 451 | "Accept": "application/vnd.github.v3+json", 452 | "Authorization": f"token {github_token}", 453 | }, 454 | timeout=github_api_timeout, 455 | ) 456 | 457 | # Ignore bad gateway errors (false negatives?) 458 | assert result.status_code in ( 459 | requests.codes.ok, # pylint: disable=no-member 460 | requests.codes.bad_gateway, # pylint: disable=no-member 461 | ), f"Unexpected status code: {result.status_code:d}" 462 | 463 | # Avoid triggering abuse detection 464 | time.sleep(10) 465 | 466 | 467 | def dismiss_change_requests( 468 | github_api_url, 469 | github_token, 470 | github_api_timeout, 471 | repo, 472 | pull_request_id, 473 | warning_comment_prefix, 474 | auto_resolve_conversations, 475 | single_comment_markers, 476 | ): # pylint: disable=too-many-arguments,too-many-positional-arguments 477 | """Dismissing stale Clang-Tidy requests for changes""" 478 | 479 | print("Checking if there are any stale requests for changes to dismiss...") 480 | 481 | result = requests.get( 482 | f"{github_api_url}/repos/{repo}/pulls/{pull_request_id:d}/reviews", 483 | headers={ 484 | "Accept": "application/vnd.github.v3+json", 485 | "Authorization": f"token {github_token}", 486 | }, 487 | timeout=github_api_timeout, 488 | ) 489 | 490 | assert result.status_code == requests.codes.ok # pylint: disable=no-member 491 | 492 | reviews = json.loads(result.text) 493 | 494 | # Dismiss only our own reviews 495 | reviews_to_dismiss = [ 496 | review["id"] 497 | for review in reviews 498 | if review["state"] == "CHANGES_REQUESTED" 499 | and warning_comment_prefix in review["body"] 500 | and review["user"]["login"] == "github-actions[bot]" 501 | ] 502 | 503 | for review_id in reviews_to_dismiss: 504 | print(f"Dismissing review {review_id:d}") 505 | 506 | result = requests.put( 507 | # pylint: disable=line-too-long 508 | f"{github_api_url}/repos/{repo}/pulls/{pull_request_id:d}/reviews/{review_id:d}/dismissals", 509 | headers={ 510 | "Accept": "application/vnd.github.v3+json", 511 | "Authorization": f"token {github_token}", 512 | }, 513 | json={ 514 | "message": "No Clang-Tidy warnings found so I assume my comments were addressed", 515 | "event": "DISMISS", 516 | }, 517 | timeout=github_api_timeout, 518 | ) 519 | 520 | assert result.status_code == requests.codes.ok # pylint: disable=no-member 521 | 522 | # Avoid triggering abuse detection 523 | time.sleep(10) 524 | 525 | if auto_resolve_conversations: 526 | resolve_conversations( 527 | github_token=github_token, 528 | repo=repo, 529 | pull_request_id=pull_request_id, 530 | github_api_timeout=github_api_timeout, 531 | single_comment_markers=single_comment_markers, 532 | ) 533 | 534 | 535 | def conversation_threads_to_close( 536 | repo, pr_number, github_token, github_api_timeout, single_comment_markers 537 | ): 538 | """Generator of unresolved conversation threads to close 539 | 540 | Uses the GitHub GraphQL API to get conversation threads for the given PR. 541 | Then filters for unresolved threads and those that have been created by the action. 542 | """ 543 | 544 | repo_owner, repo_name = repo.split("/") 545 | query = """ 546 | query { 547 | repository(owner: "%s", name: "%s") { 548 | pullRequest(number: %d) { 549 | id 550 | reviewThreads(last: 100) { 551 | nodes { 552 | id 553 | isResolved 554 | comments(first: 1) { 555 | nodes { 556 | id 557 | body 558 | author { 559 | login 560 | } 561 | } 562 | } 563 | } 564 | } 565 | } 566 | } 567 | } 568 | """ % ( 569 | repo_owner, 570 | repo_name, 571 | pr_number, 572 | ) 573 | 574 | response = requests.post( 575 | "https://api.github.com/graphql", 576 | json={"query": query}, 577 | headers={"Authorization": "Bearer " + github_token}, 578 | timeout=github_api_timeout, 579 | ) 580 | 581 | if response.status_code != 200: 582 | print( 583 | f"::error::getting unresolved conversation threads: {response.status_code}" 584 | ) 585 | raise RuntimeError("Failed to get unresolved conversation threads.") 586 | 587 | data = response.json() 588 | 589 | # list of regexes that matches comments with repeated marker emojis 590 | marker_matches = [] 591 | for single_comment_marker in single_comment_markers.values(): 592 | single_comment_marker = re.escape(single_comment_marker) 593 | comment_matcher = re.compile( 594 | f"^{single_comment_marker}.*{single_comment_marker}.*", re.DOTALL 595 | ) 596 | marker_matches.append(comment_matcher) 597 | 598 | # Iterate through review threads 599 | for thread in data["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"]: 600 | for comment in thread["comments"]["nodes"]: 601 | if ( 602 | comment["id"] 603 | and thread["isResolved"] is False 604 | # this actor here is somehow different from `github-actions[bot]` 605 | # which we get through the Rest API 606 | and comment["author"]["login"] == "github-actions" 607 | and any( 608 | matcher.match(comment["body"].strip()) for matcher in marker_matches 609 | ) 610 | ): 611 | yield thread 612 | break 613 | 614 | 615 | def close_conversation(thread_id, github_token, github_api_timeout): 616 | """Close a conversation thread using the GitHub GraphQL API""" 617 | mutation = ( 618 | """ 619 | mutation { 620 | resolveReviewThread(input: {threadId: "%s", clientMutationId: "github-actions"}) { 621 | thread { 622 | id 623 | } 624 | } 625 | } 626 | """ 627 | % thread_id 628 | ) 629 | 630 | print(f"::debug::Closing conversation {thread_id}...") 631 | response = requests.post( 632 | "https://api.github.com/graphql", 633 | json={"query": mutation}, 634 | headers={"Authorization": "Bearer " + github_token}, 635 | timeout=github_api_timeout, 636 | ) 637 | 638 | def _print_error_and_raise(msg): 639 | print( 640 | f"::error::{msg}" 641 | "::error:: Failed to close conversation. See log for details and " 642 | "https://github.com/platisd/clang-tidy-pr-comments/blob/master/README.md for help" 643 | ) 644 | raise RuntimeError("Failed to close conversation.") 645 | 646 | if response.status_code != 200: 647 | _print_error_and_raise(f"GraphQL request failed: {response.status_code}") 648 | 649 | if "errors" in response.json(): 650 | error_msg = response.json()["errors"][0]["message"] 651 | _print_error_and_raise( 652 | "Closing conversations requires `contents: write` permission." 653 | if "Resource not accessible by integration" in error_msg 654 | else f"Closing conversation query failed: {error_msg}" 655 | ) 656 | print("Conversation closed successfully.") 657 | 658 | 659 | def resolve_conversations( 660 | github_token, repo, pull_request_id, github_api_timeout, single_comment_markers 661 | ): 662 | """Resolving stale conversations""" 663 | for thread in conversation_threads_to_close( 664 | repo, pull_request_id, github_token, github_api_timeout, single_comment_markers 665 | ): 666 | close_conversation( 667 | thread_id=thread["id"], 668 | github_token=github_token, 669 | github_api_timeout=github_api_timeout, 670 | ) 671 | 672 | 673 | def reorder_diagnostics(diags): 674 | """ 675 | order diagnostics by level: first error, then warning, then remark 676 | """ 677 | errors = [d for d in diags if d["Level"] == "Error"] 678 | warnings = [d for d in diags if d["Level"] == "Warning"] 679 | remarks = [d for d in diags if d["Level"] == "Remark"] 680 | others = [d for d in diags if d["Level"] not in {"Error", "Warning", "Remark"}] 681 | 682 | if others: 683 | print( 684 | "WARNING: some fixes have an unexpected Level (e.g. not Error, Warning, Remark)" 685 | ) 686 | 687 | return errors + warnings + remarks + others 688 | 689 | 690 | def main(): 691 | """Entry point""" 692 | 693 | parser = argparse.ArgumentParser( 694 | description="Runner of the 'pull request comments from Clang-Tidy reports' action" 695 | ) 696 | parser.add_argument( 697 | "--clang-tidy-fixes", 698 | type=str, 699 | required=True, 700 | help="Path to the Clang-Tidy fixes YAML", 701 | ) 702 | parser.add_argument( 703 | "--pull-request-id", 704 | type=int, 705 | required=True, 706 | help="Pull request ID", 707 | ) 708 | parser.add_argument( 709 | "--repository", 710 | type=str, 711 | required=True, 712 | help="Name of the repository containing the code", 713 | ) 714 | parser.add_argument( 715 | "--repository-root", 716 | type=str, 717 | required=True, 718 | help="Path to the root of the repository containing the code", 719 | ) 720 | parser.add_argument( 721 | "--request-changes", 722 | type=str, 723 | required=True, 724 | help="If 'true', then request changes if there are warnings, otherwise leave a comment", 725 | ) 726 | parser.add_argument( 727 | "--suggestions-per-comment", 728 | type=int, 729 | required=True, 730 | help="Number of suggestions per comment", 731 | ) 732 | parser.add_argument( 733 | "--auto-resolve-conversations", 734 | type=str, 735 | required=True, 736 | help="If 'true', then close any discussions opened by the Action", 737 | ) 738 | 739 | args = parser.parse_args() 740 | 741 | # The GitHub API token is sensitive information, pass it through the environment 742 | github_token = os.environ.get("INPUT_GITHUB_TOKEN") 743 | 744 | github_api_url = os.environ.get("GITHUB_API_URL") 745 | github_api_timeout = 10 746 | 747 | warning_comment_prefix = ( 748 | ":warning: `Clang-Tidy` found issue(s) with the introduced code" 749 | ) 750 | single_comment_markers = { 751 | "Error": ":x:", 752 | "Warning": ":warning:", 753 | "Remark": ":speech_balloon:", 754 | "fallback": ":grey_question:", 755 | } 756 | 757 | diff_line_ranges_per_file = get_diff_line_ranges_per_file( 758 | get_pull_request_files( 759 | github_api_url, 760 | github_token, 761 | github_api_timeout, 762 | args.repository, 763 | args.pull_request_id, 764 | ) 765 | ) 766 | 767 | if os.path.isfile(args.clang_tidy_fixes): 768 | with open(args.clang_tidy_fixes, encoding="utf_8") as file: 769 | clang_tidy_fixes = yaml.safe_load(file) 770 | else: 771 | print( 772 | f"Could not find the clang-tidy fixes file '{args.clang_tidy_fixes}'," 773 | " it is assumed that it was not generated" 774 | ) 775 | clang_tidy_fixes = None 776 | 777 | if ( 778 | clang_tidy_fixes is None 779 | or "Diagnostics" not in clang_tidy_fixes 780 | or not clang_tidy_fixes["Diagnostics"] 781 | ): 782 | print("No warnings found by Clang-Tidy") 783 | dismiss_change_requests( 784 | github_api_url, 785 | github_token, 786 | github_api_timeout, 787 | args.repository, 788 | args.pull_request_id, 789 | warning_comment_prefix=warning_comment_prefix, 790 | auto_resolve_conversations=args.auto_resolve_conversations == "true", 791 | single_comment_markers=single_comment_markers, 792 | ) 793 | return 0 794 | 795 | clang_tidy_fixes["Diagnostics"] = reorder_diagnostics( 796 | clang_tidy_fixes["Diagnostics"] 797 | ) 798 | 799 | review_comments = list( 800 | generate_review_comments( 801 | clang_tidy_fixes, 802 | args.repository_root + "/", 803 | diff_line_ranges_per_file, 804 | single_comment_markers=single_comment_markers, 805 | ) 806 | ) 807 | 808 | existing_pull_request_comments = list( 809 | get_pull_request_comments( 810 | github_api_url, 811 | github_token, 812 | github_api_timeout, 813 | args.repository, 814 | args.pull_request_id, 815 | ) 816 | ) 817 | 818 | # Exclude already posted comments 819 | for comment in existing_pull_request_comments: 820 | review_comments = list( 821 | filter( 822 | lambda review_comment: not ( 823 | review_comment["path"] 824 | == comment["path"] # pylint: disable=cell-var-from-loop 825 | and review_comment["line"] 826 | == comment["line"] # pylint: disable=cell-var-from-loop 827 | and review_comment["side"] 828 | == comment["side"] # pylint: disable=cell-var-from-loop 829 | and review_comment["body"] 830 | == comment["body"] # pylint: disable=cell-var-from-loop 831 | ), 832 | review_comments, 833 | ) 834 | ) 835 | 836 | if not review_comments: 837 | print("No new warnings found by Clang-Tidy") 838 | return 0 839 | 840 | print(f"Clang-Tidy found {len(review_comments):d} new warning(s)") 841 | 842 | post_review_comments( 843 | github_api_url, 844 | github_token, 845 | github_api_timeout, 846 | args.repository, 847 | args.pull_request_id, 848 | warning_comment_prefix, 849 | "REQUEST_CHANGES" if args.request_changes == "true" else "COMMENT", 850 | review_comments, 851 | args.suggestions_per_comment, 852 | ) 853 | 854 | return 0 855 | 856 | 857 | if __name__ == "__main__": 858 | sys.exit(main()) 859 | -------------------------------------------------------------------------------- /tests/.clang-tidy: -------------------------------------------------------------------------------- 1 | Checks: '*' 2 | -------------------------------------------------------------------------------- /tests/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | project(clang_tidy_pr_comments_test LANGUAGES CXX) 2 | 3 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 4 | 5 | add_executable(clang_tidy_pr_comments_test 6 | main.cpp 7 | ) 8 | 9 | # run clang-tidy on this file 10 | add_custom_target(clang-tidy 11 | COMMAND clang-tidy -p ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp --export-fixes=${CMAKE_BINARY_DIR}/fixes.yaml 12 | ) 13 | -------------------------------------------------------------------------------- /tests/main.cpp: -------------------------------------------------------------------------------- 1 | int other() 2 | { 3 | 4 | } 5 | 6 | int main() 7 | { 8 | return other(); 9 | } 10 | --------------------------------------------------------------------------------