alpaca0984.log

Detekt — Submit a review to the GitHub

alpaca0984

Detekt is a static code analysis tool for Kotlin. It's more than just a fancy linter. It warns us of potential bugs and performance issues. Also, it suggests you follow Coroutine's best practices if you don't follow them yet.

For example, let's suppose that you write code that looks like this:

package com.example.myapplication
 
class Example {
 
    fun foo(a: String, b: Int, c: String, d: Float, e: Int, f: String) {
        println("$a $b $c $d $e $f")
    }
 
    fun plusThree(num: Int) = num + 3
}

and run detekt as:

./gradlew detekt

then you will see warnings raised by Detekt:

> Task :app:detekt FAILED
/MyApplication/app/src/main/java/com/example/myapplication/Example.kt:5:12: The function foo(a: String, b: Int, c: String, d: Float, e: Int, f: String) has too many parameters. The current threshold is set to 6. [LongParameterList]
/MyApplication/app/src/main/java/com/example/myapplication/Example.kt:16:37: This expression contains a magic number. Consider defining it to a well named constant. [MagicNumber]
 
 
Execution failed for task ':app:detekt'.
> Analysis failed with 12 weighted issues.
 
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

It's already useful enough, but don't you think it will be better if Detekt can submit reviews to GitHub instead of just dumping logs? I will show how to parse Detekt logs in Python and call GitHub API.


Parse Detekt logs

When you run Detekt, it generates logs under build/reports/detekt folder in txt, sarif and xml (checkstyle) format. The text format is not structured enough to process programmatically, sarif is very detailed but it can be too much for a simple script, so here I use checkstyle format.

The generated xml file looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="4.3">
<file name="/AndroidStudioProjects/MyApplication/app/src/main/java/com/example/myapplication/Example.kt">
	<error line="5" column="12" severity="warning" message="The function foo(a: String, b: Int, c: String, d: Float, e: Int, f: String) has too many parameters. The current threshold is set to 6." source="detekt.LongParameterList" />
	<error line="16" column="37" severity="warning" message="This expression contains a magic number. Consider defining it to a well named constant." source="detekt.MagicNumber" />
</file>
<file name="/AndroidStudioProjects/MyApplication/app/src/test/java/com/example/myapplication/ExampleUnitTest.kt">
	<error line="17" column="2" severity="warning" message="The file /AndroidStudioProjects/MyApplication/app/src/test/java/com/example/myapplication/ExampleUnitTest.kt is not ending with a new line." source="detekt.NewLineAtEndOfFile" />
	<error line="5" column="1" severity="warning" message="org.junit.Assert.* is a wildcard import. Replace it with fully qualified imports." source="detekt.WildcardImport" />
</file>
</checkstyle>

And suppose we leave a review as:

[LongParameterList] The function foo(a: String, b: Int, c: String, d: Float, e: Int, f: String) has too many parameters. The current threshold is set to 6.

When you parse a XML file in Python, you can use xml.etree.ElementTree. The script will be:

import xml.etree.ElementTree as ET
from dataclasses import dataclass
 
@dataclass
class ReviewComment:
    path: str
    body: str
    line: int
 
def parse_comments(source_dir: str) -> list[ReviewComment]:
    tree = ET.parse(f"{source_dir}/build/reports/detekt/detekt.xml")
    comments = []
    for file in tree.getroot().iter('file'):
        for error in file.iter("error"):
            # Needs to convert the absolute path to a relative one to match a location on GitHub.
            #
            # e.g. If you run this script in Bitrise, then the file path will be something like
            #   "/bitrise/app/src/main/java/com/example/myapplication/Example.kt".
            #   To submit reviews, you need to convert it to "app/src/main/java/com/example/myapplication/Example.kt".
            comment = ReviewComment(path=file.attrib["name"].replace(f"{source_dir}/", ""),
                                    body=f'[{error.attrib["source"].split(".")[1]}] {error.attrib["message"]}',
                                    line=int(error.attrib["line"]))
            comments.append(comment)
 
    return comments

Please note that, as the code comment says, when you run Detekt in a CI tool, the file path includes the working directory. To send a review to GitHub, you have to convert it to a relative path.

Send a review to GitHub

This is the official doc about creating a review for a pull request: https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request

When you send a review through curl, the command will be:

curl \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>"\
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/pulls/PULL_NUMBER/reviews \
  -d '{"commit_id":"ecdd80bb57125d7ba9641ffaa4d7d2c19d3f3091","body":"This is close to perfect! Please address the suggested inline change.","event":"REQUEST_CHANGES","comments":[{"path":"file.md","position":6,"body":"Please add more information here, and fix this typo."}]}'

