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()