Exposing ktlint report as GitHub PR annotations

GitHub is awesome! One of the great features is PR annotations – your tool may simply not only check your code, but also post an annotation that is shown in the diff.

ktlint is awesome! No more bikesheedding about coding style. Simply run ktlint, ideally let ktlint format your code and don’t let reviewers to bother with these coding style nitpicks.

How to connect these two things together?

GitHub annotations may be managed by an API. So the (ktlint) formatter should handle adding/updating the annotations. This is a potential way and lot of tooling uses it. As a consequence, the tooling also requires a secret to allow post via the API. This is soooooo complicated.

I’m running the build in GitHub Actions, I do not want pull other dependencies/actions and so. Thankfully, for GitHub Actions there is an alternative.

Output formatted annotations

GitHub offers reusing the GitHub Action output for managing the annotations. Simply, your job has to output in a correct format and the annotations will be created/replaced. There is another way using Problem Matchers, however they are for regexp parsing and in this case parsing a JSON is a bit more safe then doing a regexp.

To let ktlint output a JSON, simply configure a new “json” reporter. I am using this Kotlin & Gradle integration and I use kts gradle scripts — my config looks like this:

kotlinter {
    reporters = arrayOf("json")
    experimentalRules = true

The ktlint result is stored in build/reports/ktlint/main-lint.json and may looks like:

      "file": "src/main/java/my/project/Main.kt",
      "errors": [
            "line": 81,
            "column": 1,
            "message": "First line in a method block should not be empty",
            "rule": "experimental:no-empty-first-line-in-method-block"

We need to parse it and reformat to the error message GitHub understands, in this case it looks:

::error file=app/src/main/java/my/project/Main.kt,line=81,col=1::First line in a method block should not be empty [experimental:no-empty-first-line-in-method-block]

The message part is your responsibility, I’ve decided to add the rule type as part of the message and wrapped it into brackets.

There is a one state-of-art CLI tool for processing JSON – jq. And guess what? It is available in the default GitHub image, so we do no need to download it, pack it as docker image, etc. Protip: use https://jqplay.org/ for testing your filter expression.

The ktlint report structure is a bit complicated – the JSON contains a file block and it contains its own list of errors. So we have to flatten it:

.[] | ({ f: .file } + ( .errors[] | { l: .line, c: .column, m: .message, r: .rule } ))

We take the file property and the add a processed errors array. This expression results into simple list of objects, the remaining part is to format it as multi-line string. jq helps with that too. The resulting CLI call looks like this

jq --raw-output '[.[] | ({ f: .file } + ( .errors[] | { l: .line, c: .column, m: .message, r: .rule } )) | "::error file=app/\(.f),line=\(.l),col=\(.c)::\(.m) [\(.r)]" ] | join("\n")'

Be aware the we have to manually add the module dir name to the path. Because I have multiple modules in my project, I call this command for each of them. Also, we do not care if the jq parsing fails when the file does not exist (|| true). Do not forget to run this if the previous task failed – i. e. the ktlint detected errors. The resulting GitHub action code is:

- name: KTLint errors to annotations
  if: ${{ failure() }}
  run: |
    jq --raw-output '[.[] | ({ f: .file } + ( .errors[] | { l: .line, c: .column, m: .message, r: .rule } )) | "::error file=app/\(.f),line=\(.l),col=\(.c)::\(.m) [\(.r)]" ] | join("\n")' app/build/reports/ktlint/main-lint.json || true
    jq --raw-output '[.[] | ({ f: .file } + ( .errors[] | { l: .line, c: .column, m: .message, r: .rule } )) | "::error file=module2/\(.f),line=\(.l),col=\(.c)::\(.m) [\(.r)]" ] | join("\n")' module2/build/reports/ktlint/main-lint.json || true
    jq --raw-output '[.[] | ({ f: .file } + ( .errors[] | { l: .line, c: .column, m: .message, r: .rule } )) | "::error file=module3/\(.f),line=\(.l),col=\(.c)::\(.m) [\(.r)]" ] | join("\n")' module3/build/reports/ktlint/main-lint.json || true

Happy ktlinting!