All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.kiwiproject.changelog.github.GitHubSearchManager.kt Maven / Gradle / Ivy

package org.kiwiproject.changelog.github

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.common.annotations.VisibleForTesting
import org.kiwiproject.changelog.config.RepoConfig
import org.kiwiproject.changelog.extension.getInt
import org.kiwiproject.changelog.extension.getListOfMaps
import org.kiwiproject.changelog.extension.getMap
import org.kiwiproject.changelog.extension.getString
import java.time.ZonedDateTime
import java.util.concurrent.atomic.AtomicInteger

class GitHubSearchManager(
    private val repoConfig: RepoConfig,
    private val api: GitHubApi,
    private val githubPagingHelper: GitHubPagingHelper,
    private val mapper: ObjectMapper
) {

    fun findIssuesByMilestone(milestoneTitle: String): List {
        val allIssuesAndPulls = mutableListOf()
        val totalCount = AtomicInteger()

        // Implementation note:
        // Here, we make separate requests for issues and pull requests
        // to avoid getting a 422 Unprocessable Entity. See the Note
        // in the "Search issues and pull requests" section of the
        // "REST API endpoints for search" page in the documentation
        // stating that requests made with a user access token must
        // include "is:issue" or "is:pull-request" in the query (q).
        //
        // ref: https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-issues-and-pull-requests

        accumulateByType("issue", milestoneTitle, allIssuesAndPulls, totalCount)
        accumulateByType("pull-request", milestoneTitle, allIssuesAndPulls, totalCount)

        checkExpectedNumberOfIssues(allIssuesAndPulls.size, totalCount.get())

        return allIssuesAndPulls.sortedByDescending { it.createdAt }
    }

    private fun accumulateByType(
        type: String,
        milestoneTitle: String,
        allIssues: MutableList,
        totalCount: AtomicInteger
    ) {
        val firstPageUrl = createSearchUrl(milestoneTitle, type)

        githubPagingHelper.paginate(api, firstPageUrl) { page, response ->
            val responseContent = mapper.readValue>(response.content)

            if (page == 1) {
                totalCount.addAndGet(responseContent["total_count"] as Int)
            }

            val items = responseContent.getListOfMaps("items")
            val issues = items.map { item ->
                val title = item.getString("title")
                val number = item.getInt("number")
                val htmlUrl = item.getString("html_url")
                val createdAt = ZonedDateTime.parse(item.getString("created_at"))
                val labelNames = getLabels(item)

                val userMap = item.getMap("user")
                val login = userMap.getString("login")
                val userHtmlUrl = userMap.getString("html_url")
                val user = GitHubUser(login, login, userHtmlUrl)

                GitHubIssue(title, number, htmlUrl, labelNames, user, createdAt)
            }

            allIssues.addAll(issues)
        }
    }

    @VisibleForTesting
    internal fun checkExpectedNumberOfIssues(numIssues: Int, totalCount: Int) {
        check(numIssues == totalCount) { "Expected $totalCount issues but have $numIssues" }
    }

    private fun createSearchUrl(milestoneTitle: String, type: String): String =
        "${repoConfig.apiUrl}/search/issues?q=repo:${repoConfig.repository}+milestone:${milestoneTitle}+is:${type}&per_page=100&page=1"

    private fun getLabels(item: Map): List {
        val labels = item.getListOfMaps("labels")
        return labels.map { it["name"] as String }
    }

    fun findUniqueAuthorsInCommitsBetween(base: String, head: String): CommitAuthorsResult {
        val authors = mutableSetOf()
        val firstPageUrl = createCompareCommitsUrl(base, head)
        var totalCommits = 0

        githubPagingHelper.paginate(api, firstPageUrl) { _, response ->
            val responseContent = mapper.readValue>(response.content)

            val commitsOnPage = responseContent["total_commits"] as Int
            totalCommits += commitsOnPage

            val commits: List> = responseContent.getListOfMaps("commits")
            val pageOfUniqueAuthors = commits.map(::gitHubUserFromCommit).toSet()

            authors.addAll(pageOfUniqueAuthors)
        }

        return CommitAuthorsResult(authors, totalCommits)
    }

    @VisibleForTesting
    internal fun gitHubUserFromCommit(commitContainer: Map): GitHubUser {
        // response schema:
        // commits/commit/author/name
        // commits/author/login
        // commits/author/html_url

        val authorName = getAuthorName(commitContainer)

        // We have seen "author" be null in the "real world", so we have to handle it.
        // The scenario that we observed seems to be the result of commits by the same
        // user, but some commits were under a previous email. Since the user has a
        // different email in GitHub from the earlier commit, maybe this causes the
        // author to be null because it can't be linked by the commit email.
        //
        // We can only get the login and html_url from the "author" directly under each
        // object in the "commits" array. If we can't get them, then create a user
        // that only contains a name.
        return if (commitContainer["author"] != null) {
            gitHubUserFrom(authorName, commitContainer)
        } else {
            println("⚠️  WARN: Commit has null author: API: ${commitContainer["url"]} , HTML: ${commitContainer["html_url"]}")
            GitHubUser(authorName, null, null)
        }
    }

    private fun getAuthorName(commitContainer: Map): String {
        return commitContainer.getMap("commit").getMap("author").getString("name")
    }

    private fun gitHubUserFrom(authorName: String, commitContainer: Map): GitHubUser {
        val author = commitContainer.getMap("author")
        val login = author.getString("login")
        val htmlUrl = author.getString("html_url")
        return GitHubUser(authorName, login, htmlUrl)
    }

    data class CommitAuthorsResult(val authors: Set, val totalCommits: Int)

    private fun createCompareCommitsUrl(base: String, head: String): String =
        "${repoConfig.apiUrl}/repos/${repoConfig.repository}/compare/${base}...${head}?per_page=100&page=1"

    data class GitHubIssue(
        val title: String,
        val number: Int,
        val htmlUrl: String,
        val labels: List,
        val user: GitHubUser?,
        val createdAt: ZonedDateTime
    )

    data class GitHubUser(
        val name: String,
        val login: String?,
        val htmlUrl: String?
    ) {

        fun asMarkdown(): String {
            return if (htmlUrl != null) "[$name](${htmlUrl})" else name
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy