Using the GitHub API with Kotlin

Share on:

Recently as part of Projektor test report project, I wanted to add the capability to comment directly on pull requests with links to the test report, code coverage stats, etc. That way users could get easier and faster access to the information than searching through the CI output to find the link to the Projektor test report. And if the project has code coverage, Projektor can compare the coverage percentage from the PR to the previous mainline build run, quickly telling reviewers whether the coverage increased, decreased, or stayed the same as part of the code changes.

Projektor pull request comment

GitHub API

I explored the GitHub API to see what capabilities it may have that I could use to add comments, and found exactly what I needed in the issues and comments API.

GitHub API library

Now that I knew the API endpoints to use, I wanted to see if there was a wrapper SDK I could use to easily interact with the API from code. I'm not opposed to writing my own code to call the API endpoints, parse responses, etc. but any code I don't have to write is the best code!

GitHub has official libraries, but Projektor is in Kotlin and there wasn't an official library for the JVM. After some more searching I found this Java implementation of the GitHub API, which

Authentication

There are two main ways to authenticate and use the GitHub API:

In this situation I wanted the second option - to be able to authenticate as the Projektor app and essentially act as a bot to add comments to the PR (as shown in the username of the comment above).

Authentication with services is often tricky, and the GitHub API is no exception. The API has JWT-based auth, but it took my far longer than I care to admit to figure out that the JWT-auth isn't the full scope - it's just the first step. After authenticating as the app with JWT, I needed to also fetch another token in a second step to act on behalf of a specific installation of the app.

Let's see this in code. First, the step to authenticate with JWT and get the GitHub app instance:

1val jwtToken = jwtProvider.createJWT()
2
3val gitHub = GitHubBuilder()
4    .withJwtToken(jwtToken)
5    .withEndpoint(this.clientConfig.gitHubApiUrl)
6    .build()
7val gitHubApp = gitHub.app

Then we can use the GitHub app instance and fetch the token for a specific installation of the app:

1val appInstallation = gitHubApp.getInstallationByRepository(orgName, repoName)
2val appInstallationToken = appInstallation.createToken().create()
3val githubAuthAsInstallation = GitHubBuilder()
4    .withAppInstallationToken(appInstallationToken.token)
5    .withEndpoint(this.clientConfig.gitHubApiUrl)
6    .build()
7val repository = githubAuthAsInstallation.getRepository("$orgName/$repoName")

Now we can perform the actual operations using the GitHub API!

Creating comment

With the Java API library, performing operations to do things like add a comment to an issue/PR is pretty simple:

1fun addComment(repository: GHRepository, issueId: Int, commentText: String) {
2    val ghIssue = repository.getIssue(issueId)
3    ghIssue.comment(commentText)
4}

Or finding a comment that contains specific text and updating it:

 1fun findCommentWithText(repository: GHRepository, issueId: Int, commentText: String): GHIssueComment? {
 2    val ghIssue = repository.getIssue(issueId)
 3
 4    val comments = ghIssue.listComments().toList()
 5
 6    return comments.find { it.body.contains(commentText) }
 7}
 8
 9fun updateComment(comment: GHIssueComment, newText: String) {
10    comment.update(newText)
11}

Testing

I often use the helpful WireMock library for creating a fake version of APIs to stub out calls made during tests. WireMock is great for:

  • stubbing out API calls with specific responses
  • verifying which API calls the code made
  • simulating error scenarios - responses with HTTP error status codes, timeouts, etc.

WireMock proved helpful again to test the Projektor code that was interacting with the GitHub API.