Based on the API spec, we can create a simple API client in Python:

from dataclasses import asdict, dataclass
 
import requests
 
 
class GitHubApi:
    __base_url = "https://api.github.com"
 
    def __init__(self,
                 auth_token: str,
                 repo_slug: str):
        self.__api_token: str = auth_token
        self.__repo_slug: str = repo_slug
 
    def submit_pr_reviews(self,
                          pull_number: int,
                          commit_id: int,
                          comments: list[ReviewComment]) -> requests.Response:
        url = f"{self.__base_url}/repos/{self.__repo_slug}/pulls/{pull_number}/reviews"
        headers = {
            "Accept": "application/vnd.github+json",
            "Authorization": f"Bearer {self.__api_token}",
            "X-GitHub-Api-Version": "2022-11-28",
        }
        data = {
            "commit_id": commit_id,
            "body": "This is close to perfect! Please address the suggested inline change.",
            "event": "REQUEST_CHANGES",
            "comments": [asdict(c) for c in comments],
        }
 
        return requests.post(url, headers=headers, json=data)

Integrate them with a CI tool

To trigger the work, you should be able to get them from CI variables. For example, if you use Bitrise, you can get the below variables (reference).

def main():
    api = GitHubApi(auth_token=os.getenv("GITHUB_API_TOKEN"), # Set as a secret variable
                    repo_slug=os.getenv("BITRISEIO_GIT_REPOSITORY_SLUG"))
 
    comments = parse_comments(source_dir=os.getenv("BITRISE_SOURCE_DIR"))
 
    response = api.submit_pr_reviews(pull_number=os.getenv("BITRISE_PULL_REQUEST"),
                                     commit_id=os.getenv("BITRISE_GIT_COMMIT"),
                                     comments=comments)
    if not response.ok:
        print(response.content, end="\n\n")
 
if __name__ == "__main__":
    main()

That's it! From then on, your team will no longer need to review basic coding things. Instead of that, you will be able to focus on reviewing your colleagues' software design or business logic. I'm sure you will be more productive.

Here is the entire code for reference
import os
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass
 
import requests
 
 
@dataclass
class ReviewComment:
    path: str
    body: str
    line: int
 
 
class GitHubApi:
    __base_url = "https://api.github.com"
 
    def __init__(self,
                 auth_token: str,
                 repo_slug: str):
        self.__api_token: str = auth_token
        self.__repo_slug: str = repo_slug
 
    def submit_pr_reviews(self,
                          pull_number: int,
                          commit_id: int,
                          comments: list[ReviewComment]) -> requests.Response:
        url = f"{self.__base_url}/repos/{self.__repo_slug}/pulls/{pull_number}/reviews"
        headers = {
            "Accept": "application/vnd.github+json",
            "Authorization": f"Bearer {self.__api_token}",
            "X-GitHub-Api-Version": "2022-11-28",
        }
        data = {
            "commit_id": commit_id,
            "body": "This is close to perfect! Please address the suggested inline change.",
            "event": "REQUEST_CHANGES",
            "comments": [asdict(c) for c in comments],
        }
 
        return requests.post(url, headers=headers, json=data)
 
 
def parse_comments(source_dir: str) -> list[ReviewComment]:
    tree = ET.parse(f"{source_dir}/build/reports/detekt/detekt.xml")
    comments = []
    for file in tree.getroot().iter('file'):
        for error in file.iter("error"):
            comment = ReviewComment(path=file.attrib["name"].replace(f"{source_dir}/", ""),
                                    body=f'[{error.attrib["source"].split(".")[1]}] {error.attrib["message"]}',
                                    line=int(error.attrib["line"]))
            comments.append(comment)
 
    return comments
 
 
def main():
    api = GitHubApi(auth_token=os.getenv("GITHUB_API_TOKEN"), # Set as a secret variable
                    repo_slug=os.getenv("BITRISEIO_GIT_REPOSITORY_SLUG"))
 
    comments = parse_comments(source_dir=os.getenv("BITRISE_SOURCE_DIR"))
 
    response = api.submit_pr_reviews(pull_number=os.getenv("BITRISE_PULL_REQUEST"),
                                     commit_id=os.getenv("BITRISE_GIT_COMMIT"),
                                     comments=comments)
    if not response.ok:
        print(response.content, end="\n\n")
 
 
if __name__ == "__main__":
    main()