To make the test cases themselves more readable, I often encapsulate the WireMock stubs into their own class - in this case GitHubWireMockStubber. Then in the test code I can stub out the API calls I expect the code to make during the test and verify the expected calls were indeed performed:

 1class GitHubCommentClientSpec : StringSpec() {
 2    private val wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort())
 3    private val gitHubWireMockStubber = GitHubWireMockStubber(wireMockServer)
 4
 5    override fun listeners() = listOf(WireMockTestListener(wireMockServer))
 6
 7    init {
 8        "should create comment on issue" {
 9            val gitHubApiUrl = "http://localhost:${wireMockServer.port()}/"
10            val clientConfig = GitHubClientConfig(
11                gitHubApiUrl
12            )
13            val gitHubCommentClient = GitHubCommentClient(clientConfig, jwtProvider)
14
15            val orgName = "my-org"
16            val repoName = "my-repo"
17            val issueId = 12
18
19            val commentText = "Here is my comment"
20
21            gitHubWireMockStubber.stubRepositoryRequests(orgName, repoName)
22
23            gitHubWireMockStubber.stubGetIssue(orgName, repoName, issueId)
24            gitHubWireMockStubber.stubAddComment(orgName, repoName, issueId)
25
26            val repository = gitHubCommentClient.getRepository(orgName, repoName)
27            assertNotNull(repository)
28            gitHubCommentClient.addComment(repository, issueId, commentText)
29
30            val addCommentRequestBodies = gitHubWireMockStubber.findAddCommentRequestBodies(orgName, repoName, issueId)
31            expectThat(addCommentRequestBodies).hasSize(1)
32
33            expectThat(addCommentRequestBodies[0]).contains(commentText)
34        }
35    }
36}

The stubs themselves are a bit verbose as I took the easy route to create the responses and just copied over the response bodies from GitHub's API docs, and those responses have quite a few fields. And there were only specific fields that my tests needed control over. For example, in this stub for the API call to update a comment I only needed dynamic control over the issue_id field in the response:

 1fun stubUpdateComment(orgName: String, repoName: String, issueId: Int, commentId: Int) {
 2    val responseBody = """
 3        {
 4          "id": 1,
 5          "node_id": "MDEyOklzc3VlQ29tbWVudDE=",
 6          "url": "https://api.github.com/repos/octocat/Hello-World/issues/comments/1",
 7          "html_url": "https://github.com/octocat/Hello-World/issues/1347#issuecomment-1",
 8          "body": "Me too",
 9          "user": {
10            "login": "octocat",
11            "id": 1,
12            "node_id": "MDQ6VXNlcjE=",
13            "avatar_url": "https://github.com/images/error/octocat_happy.gif",
14            "gravatar_id": "",
15            "type": "User",
16            "site_admin": false
17          },
18          "created_at": "2011-04-14T16:00:49Z",
19          "updated_at": "2011-04-14T16:00:49Z",
20          "issue_url": "https://api.github.com/repos/$orgName/$repoName/issues/$issueId",
21          "author_association": "collaborator"
22        }
23    """.trimIndent()
24
25    wireMockServer.stubFor(
26        patch(urlEqualTo("/repos/$orgName/$repoName/issues/comments/$commentId"))
27            .willReturn(
28                aResponse().withBody(responseBody)
29            )
30    )
31}

As I mentioned, WireMock also provides helpful capabilities to find requests made to the mock server, for example to grab the request bodies made to the stubbed API endpoints and verify whether the app made an API call to add or update a comment:

1fun findAddCommentRequestBodies(orgName: String, repoName: String, issueId: Int) =
2    wireMockServer.findRequestsMatching(
3        postRequestedFor(urlEqualTo("/repos/$orgName/$repoName/issues/$issueId/comments")).build()
4    ).requests.map { it.bodyAsString }
5
6fun findUpdateCommentRequestBodies(orgName: String, repoName: String, commentId: Int) =
7    wireMockServer.findRequestsMatching(
8        patchRequestedFor(urlEqualTo("/repos/$orgName/$repoName/issues/comments/$commentId")).build()
9    ).requests.map { it.bodyAsString }

GitHub app

Now that all the code was in place to use the GitHub API, I needed to add the app to GitHub so it could get access to repositories and start adding comments to pull requests.

  1. First, I created a GitHub app, including specifying the limited permissions it needs
  2. Next, I installed the app in my repos where I wanted to use it for commenting on PRs (for example, the Projektor repo itself)
  3. Finally, I created a private key for the GitHub app to include in the Projektor instance so it could authenticate with the GitHub API

Conclusion

The GitHub API provides a powerful set of capabilities to interaction with GitHub and further enhance developer productivity through automation. Authentication is a bit tricky, but hopefully with this post you can learn from my struggles and implement auth in less time than it took me! And the Java GitHub API implementation is a great way to quickly and easily use the API from a JVM app.

The full code for the Projektor GitHub API interactions is in the GitHub subproject in the Projektor codebase.