diff --git a/build.gradle.kts b/build.gradle.kts index 9d37d8f..0cfadcc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { // For build.gradle.kts (Kotlin DSL) // https://kotlinlang.org/docs/releases.html#release-details kotlin("jvm") version "1.9.22" - id("org.jmailen.kotlinter") version "3.16.0" + id("org.jmailen.kotlinter") version "4.1.1" // https://kotlinlang.org/docs/ksp-quickstart.html#use-your-own-processor-in-a-project // id("com.google.devtools.ksp") version "1.9.20-1.0.6" // Not needed yet. application diff --git a/src/main/kotlin/PrStatsApplication.kt b/src/main/kotlin/PrStatsApplication.kt index c96e061..8f5e87a 100644 --- a/src/main/kotlin/PrStatsApplication.kt +++ b/src/main/kotlin/PrStatsApplication.kt @@ -21,29 +21,31 @@ class PrStatsApplication : KoinComponent { suspend fun generatePrStats(prNumber: Int) { val (repoOwner, repoId, _, _) = appConfig.get() Log.i("■ Building stats for PR#`$prNumber`.\n") - val authorReportBuildTime = measureTimeMillis { - val statsResult: PullRequestStatsRepo.StatsResult = try { - pullRequestStatsRepo.stats( - repoOwner = repoOwner, - repoId = repoId, - prNumber = prNumber, - ) - } catch (e: Exception) { - val error = errorProcessor.getDetailedError(e) - PullRequestStatsRepo.StatsResult.Failure(error) - } + val authorReportBuildTime = + measureTimeMillis { + val statsResult: PullRequestStatsRepo.StatsResult = + try { + pullRequestStatsRepo.stats( + repoOwner = repoOwner, + repoId = repoId, + prNumber = prNumber, + ) + } catch (e: Exception) { + val error = errorProcessor.getDetailedError(e) + PullRequestStatsRepo.StatsResult.Failure(error) + } - when (statsResult) { - is PullRequestStatsRepo.StatsResult.Success -> { - formatters.forEach { - println(it.formatSinglePrStats(statsResult.stats)) + when (statsResult) { + is PullRequestStatsRepo.StatsResult.Success -> { + formatters.forEach { + println(it.formatSinglePrStats(statsResult.stats)) + } + } + is PullRequestStatsRepo.StatsResult.Failure -> { + Log.w("⚠️ Failed to generate PR stats for PR#`$prNumber`. Error Message: ${statsResult.error.message}") } - } - is PullRequestStatsRepo.StatsResult.Failure -> { - Log.w("⚠️ Failed to generate PR stats for PR#`$prNumber`. Error Message: ${statsResult.error.message}") } } - } Log.d("\nⓘ Stats generation for PR#`$prNumber` took ${authorReportBuildTime.milliseconds}") diff --git a/src/main/kotlin/StatsGeneratorApplication.kt b/src/main/kotlin/StatsGeneratorApplication.kt index e2e17be..05ef935 100644 --- a/src/main/kotlin/StatsGeneratorApplication.kt +++ b/src/main/kotlin/StatsGeneratorApplication.kt @@ -32,7 +32,6 @@ class StatsGeneratorApplication( */ private val formatters: List, ) : KoinComponent { - /** * Generates stats for user as PR author * for all PRs created by each user defined in `[LOCAL_PROPERTIES_FILE]` config file. @@ -48,13 +47,14 @@ class StatsGeneratorApplication( appConfig.get().userIds.forEach { authorId -> println(resources.string("status_building_author_pr_stats", authorId)) - val authorReportBuildTime = measureTimeMillis { - val authorStats: AuthorStats = prAuthorStatsService.authorStats(prAuthorUserId = authorId) - allAuthorStats.add(authorStats) - formatters.forEach { - println(it.formatAuthorStats(authorStats)) + val authorReportBuildTime = + measureTimeMillis { + val authorStats: AuthorStats = prAuthorStatsService.authorStats(prAuthorUserId = authorId) + allAuthorStats.add(authorStats) + formatters.forEach { + println(it.formatAuthorStats(authorStats)) + } } - } Log.d(resources.string("stats_process_time_for_user", authorId, authorReportBuildTime.milliseconds)) Log.i("\n─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n") @@ -78,13 +78,14 @@ class StatsGeneratorApplication( printCurrentAppConfigs() // For each user, generates stats for all the PRs reviewed by the user appConfig.get().userIds.forEach { usedId -> - val reviewerReportBuildTime = measureTimeMillis { - println(resources.string("status_building_reviewer_pr_stats", usedId)) - val prReviewerReviewStats = prReviewerStatsService.reviewerStats(prReviewerUserId = usedId) - formatters.forEach { - println(it.formatReviewerStats(prReviewerReviewStats)) + val reviewerReportBuildTime = + measureTimeMillis { + println(resources.string("status_building_reviewer_pr_stats", usedId)) + val prReviewerReviewStats = prReviewerStatsService.reviewerStats(prReviewerUserId = usedId) + formatters.forEach { + println(it.formatReviewerStats(prReviewerReviewStats)) + } } - } Log.d(resources.string("stats_process_time_for_user", usedId, reviewerReportBuildTime.milliseconds)) Log.i("\n─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n") diff --git a/src/main/kotlin/dev/hossain/ascii/Art.kt b/src/main/kotlin/dev/hossain/ascii/Art.kt index ef7d142..00af651 100644 --- a/src/main/kotlin/dev/hossain/ascii/Art.kt +++ b/src/main/kotlin/dev/hossain/ascii/Art.kt @@ -10,27 +10,27 @@ object Art { // Tea/Coffee art by Elissa Potier return """ - ( ) ( ) ) - ) ( ) ( ( - ( ) ( ) ) - _____________ - <_____________> ___ - | |/ _ \ - | | | | - | |_| | - ___| |\___/ - / \___________/ \ - \_____________________/ - -Enjoy a cup of ☕️ while stats are being generated. + ( ) ( ) ) + ) ( ) ( ( + ( ) ( ) ) + _____________ + <_____________> ___ + | |/ _ \ + | | | | + | |_| | + ___| |\___/ + / \___________/ \ + \_____________________/ + + Enjoy a cup of ☕️ while stats are being generated. - """.trimIndent() + """.trimIndent() } /** * Meh! just 🤷 */ - const val shrug = "¯\\_(ツ)_/¯" + const val SHRUG = "¯\\_(ツ)_/¯" /** * Warns library user about the provided review time which can't be used literaly. @@ -73,6 +73,6 @@ Enjoy a cup of ☕️ while stats are being generated. ! ! \__/ - """.trimIndent() + """.trimIndent() } } diff --git a/src/main/kotlin/dev/hossain/githubstats/BuildConfig.kt b/src/main/kotlin/dev/hossain/githubstats/BuildConfig.kt index cd050c3..082b6b3 100644 --- a/src/main/kotlin/dev/hossain/githubstats/BuildConfig.kt +++ b/src/main/kotlin/dev/hossain/githubstats/BuildConfig.kt @@ -15,7 +15,7 @@ object BuildConfig { * @see Log.WARNING * @see Log.NONE */ - var LOG_LEVEL = Log.DEBUG + var logLevel = Log.DEBUG /** * Shows HTTP requests and response on the console. diff --git a/src/main/kotlin/dev/hossain/githubstats/PrAuthorStatsService.kt b/src/main/kotlin/dev/hossain/githubstats/PrAuthorStatsService.kt index 3ba904c..9960e04 100644 --- a/src/main/kotlin/dev/hossain/githubstats/PrAuthorStatsService.kt +++ b/src/main/kotlin/dev/hossain/githubstats/PrAuthorStatsService.kt @@ -24,7 +24,6 @@ class PrAuthorStatsService constructor( private val appConfig: AppConfig, private val errorProcessor: ErrorProcessor, ) { - /** * Generates stats for reviews given by different PR reviewers for specified PR [prAuthorUserId]. * @@ -38,59 +37,63 @@ class PrAuthorStatsService constructor( * Jim -> 13 PRs reviewed for Bob; Average Review Time: 14 hours 21 min * ``` */ - suspend fun authorStats( - prAuthorUserId: String, - ): AuthorStats { + suspend fun authorStats(prAuthorUserId: String): AuthorStats { val (repoOwner, repoId, _, dateLimitAfter, dateLimitBefore) = appConfig.get() // First get all the recent PRs made by author - val allMergedPrsByAuthor: List = issueSearchPager.searchIssues( - searchQuery = SearchParams( - repoOwner = repoOwner, - repoId = repoId, - author = prAuthorUserId, - dateAfter = dateLimitAfter, - dateBefore = dateLimitBefore, - ).toQuery(), - ).filter { - // Makes sure it is a PR, not an issue - it.pull_request != null - } + val allMergedPrsByAuthor: List = + issueSearchPager.searchIssues( + searchQuery = + SearchParams( + repoOwner = repoOwner, + repoId = repoId, + author = prAuthorUserId, + dateAfter = dateLimitAfter, + dateBefore = dateLimitBefore, + ).toQuery(), + ).filter { + // Makes sure it is a PR, not an issue + it.pull_request != null + } // Provides periodic progress updates based on config val progress = PrAnalysisProgress(allMergedPrsByAuthor).also { it.start() } // For each PR by author, get the review stats on the PR - val mergedPrsStatsList: List = allMergedPrsByAuthor - .mapIndexed { index, pr -> - progress.publish(index) - - // ⏰ Slight delay to avoid GitHub API rate-limit - delay(BuildConfig.API_REQUEST_DELAY_MS) - - try { - pullRequestStatsRepo.stats( - repoOwner = repoOwner, - repoId = repoId, - prNumber = pr.number, - ) - } catch (e: Exception) { - val error = errorProcessor.getDetailedError(e) - Log.w("Error getting PR#${pr.number}. Got: ${error.message}") - StatsResult.Failure(error) + val mergedPrsStatsList: List = + allMergedPrsByAuthor + .mapIndexed { index, pr -> + progress.publish(index) + + // ⏰ Slight delay to avoid GitHub API rate-limit + delay(BuildConfig.API_REQUEST_DELAY_MS) + + try { + pullRequestStatsRepo.stats( + repoOwner = repoOwner, + repoId = repoId, + prNumber = pr.number, + ) + } catch (e: Exception) { + val error = errorProcessor.getDetailedError(e) + Log.w("Error getting PR#${pr.number}. Got: ${error.message}") + StatsResult.Failure(error) + } + } + .filterIsInstance() + .map { + it.stats } - } - .filterIsInstance() - .map { - it.stats - } progress.end() val authorPrStats = aggregatePrAuthorsPrStats(prAuthorUserId, allMergedPrsByAuthor, mergedPrsStatsList) Log.i( "ℹ️ The author '$prAuthorUserId' has created ${authorPrStats.totalPrsCreated} PRs that successfully got merged." + - "\nTotal Comments Received - Code Review: ${authorPrStats.totalCodeReviewComments}, PR Comment: ${authorPrStats.totalIssueComments}, Review+Re-review: ${authorPrStats.totalPrSubmissionComments}", + "\nTotal Comments Received - " + + "Code Review: ${authorPrStats.totalCodeReviewComments}, " + + "PR Comment: ${authorPrStats.totalIssueComments}, " + + "Review+Re-review: ${authorPrStats.totalPrSubmissionComments}", ) val authorReviewStats: List = @@ -110,15 +113,16 @@ class PrAuthorStatsService constructor( mergedPrsStatsList.filter { it.prApprovalTime.isNotEmpty() } .forEach { stats: PrStats -> stats.prApprovalTime.forEach { (userId, time) -> - val reviewStats = ReviewStats( - reviewerUserId = userId, - pullRequest = stats.pullRequest, - reviewCompletion = time, - initialResponseTime = stats.initialResponseTime[userId] ?: time, - prComments = stats.comments[userId] ?: noComments(userId), - prReadyOn = stats.prReadyOn, - prMergedOn = stats.prMergedOn, - ) + val reviewStats = + ReviewStats( + reviewerUserId = userId, + pullRequest = stats.pullRequest, + reviewCompletion = time, + initialResponseTime = stats.initialResponseTime[userId] ?: time, + prComments = stats.comments[userId] ?: noComments(userId), + prReadyOn = stats.prReadyOn, + prMergedOn = stats.prMergedOn, + ) if (userReviews.containsKey(userId)) { userReviews[userId] = userReviews[userId]!!.plus(reviewStats) } else { @@ -127,24 +131,26 @@ class PrAuthorStatsService constructor( } } - val authorReviewStats: List = userReviews.map { (reviewerUserId, reviewStats) -> - val totalReviews: Int = reviewStats.size - val averageReviewTime: Duration = reviewStats - .map { it.reviewCompletion } - .fold(Duration.ZERO, Duration::plus) - .div(totalReviews) - val totalReviewComments = reviewStats.sumOf { it.prComments.allComments } - - AuthorReviewStats( - repoId = repoId, - prAuthorId = prAuthorUsedId, - reviewerId = reviewerUserId, - average = averageReviewTime, - totalReviews = totalReviews, - totalComments = totalReviewComments, - stats = reviewStats, - ) - }.sortedByDescending { it.totalReviews } + val authorReviewStats: List = + userReviews.map { (reviewerUserId, reviewStats) -> + val totalReviews: Int = reviewStats.size + val averageReviewTime: Duration = + reviewStats + .map { it.reviewCompletion } + .fold(Duration.ZERO, Duration::plus) + .div(totalReviews) + val totalReviewComments = reviewStats.sumOf { it.prComments.allComments } + + AuthorReviewStats( + repoId = repoId, + prAuthorId = prAuthorUsedId, + reviewerId = reviewerUserId, + average = averageReviewTime, + totalReviews = totalReviews, + totalComments = totalReviewComments, + stats = reviewStats, + ) + }.sortedByDescending { it.totalReviews } return authorReviewStats } @@ -164,21 +170,24 @@ class PrAuthorStatsService constructor( ): AuthorPrStats { // Builds author's stats for all PRs made by the author val totalPrsCreated = allMergedPrsByAuthor.size - val totalIssueComments = mergedPrsStatsList.sumOf { - it.comments.entries - .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } - .sumOf { commentEntry -> commentEntry.value.issueComment } - } - val totalPrSubmissionComments = mergedPrsStatsList.sumOf { - it.comments.entries - .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } - .sumOf { commentEntry -> commentEntry.value.prReviewSubmissionComment } - } - val totalCodeReviewComments = mergedPrsStatsList.sumOf { - it.comments.entries - .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } - .sumOf { commentEntry -> commentEntry.value.codeReviewComment } - } + val totalIssueComments = + mergedPrsStatsList.sumOf { + it.comments.entries + .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } + .sumOf { commentEntry -> commentEntry.value.issueComment } + } + val totalPrSubmissionComments = + mergedPrsStatsList.sumOf { + it.comments.entries + .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } + .sumOf { commentEntry -> commentEntry.value.prReviewSubmissionComment } + } + val totalCodeReviewComments = + mergedPrsStatsList.sumOf { + it.comments.entries + .filter { prCommentEntry -> prCommentEntry.key != prAuthorUserId } + .sumOf { commentEntry -> commentEntry.value.codeReviewComment } + } return AuthorPrStats( authorUserId = prAuthorUserId, diff --git a/src/main/kotlin/dev/hossain/githubstats/PrReviewerStatsService.kt b/src/main/kotlin/dev/hossain/githubstats/PrReviewerStatsService.kt index 7b81176..aa142c8 100644 --- a/src/main/kotlin/dev/hossain/githubstats/PrReviewerStatsService.kt +++ b/src/main/kotlin/dev/hossain/githubstats/PrReviewerStatsService.kt @@ -24,73 +24,75 @@ class PrReviewerStatsService constructor( private val appConfig: AppConfig, private val errorProcessor: ErrorProcessor, ) { - suspend fun reviewerStats( - prReviewerUserId: String, - ): ReviewerReviewStats { + suspend fun reviewerStats(prReviewerUserId: String): ReviewerReviewStats { val (repoOwner, repoId, _, dateLimitAfter, dateLimitBefore) = appConfig.get() // First get all the recent PRs reviewed by the user - val reviewedClosedPrs: List = issueSearchPager.searchIssues( - searchQuery = SearchParams( - repoOwner = repoOwner, - repoId = repoId, - reviewer = prReviewerUserId, - dateAfter = dateLimitAfter, - dateBefore = dateLimitBefore, - ).toQuery(), - ).filter { - // Makes sure it is a PR, not an issue - it.pull_request != null - } + val reviewedClosedPrs: List = + issueSearchPager.searchIssues( + searchQuery = + SearchParams( + repoOwner = repoOwner, + repoId = repoId, + reviewer = prReviewerUserId, + dateAfter = dateLimitAfter, + dateBefore = dateLimitBefore, + ).toQuery(), + ).filter { + // Makes sure it is a PR, not an issue + it.pull_request != null + } // Provides periodic progress updates based on config val progress = PrAnalysisProgress(reviewedClosedPrs).also { it.start() } // For each of the PRs reviewed by the reviewer, get the stats - val prStatsListReviewedByReviewer: List = reviewedClosedPrs - .mapIndexed { index, pr -> - progress.publish(index) + val prStatsListReviewedByReviewer: List = + reviewedClosedPrs + .mapIndexed { index, pr -> + progress.publish(index) - // ⏰ Slight delay to avoid GitHub API rate-limit - delay(BuildConfig.API_REQUEST_DELAY_MS) + // ⏰ Slight delay to avoid GitHub API rate-limit + delay(BuildConfig.API_REQUEST_DELAY_MS) - try { - pullRequestStatsRepo.stats( - repoOwner = repoOwner, - repoId = repoId, - prNumber = pr.number, - ) - } catch (e: Exception) { - val error = errorProcessor.getDetailedError(e) - println("Error getting PR#${pr.number}. Got: ${error.message}") - StatsResult.Failure(error) + try { + pullRequestStatsRepo.stats( + repoOwner = repoOwner, + repoId = repoId, + prNumber = pr.number, + ) + } catch (e: Exception) { + val error = errorProcessor.getDetailedError(e) + println("Error getting PR#${pr.number}. Got: ${error.message}") + StatsResult.Failure(error) + } + } + .filterIsInstance() + .map { + it.stats } - } - .filterIsInstance() - .map { - it.stats - } progress.end() // Builds `ReviewStats` list for PRs that are reviewed by specified reviewer user-id - val reviewerPrReviewStatsList: List = prStatsListReviewedByReviewer - .filter { - // Ensures that the PR was approved by the reviewer requested in the function - it.prApprovalTime.containsKey(prReviewerUserId) - } - .map { stats -> - val prApprovalTime = stats.prApprovalTime[prReviewerUserId]!! - ReviewStats( - reviewerUserId = prReviewerUserId, - pullRequest = stats.pullRequest, - reviewCompletion = prApprovalTime, - initialResponseTime = stats.initialResponseTime[prReviewerUserId] ?: prApprovalTime, - prComments = stats.comments[prReviewerUserId] ?: noComments(prReviewerUserId), - prReadyOn = stats.prReadyOn, - prMergedOn = stats.prMergedOn, - ) - } + val reviewerPrReviewStatsList: List = + prStatsListReviewedByReviewer + .filter { + // Ensures that the PR was approved by the reviewer requested in the function + it.prApprovalTime.containsKey(prReviewerUserId) + } + .map { stats -> + val prApprovalTime = stats.prApprovalTime[prReviewerUserId]!! + ReviewStats( + reviewerUserId = prReviewerUserId, + pullRequest = stats.pullRequest, + reviewCompletion = prApprovalTime, + initialResponseTime = stats.initialResponseTime[prReviewerUserId] ?: prApprovalTime, + prComments = stats.comments[prReviewerUserId] ?: noComments(prReviewerUserId), + prReadyOn = stats.prReadyOn, + prMergedOn = stats.prMergedOn, + ) + } // Builds a hashmap for [Reviewed for UserID -> List of PR Reviewed and their Stats] // For example: @@ -117,13 +119,14 @@ class PrReviewerStatsService constructor( return ReviewerReviewStats( repoId = repoId, reviewerId = prReviewerUserId, - average = if (reviewerPrReviewStatsList.isEmpty()) { - Duration.ZERO - } else { - reviewerPrReviewStatsList.map { it.reviewCompletion } - .fold(Duration.ZERO, Duration::plus) - .div(reviewerPrReviewStatsList.size) - }, + average = + if (reviewerPrReviewStatsList.isEmpty()) { + Duration.ZERO + } else { + reviewerPrReviewStatsList.map { it.reviewCompletion } + .fold(Duration.ZERO, Duration::plus) + .div(reviewerPrReviewStatsList.size) + }, totalReviews = reviewerPrReviewStatsList.size, reviewedPrStats = reviewerPrReviewStatsList, reviewedForPrStats = reviewerReviewedFor, diff --git a/src/main/kotlin/dev/hossain/githubstats/ReviewStats.kt b/src/main/kotlin/dev/hossain/githubstats/ReviewStats.kt index eae7918..78ef32f 100644 --- a/src/main/kotlin/dev/hossain/githubstats/ReviewStats.kt +++ b/src/main/kotlin/dev/hossain/githubstats/ReviewStats.kt @@ -19,29 +19,24 @@ data class PrStats( * The PR information including PR number and URL. */ val pullRequest: PullRequest, - /** * A map containing `reviewer-id -> PR review time` in working hours (excludes weekends and after hours) */ val prApprovalTime: Map, - /** * A map containing `reviewer-id -> PR initial response time` in working hours (excludes weekends and after hours) * The initial response time indicates the time it took for reviewer to first respond to PR * by either commenting, reviewing or approving the PR. */ val initialResponseTime: Map, - /** * Map of `user-id -> total comments made` for the [pullRequest]. */ val comments: Map, - /** * Date and time when the PR was ready for review for the specific author. */ val prReadyOn: Instant, - /** * Date and time when the PR was merged successfully. */ @@ -61,28 +56,23 @@ data class ReviewStats constructor( * The PR information including PR number and URL. */ val pullRequest: PullRequest, - /** * PR review completion time in working hours (excludes weekends and after hours) */ val reviewCompletion: Duration, - /** * The initial response time indicates the time it took for reviewer to first respond to PR * by either commenting, reviewing or approving the PR. */ val initialResponseTime: Duration, - /** * Contains PR issue comment and review comment count by a specific user. */ val prComments: UserPrComment, - /** * Date and time when the PR was ready for review for the specific author. */ val prReadyOn: Instant, - /** * Date and time when the PR was merged successfully. */ @@ -133,10 +123,11 @@ data class AuthorPrStats( /** * Checks if stats is empty, then it's likely not worth showing. */ - fun isEmpty(): Boolean = totalPrsCreated == 0 && - totalIssueComments == 0 && - totalPrSubmissionComments == 0 && - totalCodeReviewComments == 0 + fun isEmpty(): Boolean = + totalPrsCreated == 0 && + totalIssueComments == 0 && + totalPrSubmissionComments == 0 && + totalCodeReviewComments == 0 } /** @@ -203,12 +194,10 @@ data class UserPrComment( * For example, on an open PR page, going at the end of the page to add comment. */ val issueComment: Int, - /** * Pull request review comments are comments on a portion of the unified diff made during a pull request review. */ val codeReviewComment: Int, - /** * Total PR review comment that either is [ReviewState.COMMENTED] or [ReviewState.CHANGE_REQUESTED] which * is used when reviewer submits a review after reviewing the PR. @@ -219,12 +208,13 @@ data class UserPrComment( /** * Provides empty comments stats for specific [userId]/ */ - fun noComments(userId: UserId) = UserPrComment( - user = userId, - issueComment = 0, - codeReviewComment = 0, - prReviewSubmissionComment = 0, - ) + fun noComments(userId: UserId) = + UserPrComment( + user = userId, + issueComment = 0, + codeReviewComment = 0, + prReviewSubmissionComment = 0, + ) } val allComments: Int = issueComment + codeReviewComment + prReviewSubmissionComment diff --git a/src/main/kotlin/dev/hossain/githubstats/di/Koin.kt b/src/main/kotlin/dev/hossain/githubstats/di/Koin.kt index d9ba95c..54b6194 100644 --- a/src/main/kotlin/dev/hossain/githubstats/di/Koin.kt +++ b/src/main/kotlin/dev/hossain/githubstats/di/Koin.kt @@ -26,84 +26,86 @@ import me.tongfei.progressbar.ProgressBarBuilder import me.tongfei.progressbar.ProgressBarStyle import org.koin.dsl.bind import org.koin.dsl.module -import java.util.* +import java.util.Locale +import java.util.ResourceBundle /** * Application module setup for dependency injection using Koin. * * See https://insert-koin.io/docs/reference/koin-core/dsl for more info. */ -val appModule = module { - // Network and local services for stat generation - single { Client.githubApiService } - single { - PullRequestStatsRepoImpl( - githubApiService = get(), - timelinesPager = get(), - userTimeZone = get(), - ) - } - factory { - IssueSearchPagerService( - githubApiService = get(), - errorProcessor = get(), - ) - } - factory { - TimelineEventsPagerService( - githubApiService = get(), - errorProcessor = get(), - ) - } - factory { - PrReviewerStatsService( - pullRequestStatsRepo = get(), - issueSearchPager = get(), - appConfig = get(), - errorProcessor = get(), - ) - } - factory { - PrAuthorStatsService( - pullRequestStatsRepo = get(), - issueSearchPager = get(), - appConfig = get(), - errorProcessor = get(), - ) - } - single { ErrorProcessor() } - single { UserTimeZone() } +val appModule = + module { + // Network and local services for stat generation + single { Client.githubApiService } + single { + PullRequestStatsRepoImpl( + githubApiService = get(), + timelinesPager = get(), + userTimeZone = get(), + ) + } + factory { + IssueSearchPagerService( + githubApiService = get(), + errorProcessor = get(), + ) + } + factory { + TimelineEventsPagerService( + githubApiService = get(), + errorProcessor = get(), + ) + } + factory { + PrReviewerStatsService( + pullRequestStatsRepo = get(), + issueSearchPager = get(), + appConfig = get(), + errorProcessor = get(), + ) + } + factory { + PrAuthorStatsService( + pullRequestStatsRepo = get(), + issueSearchPager = get(), + appConfig = get(), + errorProcessor = get(), + ) + } + single { ErrorProcessor() } + single { UserTimeZone() } - single { - StatsGeneratorApplication( - prReviewerStatsService = get(), - prAuthorStatsService = get(), - resources = get(), - appConfig = get(), - formatters = getAll(), - ) - } + single { + StatsGeneratorApplication( + prReviewerStatsService = get(), + prAuthorStatsService = get(), + resources = get(), + appConfig = get(), + formatters = getAll(), + ) + } - // Localization - single { ResourceBundle.getBundle("strings", Locale.getDefault()) } - factory { ResourcesImpl(resourceBundle = get()) } bind Resources::class + // Localization + single { ResourceBundle.getBundle("strings", Locale.getDefault()) } + factory { ResourcesImpl(resourceBundle = get()) } bind Resources::class - // Config to load local properties - factory { AppConfig(localProperties = get()) } - factory { LocalProperties() } - single { LocalProperties() } + // Config to load local properties + factory { AppConfig(localProperties = get()) } + factory { LocalProperties() } + single { LocalProperties() } - // Binds all the different stats formatters - single { PicnicTableFormatter() } bind StatsFormatter::class - single { CsvFormatter() } bind StatsFormatter::class - single { FileWriterFormatter(PicnicTableFormatter()) } bind StatsFormatter::class - single { HtmlChartFormatter() } bind StatsFormatter::class + // Binds all the different stats formatters + single { PicnicTableFormatter() } bind StatsFormatter::class + single { CsvFormatter() } bind StatsFormatter::class + single { FileWriterFormatter(PicnicTableFormatter()) } bind StatsFormatter::class + single { HtmlChartFormatter() } bind StatsFormatter::class - // Progress Bar - factory { - ProgressBarBuilder() - .setTaskName(AppConstants.PROGRESS_LABEL) - .setStyle(ProgressBarStyle.COLORFUL_UNICODE_BAR) - .setConsumer(ConsoleProgressBarConsumer(System.out)) + // Progress Bar + factory { + ProgressBarBuilder() + .setTaskName(AppConstants.PROGRESS_LABEL) + .setStyle(ProgressBarStyle.COLORFUL_UNICODE_BAR) + .setConsumer(ConsoleProgressBarConsumer(System.out)) + } } -} diff --git a/src/main/kotlin/dev/hossain/githubstats/formatter/CsvFormatter.kt b/src/main/kotlin/dev/hossain/githubstats/formatter/CsvFormatter.kt index e068eb9..41f60f8 100644 --- a/src/main/kotlin/dev/hossain/githubstats/formatter/CsvFormatter.kt +++ b/src/main/kotlin/dev/hossain/githubstats/formatter/CsvFormatter.kt @@ -28,7 +28,7 @@ class CsvFormatter : StatsFormatter, KoinComponent { */ override fun formatAuthorStats(stats: AuthorStats): String { if (stats.reviewStats.isEmpty()) { - return "⚠ ERROR: No stats to format. No CSV files for you! ${Art.shrug}" + return "⚠ ERROR: No stats to format. No CSV files for you! ${Art.SHRUG}" } // Create multiple CSV file per author for better visualization @@ -52,40 +52,41 @@ class CsvFormatter : StatsFormatter, KoinComponent { // Individual report per reviewer val fileName = FileUtil.reviewedForAuthorCsvFile(stat) - val headerItem: List = listOf( - "Reviewer", - "PR Number", - "Review time (mins)", - "Initial Response time (mins)", - "Code Review Comments", - "PR Issue Comments", - "PR Review Comments", - "Total Comments", - "PR URL", - ) + val headerItem: List = + listOf( + "Reviewer", + "PR Number", + "Review time (mins)", + "Initial Response time (mins)", + "Code Review Comments", + "PR Issue Comments", + "PR Review Comments", + "Total Comments", + "PR URL", + ) csvWriter().open(fileName) { writeRow(headerItem) stat.stats.forEach { reviewStats -> writeRow( - /* "Reviewer" */ + // "Reviewer" stat.reviewerId, - /* "PR Number" */ + // "PR Number" "PR ${reviewStats.pullRequest.number}", - /* "Review time (mins)" */ + // "Review time (mins)" "${reviewStats.reviewCompletion.toInt(DurationUnit.MINUTES)}", - /* "Initial Response time (mins)" */ + // "Initial Response time (mins)" "${reviewStats.initialResponseTime.toInt(DurationUnit.MINUTES)}", - /* "Code Review Comments" */ + // "Code Review Comments" "${reviewStats.prComments.codeReviewComment}", - /* "PR Issue Comments" */ + // "PR Issue Comments" "${reviewStats.prComments.issueComment}", - /* "PR Review Comments" */ + // "PR Review Comments" "${reviewStats.prComments.prReviewSubmissionComment}", - /* "Total Comments" */ + // "Total Comments" "${reviewStats.prComments.allComments}", - /* "PR URL" */ + // "PR URL" reviewStats.pullRequest.html_url, ) } @@ -98,21 +99,22 @@ class CsvFormatter : StatsFormatter, KoinComponent { override fun formatAllAuthorStats(aggregatedPrStats: List): String { if (aggregatedPrStats.isEmpty()) { - return "⚠ ERROR: No aggregated stats to format. No CSV files for you! ${Art.shrug}" + return "⚠ ERROR: No aggregated stats to format. No CSV files for you! ${Art.SHRUG}" } // Generate aggregated PR review stats // 1. List of users that created PR and cumulative stats about those PRs val targetFileName = FileUtil.repositoryAggregatedPrStatsByAuthorFilename() - val headerItem: List = listOf( - "Stats Date Range", - "PR Author ID (created by)", - "Total PRs Created by Author", - "Total Source Code Review Comments", - "Total PR Issue Comments (not associated with code)", - "Total PR Review Submission comments (reviewed or request change)", - ) + val headerItem: List = + listOf( + "Stats Date Range", + "PR Author ID (created by)", + "Total PRs Created by Author", + "Total Source Code Review Comments", + "Total PR Issue Comments (not associated with code)", + "Total PR Review Submission comments (reviewed or request change)", + ) csvWriter().open(targetFileName) { writeRow(headerItem) @@ -136,7 +138,7 @@ class CsvFormatter : StatsFormatter, KoinComponent { */ override fun formatReviewerStats(stats: ReviewerReviewStats): String { if (stats.reviewedPrStats.isEmpty()) { - return "⚠ ERROR: No stats to format. No CSV files for you! ${Art.shrug}" + return "⚠ ERROR: No stats to format. No CSV files for you! ${Art.SHRUG}" } // Generate two different CSV @@ -144,22 +146,24 @@ class CsvFormatter : StatsFormatter, KoinComponent { // 2. List of author reviewed for val reviewedForFile = FileUtil.prReviewedForCombinedFilename(stats.reviewerId) - val headerItem: List = listOf( - "Reviewed For different PR Authors", - "Total PRs Reviewed by ${stats.reviewerId} since ${props.getDateLimitAfter()}", - "Total Code Review Comments", - "Total PR Issue Comments", - "Total PR Review Comments", - "Total All Comments Made", - "PR# List", - ) + val headerItem: List = + listOf( + "Reviewed For different PR Authors", + "Total PRs Reviewed by ${stats.reviewerId} since ${props.getDateLimitAfter()}", + "Total Code Review Comments", + "Total PR Issue Comments", + "Total PR Review Comments", + "Total All Comments Made", + "PR# List", + ) csvWriter().open(reviewedForFile) { writeRow(headerItem) stats.reviewedForPrStats.forEach { (prAuthorId, prReviewStats) -> // Get all the comments made by the reviewer for the PR author - val userComments = prReviewStats.map { it.comments.values }.flatten() - .filter { it.user == stats.reviewerId } + val userComments = + prReviewStats.map { it.comments.values }.flatten() + .filter { it.user == stats.reviewerId } writeRow( prAuthorId, prReviewStats.size, diff --git a/src/main/kotlin/dev/hossain/githubstats/formatter/FileWriterFormatter.kt b/src/main/kotlin/dev/hossain/githubstats/formatter/FileWriterFormatter.kt index 519283e..eb1814b 100644 --- a/src/main/kotlin/dev/hossain/githubstats/formatter/FileWriterFormatter.kt +++ b/src/main/kotlin/dev/hossain/githubstats/formatter/FileWriterFormatter.kt @@ -26,7 +26,7 @@ class FileWriterFormatter constructor( override fun formatAuthorStats(stats: AuthorStats): String { if (stats.reviewStats.isEmpty()) { - return "⚠ ERROR: No author stats to format. No files to write! ${Art.shrug}" + return "⚠ ERROR: No author stats to format. No files to write! ${Art.SHRUG}" } // Create multiple CSV file per author for better visualization @@ -47,7 +47,7 @@ class FileWriterFormatter constructor( override fun formatReviewerStats(stats: ReviewerReviewStats): String { if (stats.reviewedPrStats.isEmpty() || stats.reviewedForPrStats.isEmpty()) { - return "⚠ ERROR: No reviewer stats to format. No files to write! ${Art.shrug}" + return "⚠ ERROR: No reviewer stats to format. No files to write! ${Art.SHRUG}" } val formattedStats = formatter.formatReviewerStats(stats) diff --git a/src/main/kotlin/dev/hossain/githubstats/formatter/HtmlChartFormatter.kt b/src/main/kotlin/dev/hossain/githubstats/formatter/HtmlChartFormatter.kt index 6d44511..08a0de8 100644 --- a/src/main/kotlin/dev/hossain/githubstats/formatter/HtmlChartFormatter.kt +++ b/src/main/kotlin/dev/hossain/githubstats/formatter/HtmlChartFormatter.kt @@ -37,23 +37,26 @@ class HtmlChartFormatter : StatsFormatter, KoinComponent { */ override fun formatAuthorStats(stats: AuthorStats): String { if (stats.reviewStats.isEmpty()) { - return "⚠ ERROR: No author stats to format. No charts to generate! ${Art.shrug}" + return "⚠ ERROR: No author stats to format. No charts to generate! ${Art.SHRUG}" } val prAuthorId = stats.reviewStats.first().prAuthorId // Prepares data for pie chart generation // https://developers.google.com/chart/interactive/docs/gallery/piechart - val statsJsData = stats.reviewStats.map { - "['${it.reviewerId} [${it.stats.size}]', ${it.stats.size}]" - }.joinToString() - - val chartTitle = "PR reviewer`s stats for PRs created by `$prAuthorId` on `${appConfig.get().repoId}` repository " + - "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." - val formattedPieChart = Template.pieChart( - title = chartTitle, - statsJsData = statsJsData, - ) + val statsJsData = + stats.reviewStats.map { + "['${it.reviewerId} [${it.stats.size}]', ${it.stats.size}]" + }.joinToString() + + val chartTitle = + "PR reviewer`s stats for PRs created by `$prAuthorId` on `${appConfig.get().repoId}` repository " + + "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." + val formattedPieChart = + Template.pieChart( + title = chartTitle, + statsJsData = statsJsData, + ) val pieChartFileName = FileUtil.authorPieChartHtmlFile(prAuthorId) val pieChartFile = File(pieChartFileName) @@ -61,37 +64,45 @@ class HtmlChartFormatter : StatsFormatter, KoinComponent { // Prepares data for bar chart generation // https://developers.google.com/chart/interactive/docs/gallery/barchart - val barStatsJsData: String = listOf("['Reviewer', 'Total Reviewed', 'Total Commented']") - .plus( - stats.reviewStats.map { - "['${it.reviewerId}', ${it.totalReviews}, ${it.totalComments}]" - }, - ).joinToString() - - val formattedBarChart = Template.barChart( - title = chartTitle, - chartData = barStatsJsData, - dataSize = stats.reviewStats.size * 2, // Multiplied by data columns - ) + val barStatsJsData: String = + listOf("['Reviewer', 'Total Reviewed', 'Total Commented']") + .plus( + stats.reviewStats.map { + "['${it.reviewerId}', ${it.totalReviews}, ${it.totalComments}]" + }, + ).joinToString() + + val formattedBarChart = + Template.barChart( + title = chartTitle, + chartData = barStatsJsData, + // Multiplied by data columns + dataSize = stats.reviewStats.size * 2, + ) val barChartFileName = FileUtil.authorBarChartHtmlFile(prAuthorId) val barChartFile = File(barChartFileName) barChartFile.writeText(formattedBarChart) // Prepares data for bar chart with author PR's aggregate data generation // https://developers.google.com/chart/interactive/docs/gallery/barchart - val barStatsJsDataAggregate: String = listOf("['PR Author', 'Total PRs Created', 'Total Source Code Review Comments Received', 'Total PR Issue Comments Received', 'Total PR Review+Re-review Submissions Received']") - .plus( - - "['${stats.prStats.authorUserId}', ${stats.prStats.totalPrsCreated}, ${stats.prStats.totalCodeReviewComments},${stats.prStats.totalIssueComments},${stats.prStats.totalPrSubmissionComments}]", - - ).joinToString() - - val formattedBarChartAggregate = Template.barChart( - title = "PR authors`s stats for PRs created by `$prAuthorId` on `${appConfig.get().repoId}` repository " + - "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}.", - chartData = barStatsJsDataAggregate, - dataSize = 5, // Multiplied by data columns - ) + @Suppress("ktlint:standard:max-line-length") + val barStatsJsDataAggregate: String = + listOf( + "['PR Author', 'Total PRs Created', 'Total Source Code Review Comments Received', 'Total PR Issue Comments Received', 'Total PR Review+Re-review Submissions Received']", + ) + .plus( + "['${stats.prStats.authorUserId}', ${stats.prStats.totalPrsCreated}, ${stats.prStats.totalCodeReviewComments},${stats.prStats.totalIssueComments},${stats.prStats.totalPrSubmissionComments}]", + ).joinToString() + + val formattedBarChartAggregate = + Template.barChart( + title = + "PR authors`s stats for PRs created by `$prAuthorId` on `${appConfig.get().repoId}` repository " + + "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}.", + chartData = barStatsJsDataAggregate, + // Multiplied by data columns + dataSize = 5, + ) val barChartFileNameAggregate = FileUtil.authorBarChartAggregateHtmlFile(prAuthorId) val barChartFileAggregate = File(barChartFileNameAggregate) barChartFileAggregate.writeText(formattedBarChartAggregate) @@ -105,19 +116,26 @@ class HtmlChartFormatter : StatsFormatter, KoinComponent { override fun formatAllAuthorStats(aggregatedPrStats: List): String { // Prepares data for bar chart with all author PR's aggregate data generation // https://developers.google.com/chart/interactive/docs/gallery/barchart - val barStatsJsDataAggregate: String = listOf("['PR Author', 'Total PRs Created', 'Total Source Code Review Comments Received', 'Total PR Issue Comments Received', 'Total PR Review+Re-review Submissions Received']") - .plus( - aggregatedPrStats.filter { it.isEmpty().not() }.map { - "['${it.authorUserId}', ${it.totalPrsCreated}, ${it.totalCodeReviewComments},${it.totalIssueComments},${it.totalPrSubmissionComments}]" - }, - ).joinToString() - - val formattedBarChartAggregate = Template.barChart( - title = "Aggregated PR Stats on `${appConfig.get().repoId}` repository " + - "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}.", - chartData = barStatsJsDataAggregate, - dataSize = 5, // Multiplied by data columns - ) + @Suppress("ktlint:standard:max-line-length") + val barStatsJsDataAggregate: String = + listOf( + "['PR Author', 'Total PRs Created', 'Total Source Code Review Comments Received', 'Total PR Issue Comments Received', 'Total PR Review+Re-review Submissions Received']", + ) + .plus( + aggregatedPrStats.filter { it.isEmpty().not() }.map { + "['${it.authorUserId}', ${it.totalPrsCreated}, ${it.totalCodeReviewComments},${it.totalIssueComments},${it.totalPrSubmissionComments}]" + }, + ).joinToString() + + val formattedBarChartAggregate = + Template.barChart( + title = + "Aggregated PR Stats on `${appConfig.get().repoId}` repository " + + "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}.", + chartData = barStatsJsDataAggregate, + // Multiplied by data columns + dataSize = 5, + ) val barChartFileNameAggregate = FileUtil.allAuthorBarChartAggregateHtmlFile() val barChartFileAggregate = File(barChartFileNameAggregate) barChartFileAggregate.writeText(formattedBarChartAggregate) @@ -131,75 +149,82 @@ class HtmlChartFormatter : StatsFormatter, KoinComponent { */ override fun formatReviewerStats(stats: ReviewerReviewStats): String { if (stats.reviewedPrStats.isEmpty() || stats.reviewedForPrStats.isEmpty()) { - return "⚠ ERROR: No reviewer stats to format. No charts to generate! ${Art.shrug}" + return "⚠ ERROR: No reviewer stats to format. No charts to generate! ${Art.SHRUG}" } - val headerItem: List = listOf( - "[" + - "'Reviewed For different PR Authors', " + - "'Total PRs Reviewed by ${stats.reviewerId} since ${appConfig.get().dateLimitAfter}', " + - "'Total Source Code Review Comments', " + - "'Total PR Issue Comments', " + - "'Total PR Review Comments', " + - "'Total All Comments Made'" + - "]", - ) + val headerItem: List = + listOf( + "[" + + "'Reviewed For different PR Authors', " + + "'Total PRs Reviewed by ${stats.reviewerId} since ${appConfig.get().dateLimitAfter}', " + + "'Total Source Code Review Comments', " + + "'Total PR Issue Comments', " + + "'Total PR Review Comments', " + + "'Total All Comments Made'" + + "]", + ) // Prepares data for bar chart generation // https://developers.google.com/chart/interactive/docs/gallery/barchart - val barStatsJsData: String = headerItem - .plus( - stats.reviewedForPrStats.map { (prAuthorId, prReviewStats) -> - // Get all the comments made by the reviewer for the PR author - val userComments = prReviewStats.map { it.comments.values }.flatten() - .filter { it.user == stats.reviewerId } - - "" + - "[" + - "'$prAuthorId', " + - "${prReviewStats.size}, " + - "${userComments.sumOf { it.codeReviewComment }}," + - "${userComments.sumOf { it.issueComment }}," + - "${userComments.sumOf { it.prReviewSubmissionComment }}," + - "${userComments.sumOf { it.allComments }}" + - "]" - }, - ).joinToString() - - val formattedBarChart = Template.barChart( - title = "PRs Reviewed by ${stats.reviewerId}", - chartData = barStatsJsData, - dataSize = stats.reviewedForPrStats.size * 6, // Multiplied by data columns - ) + val barStatsJsData: String = + headerItem + .plus( + stats.reviewedForPrStats.map { (prAuthorId, prReviewStats) -> + // Get all the comments made by the reviewer for the PR author + val userComments = + prReviewStats.map { it.comments.values }.flatten() + .filter { it.user == stats.reviewerId } + + "" + + "[" + + "'$prAuthorId', " + + "${prReviewStats.size}, " + + "${userComments.sumOf { it.codeReviewComment }}," + + "${userComments.sumOf { it.issueComment }}," + + "${userComments.sumOf { it.prReviewSubmissionComment }}," + + "${userComments.sumOf { it.allComments }}" + + "]" + }, + ).joinToString() + + val formattedBarChart = + Template.barChart( + title = "PRs Reviewed by ${stats.reviewerId}", + chartData = barStatsJsData, + // Multiplied by data columns + dataSize = stats.reviewedForPrStats.size * 6, + ) val reviewedForBarChartFileName = FileUtil.prReviewedForCombinedBarChartFilename(stats.reviewerId) val reviewedForBarChartFile = File(reviewedForBarChartFileName) reviewedForBarChartFile.writeText(formattedBarChart) // Prepares data for bar chart generation // https://developers.google.com/chart/interactive/docs/gallery/barchart - val userAllPrChartData: String = listOf( - "" + - "[" + - "'PR#', " + - "'Initial Response Time (mins)'," + - "'Review Time (mins)'" + - "]", - ).plus( - stats.reviewedPrStats.map { reviewStats -> + val userAllPrChartData: String = + listOf( "" + "[" + - "'PR# ${reviewStats.pullRequest.number}', " + - "${reviewStats.initialResponseTime.toInt(DurationUnit.MINUTES)}," + - "${reviewStats.reviewCompletion.toInt(DurationUnit.MINUTES)}" + - "]" - }, - ).joinToString() - - val appPrBarChart = Template.barChart( - title = "PRs Reviewed by ${stats.reviewerId}", - chartData = userAllPrChartData, - dataSize = stats.reviewedPrStats.size, - ) + "'PR#', " + + "'Initial Response Time (mins)'," + + "'Review Time (mins)'" + + "]", + ).plus( + stats.reviewedPrStats.map { reviewStats -> + "" + + "[" + + "'PR# ${reviewStats.pullRequest.number}', " + + "${reviewStats.initialResponseTime.toInt(DurationUnit.MINUTES)}," + + "${reviewStats.reviewCompletion.toInt(DurationUnit.MINUTES)}" + + "]" + }, + ).joinToString() + + val appPrBarChart = + Template.barChart( + title = "PRs Reviewed by ${stats.reviewerId}", + chartData = userAllPrChartData, + dataSize = stats.reviewedPrStats.size, + ) val allPrChartFileName = FileUtil.prReviewerReviewedPrStatsBarChartFile(stats.reviewerId) val allPrChartFile = File(allPrChartFileName) diff --git a/src/main/kotlin/dev/hossain/githubstats/formatter/PicnicTableFormatter.kt b/src/main/kotlin/dev/hossain/githubstats/formatter/PicnicTableFormatter.kt index db37a2d..2bedf3f 100644 --- a/src/main/kotlin/dev/hossain/githubstats/formatter/PicnicTableFormatter.kt +++ b/src/main/kotlin/dev/hossain/githubstats/formatter/PicnicTableFormatter.kt @@ -27,9 +27,10 @@ import kotlin.time.Duration * Uses text based table for console output using [Picnic](https://github.com/JakeWharton/picnic) */ class PicnicTableFormatter : StatsFormatter, KoinComponent { - private val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(Locale.US) - .withZone(ZoneId.systemDefault()) + private val dateFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) private val appConfig: AppConfig by inject() @@ -69,7 +70,11 @@ class PicnicTableFormatter : StatsFormatter, KoinComponent { "${userPrComment.user} made total ${userPrComment.allComments} ${userPrComment.allComments.comments()}.\n" + "Code Review Comments = ${userPrComment.codeReviewComment}, " + "Issue Comments = ${userPrComment.issueComment}" + - if (userPrComment.prReviewSubmissionComment > 0) "\nHas reviewed PR ${userPrComment.prReviewSubmissionComment} ${userPrComment.prReviewSubmissionComment.times()}." else "" + if (userPrComment.prReviewSubmissionComment > 0) { + "\nHas reviewed PR ${userPrComment.prReviewSubmissionComment} ${userPrComment.prReviewSubmissionComment.times()}." + } else { + "" + } fun formatUserDuration(userDuration: Map.Entry): String { return "$userDuration | ${userDuration.value.toWorkingHour()}" @@ -173,12 +178,13 @@ class PicnicTableFormatter : StatsFormatter, KoinComponent { */ override fun formatAuthorStats(stats: AuthorStats): String { if (stats.reviewStats.isEmpty()) { - return "⚠ ERROR: No stats to format. No ◫ fancy tables for you! ${Art.shrug}" + return "⚠ ERROR: No stats to format. No ◫ fancy tables for you! ${Art.SHRUG}" } /** * Internal function to format PR review time and review comments count. */ + @Suppress("ktlint:standard:max-line-length") fun formatPrReviewTimeAndComments(reviewStats: ReviewStats): String { return "${reviewStats.reviewCompletion} for PR#${reviewStats.pullRequest.number}" + if (reviewStats.prComments.isEmpty().not()) { @@ -210,8 +216,9 @@ class PicnicTableFormatter : StatsFormatter, KoinComponent { } row { // Export global info for the stats - val headingText = "PR reviewer's stats for PRs created by '$prAuthorId' on '$repoId' repository " + - "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." + val headingText = + "PR reviewer's stats for PRs created by '$prAuthorId' on '$repoId' repository " + + "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." val headingSeparator = "-".repeat(headingText.length) cell("$headingSeparator\n$headingText\n$headingSeparator") { columnSpan = 2 @@ -290,7 +297,7 @@ class PicnicTableFormatter : StatsFormatter, KoinComponent { */ override fun formatReviewerStats(stats: ReviewerReviewStats): String { if (stats.reviewedPrStats.isEmpty()) { - return "⚠ ERROR: No stats to format. No ◫ fancy tables for you! ${Art.shrug}" + return "⚠ ERROR: No stats to format. No ◫ fancy tables for you! ${Art.SHRUG}" } return table { cellStyle { @@ -309,8 +316,9 @@ class PicnicTableFormatter : StatsFormatter, KoinComponent { paddingTop = 2 } row { - val headingText = "Stats for all PR reviews given by '${stats.reviewerId}' on '${stats.repoId}' repository " + - "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." + val headingText = + "Stats for all PR reviews given by '${stats.reviewerId}' on '${stats.repoId}' repository " + + "between ${appConfig.get().dateLimitAfter} and ${appConfig.get().dateLimitBefore}." val headingSeparator = "-".repeat(headingText.length) cell("$headingSeparator\n$headingText\n$headingSeparator") { columnSpan = 2 diff --git a/src/main/kotlin/dev/hossain/githubstats/formatter/html/Template.kt b/src/main/kotlin/dev/hossain/githubstats/formatter/html/Template.kt index 019fa47..8e0a92d 100644 --- a/src/main/kotlin/dev/hossain/githubstats/formatter/html/Template.kt +++ b/src/main/kotlin/dev/hossain/githubstats/formatter/html/Template.kt @@ -4,13 +4,15 @@ package dev.hossain.githubstats.formatter.html * Contains templating function to generate HTML with chart data. */ object Template { - /** * Provides HTML content to display pie chart with [statsJsData] * @see getPieChartHtml * @see pieChartScript */ - fun pieChart(title: String, statsJsData: String): String { + fun pieChart( + title: String, + statsJsData: String, + ): String { return getPieChartHtml(pieChartScript(title, statsJsData)) } @@ -21,59 +23,62 @@ object Template { private fun getPieChartHtml(chartJsScript: String): String { //language=html return """ - - - - - - + + + + + + - - -
- - - """.trimIndent() + + +
+ + + """.trimIndent() } /** * https://developers.google.com/chart/interactive/docs/gallery/piechart * @see pieChart */ - private fun pieChartScript(title: String, chartRowData: String): String { + private fun pieChartScript( + title: String, + chartRowData: String, + ): String { //language=js return """ - // Load the Visualization API and the corechart package. - google.charts.load('current', {'packages':['corechart']}); - - // Set a callback to run when the Google Visualization API is loaded. - google.charts.setOnLoadCallback(drawChart); - - // Callback that creates and populates a data table, - // instantiates the pie chart, passes in the data and - // draws it. - function drawChart() { - - // Create the data table. - var data = new google.visualization.DataTable(); - data.addColumn('string', 'Topping'); - data.addColumn('number', 'Slices'); - data.addRows([ - $chartRowData - ]); - - // Set chart options - var options = {'title':'$title', - 'width':800, - 'height':600}; - - // Instantiate and draw our chart, passing in some options. - var chart = new google.visualization.PieChart(document.getElementById('chart_div')); - chart.draw(data, options); - } - """.trimIndent() + // Load the Visualization API and the corechart package. + google.charts.load('current', {'packages':['corechart']}); + + // Set a callback to run when the Google Visualization API is loaded. + google.charts.setOnLoadCallback(drawChart); + + // Callback that creates and populates a data table, + // instantiates the pie chart, passes in the data and + // draws it. + function drawChart() { + + // Create the data table. + var data = new google.visualization.DataTable(); + data.addColumn('string', 'Topping'); + data.addColumn('number', 'Slices'); + data.addRows([ + $chartRowData + ]); + + // Set chart options + var options = {'title':'$title', + 'width':800, + 'height':600}; + + // Instantiate and draw our chart, passing in some options. + var chart = new google.visualization.PieChart(document.getElementById('chart_div')); + chart.draw(data, options); + } + """.trimIndent() } /** @@ -81,7 +86,11 @@ $chartJsScript * @see barChartHtml * @see barChartJsScript */ - fun barChart(title: String, chartData: String, dataSize: Int): String { + fun barChart( + title: String, + chartData: String, + dataSize: Int, + ): String { return barChartHtml(barChartJsScript(title, chartData), dataSize) } @@ -89,51 +98,57 @@ $chartJsScript * https://developers.google.com/chart/interactive/docs/gallery/barchart * @see barChart */ - private fun barChartHtml(chartJsScript: String, dataSize: Int): String { + private fun barChartHtml( + chartJsScript: String, + dataSize: Int, + ): String { val barChartHeightPercent: Int = if (dataSize > 30) (((dataSize.div(30)) + 1) * 100) else 100 //language=html return """ - - - - - - -
- - - """.trimIndent() + + + + + + +
+ + + """.trimIndent() } /** * https://developers.google.com/chart/interactive/docs/gallery/barchart * @see barChart */ - private fun barChartJsScript(title: String, chartData: String): String { + private fun barChartJsScript( + title: String, + chartData: String, + ): String { //language=js return """ - google.charts.load('current', {'packages':['bar']}); - google.charts.setOnLoadCallback(drawChart); - - function drawChart() { - var data = google.visualization.arrayToDataTable([ - $chartData - ]); - - var options = { - chart: { - title: '$title', - }, - bars: 'horizontal' // Required for Material Bar Charts. - }; - - var chart = new google.charts.Bar(document.getElementById('barchart_material')); - - chart.draw(data, google.charts.Bar.convertOptions(options)); - } - """.trimIndent() + google.charts.load('current', {'packages':['bar']}); + google.charts.setOnLoadCallback(drawChart); + + function drawChart() { + var data = google.visualization.arrayToDataTable([ + $chartData + ]); + + var options = { + chart: { + title: '$title', + }, + bars: 'horizontal' // Required for Material Bar Charts. + }; + + var chart = new google.charts.Bar(document.getElementById('barchart_material')); + + chart.draw(data, google.charts.Bar.convertOptions(options)); + } + """.trimIndent() } } diff --git a/src/main/kotlin/dev/hossain/githubstats/io/Client.kt b/src/main/kotlin/dev/hossain/githubstats/io/Client.kt index c3be19f..661ce75 100644 --- a/src/main/kotlin/dev/hossain/githubstats/io/Client.kt +++ b/src/main/kotlin/dev/hossain/githubstats/io/Client.kt @@ -35,29 +35,31 @@ object Client { internal var baseUrl: HttpUrl = "https://api.github.com/".toHttpUrlOrNull()!! // JSON serialization using Moshi - private val moshi = Moshi.Builder() - .add( - // https://github.com/square/moshi/blob/master/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt - PolymorphicJsonAdapterFactory.of(TimelineEvent::class.java, "event") - .withSubtype(ClosedEvent::class.java, ClosedEvent.TYPE) - .withSubtype(CommentedEvent::class.java, CommentedEvent.TYPE) - .withSubtype(MergedEvent::class.java, MergedEvent.TYPE) - .withSubtype(ReadyForReviewEvent::class.java, ReadyForReviewEvent.TYPE) - .withSubtype(ReviewRequestedEvent::class.java, ReviewRequestedEvent.TYPE) - .withSubtype(ReviewedEvent::class.java, ReviewedEvent.TYPE) - .withDefaultValue(UnknownEvent()), - ) - .addLast(KotlinJsonAdapterFactory()) - .build() + private val moshi = + Moshi.Builder() + .add( + // https://github.com/square/moshi/blob/master/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt + PolymorphicJsonAdapterFactory.of(TimelineEvent::class.java, "event") + .withSubtype(ClosedEvent::class.java, ClosedEvent.TYPE) + .withSubtype(CommentedEvent::class.java, CommentedEvent.TYPE) + .withSubtype(MergedEvent::class.java, MergedEvent.TYPE) + .withSubtype(ReadyForReviewEvent::class.java, ReadyForReviewEvent.TYPE) + .withSubtype(ReviewRequestedEvent::class.java, ReviewRequestedEvent.TYPE) + .withSubtype(ReviewedEvent::class.java, ReviewedEvent.TYPE) + .withDefaultValue(UnknownEvent()), + ) + .addLast(KotlinJsonAdapterFactory()) + .build() /** * Builds OkHttp client with caching and debugging based on configuration. */ private fun okHttpClient(): OkHttpClient { - val logging = HttpLoggingInterceptor { - // Use JVM console logger using error stream. - System.err.println(it) - } + val logging = + HttpLoggingInterceptor { + // Use JVM console logger using error stream. + System.err.println(it) + } logging.level = HttpLoggingInterceptor.Level.BODY val builder = OkHttpClient.Builder() @@ -69,11 +71,12 @@ object Client { // Sets up global header for the GitHub API requests builder.addInterceptor { chain -> val originalRequest = chain.request() - val requestBuilder = originalRequest.newBuilder() - .header("User-Agent", "Kotlin-Cli") - .header("Accept", "application/vnd.github.v3+json") - // https://docs.github.com/en/rest/overview/other-authentication-methods - .header("Authorization", "Bearer ${getAccessToken()}") + val requestBuilder = + originalRequest.newBuilder() + .header("User-Agent", "Kotlin-Cli") + .header("Accept", "application/vnd.github.v3+json") + // https://docs.github.com/en/rest/overview/other-authentication-methods + .header("Authorization", "Bearer ${getAccessToken()}") chain.proceed(requestBuilder.build()) } @@ -90,11 +93,12 @@ object Client { } val githubApiService: GithubApiService by lazy { - val retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(baseUrl) + .client(httpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() retrofit.create(GithubApiService::class.java) } diff --git a/src/main/kotlin/dev/hossain/githubstats/logging/Log.kt b/src/main/kotlin/dev/hossain/githubstats/logging/Log.kt index a1465de..d05b883 100644 --- a/src/main/kotlin/dev/hossain/githubstats/logging/Log.kt +++ b/src/main/kotlin/dev/hossain/githubstats/logging/Log.kt @@ -32,15 +32,21 @@ object Log { const val NONE = 5 fun v(msg: String): Unit = log(VERBOSE, msg) + fun d(msg: String): Unit = log(DEBUG, msg) + fun i(msg: String): Unit = log(INFO, msg) + fun w(msg: String): Unit = log(WARNING, msg) /** * Logs only if set log level */ - private fun log(logLevel: Int, logMessage: String) { - if (logLevel >= BuildConfig.LOG_LEVEL) { + private fun log( + logLevel: Int, + logMessage: String, + ) { + if (logLevel >= BuildConfig.logLevel) { println(logMessage) } } diff --git a/src/main/kotlin/dev/hossain/githubstats/model/timeline/TimelineEvent.kt b/src/main/kotlin/dev/hossain/githubstats/model/timeline/TimelineEvent.kt index c809fbc..2c04bdb 100644 --- a/src/main/kotlin/dev/hossain/githubstats/model/timeline/TimelineEvent.kt +++ b/src/main/kotlin/dev/hossain/githubstats/model/timeline/TimelineEvent.kt @@ -26,9 +26,10 @@ sealed interface TimelineEvent { @Suppress("UNCHECKED_CAST") fun List.filterTo(kClass: KClass): List { // Finds the timeline event type value first - val clazzEventType: String = kClass.companionObject!!.members - .filter { it is KProperty } - .first().call(null) as String + val clazzEventType: String = + kClass.companionObject!!.members + .filter { it is KProperty } + .first().call(null) as String // Filters the timelines to only selected type, and returns typed list return this.filter { it.eventType == clazzEventType }.map { it as T } diff --git a/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepo.kt b/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepo.kt index 9dd55d0..c5e1a62 100644 --- a/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepo.kt +++ b/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepo.kt @@ -35,5 +35,9 @@ interface PullRequestStatsRepo { * } * ``` */ - suspend fun stats(repoOwner: String, repoId: String, prNumber: Int): StatsResult + suspend fun stats( + repoOwner: String, + repoId: String, + prNumber: Int, + ): StatsResult } diff --git a/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepoImpl.kt b/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepoImpl.kt index c1e0c25..6d78302 100644 --- a/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepoImpl.kt +++ b/src/main/kotlin/dev/hossain/githubstats/repository/PullRequestStatsRepoImpl.kt @@ -63,24 +63,27 @@ class PullRequestStatsRepoImpl( val prReviewers: Set = prReviewers(pullRequest.user, prTimelineEvents) // Builds a map of [Reviewer User -> Initial response time by either commenting, reviewing or approving PR] - val prInitialResponseTimeMap: Map = prInitialResponseTimeByUser( - prAvailableForReviewOn = prAvailableForReviewOn, - prReviewers = prReviewers, - prTimelineEvents = prTimelineEvents, - ) + val prInitialResponseTimeMap: Map = + prInitialResponseTimeByUser( + prAvailableForReviewOn = prAvailableForReviewOn, + prReviewers = prReviewers, + prTimelineEvents = prTimelineEvents, + ) // Builds a map of [Reviewer User -> Review Time during Working Hours] - val prReviewCompletionMap: Map = prReviewTimeByUser( - pullRequest = pullRequest, - prAvailableForReviewOn = prAvailableForReviewOn, - prReviewers = prReviewers, - prTimelineEvents = prTimelineEvents, - ) + val prReviewCompletionMap: Map = + prReviewTimeByUser( + pullRequest = pullRequest, + prAvailableForReviewOn = prAvailableForReviewOn, + prReviewers = prReviewers, + prTimelineEvents = prTimelineEvents, + ) - val commentsByUser: Map = prCommentsCountByUser( - prTimelineEvents = prTimelineEvents, - prCodeReviewComments = prCodeReviewComments, - ) + val commentsByUser: Map = + prCommentsCountByUser( + prTimelineEvents = prTimelineEvents, + prCodeReviewComments = prCodeReviewComments, + ) return StatsResult.Success( PrStats( @@ -153,15 +156,16 @@ class PullRequestStatsRepoImpl( } } - val prUserCommentsMap = (issueCommentsByUser.keys + codeReviewCommentsByUser.keys + reviewedEventByUser.keys) - .associateWith { userId -> - UserPrComment( - user = userId, - issueComment = issueCommentsByUser[userId] ?: 0, - codeReviewComment = codeReviewCommentsByUser[userId] ?: 0, - prReviewSubmissionComment = reviewedEventByUser[userId] ?: 0, - ) - } + val prUserCommentsMap = + (issueCommentsByUser.keys + codeReviewCommentsByUser.keys + reviewedEventByUser.keys) + .associateWith { userId -> + UserPrComment( + user = userId, + issueComment = issueCommentsByUser[userId] ?: 0, + codeReviewComment = codeReviewCommentsByUser[userId] ?: 0, + prReviewSubmissionComment = reviewedEventByUser[userId] ?: 0, + ) + } return prUserCommentsMap } @@ -185,12 +189,15 @@ class PullRequestStatsRepoImpl( val prReadyForReviewOn = evaluatePrReadyForReviewByUser(reviewer, prAvailableForReviewOn, prTimelineEvents) // Calculates the PR review time in working hour by the reviewer on their time-zone (if configured) - val reviewTimeInWorkingHours = DateTimeDiffer.diffWorkingHours( - startInstant = prReadyForReviewOn, - endInstant = firstReviewedEvent.submitted_at.toInstant(), - timeZoneId = userTimeZone.get(prReviewerUserId), + val reviewTimeInWorkingHours = + DateTimeDiffer.diffWorkingHours( + startInstant = prReadyForReviewOn, + endInstant = firstReviewedEvent.submitted_at.toInstant(), + timeZoneId = userTimeZone.get(prReviewerUserId), + ) + Log.d( + " -- First Responded[${firstReviewedEvent.state.name.lowercase()}] in `$reviewTimeInWorkingHours` by `$prReviewerUserId`.", ) - Log.d(" -- First Responded[${firstReviewedEvent.state.name.lowercase()}] in `$reviewTimeInWorkingHours` by `$prReviewerUserId`.") Log.v( " -- 🔍👀 Initial response event: $firstReviewedEvent. PR available on ${prAvailableForReviewOn.format()}," + "ready for reviewer on ${prReadyForReviewOn.format()} " + @@ -234,11 +241,12 @@ class PullRequestStatsRepoImpl( val openToCloseDuration = pullRequest.prMergedOn!! - prAvailableForReviewOn // Calculates the PR review time in working hour by the reviewer on their time-zone (if configured) - val reviewTimeInWorkingHours = DateTimeDiffer.diffWorkingHours( - startInstant = prReadyForReviewOn, - endInstant = prApprovedByReviewerEvent.submitted_at.toInstant(), - timeZoneId = userTimeZone.get(prReviewerUserId), - ) + val reviewTimeInWorkingHours = + DateTimeDiffer.diffWorkingHours( + startInstant = prReadyForReviewOn, + endInstant = prApprovedByReviewerEvent.submitted_at.toInstant(), + timeZoneId = userTimeZone.get(prReviewerUserId), + ) Log.i( " -- Reviewed and ✔approved in `$reviewTimeInWorkingHours` by `$prReviewerUserId`. " + "PR open->merged: $openToCloseDuration", @@ -261,11 +269,13 @@ class PullRequestStatsRepoImpl( prTimelineEvents: List, ): Instant { // Find out if user has been requested to review later - val reviewRequestedEvent: ReviewRequestedEvent? = prTimelineEvents.filterTo(ReviewRequestedEvent::class) - .find { it.requested_reviewer == reviewer } + val reviewRequestedEvent: ReviewRequestedEvent? = + prTimelineEvents.filterTo(ReviewRequestedEvent::class) + .find { it.requested_reviewer == reviewer } - val reviewedByUserEvent: ReviewedEvent? = prTimelineEvents.filterTo(ReviewedEvent::class) - .find { it.user == reviewer } + val reviewedByUserEvent: ReviewedEvent? = + prTimelineEvents.filterTo(ReviewedEvent::class) + .find { it.user == reviewer } if (reviewRequestedEvent != null && reviewedByUserEvent != null) { // This is an edge case where user as reviewed PR and then was requested to review later diff --git a/src/main/kotlin/dev/hossain/githubstats/service/IssueSearchPagerService.kt b/src/main/kotlin/dev/hossain/githubstats/service/IssueSearchPagerService.kt index ae75583..272212b 100644 --- a/src/main/kotlin/dev/hossain/githubstats/service/IssueSearchPagerService.kt +++ b/src/main/kotlin/dev/hossain/githubstats/service/IssueSearchPagerService.kt @@ -25,15 +25,16 @@ class IssueSearchPagerService constructor( var pageNumber = 1 do { - val issueSearchResult: IssueSearchResult = try { - githubApiService.searchIssues( - searchQuery = searchQuery, - page = pageNumber, - size = pageSize, - ) - } catch (exception: Exception) { - throw errorProcessor.getDetailedError(exception) - } + val issueSearchResult: IssueSearchResult = + try { + githubApiService.searchIssues( + searchQuery = searchQuery, + page = pageNumber, + size = pageSize, + ) + } catch (exception: Exception) { + throw errorProcessor.getDetailedError(exception) + } val totalItemCount: Int = issueSearchResult.total_count val maxPageNeeded: Int = ceil(totalItemCount * 1.0f / pageSize * 1.0f).toInt() diff --git a/src/main/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerService.kt b/src/main/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerService.kt index 3186ff0..1f951bc 100644 --- a/src/main/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerService.kt +++ b/src/main/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerService.kt @@ -26,16 +26,17 @@ class TimelineEventsPagerService constructor( var pageNumber = 1 do { - val timelineEvents = try { - githubApiService.timelineEvents( - owner = repoOwner, - repo = repoId, - issue = prNumber, - page = pageNumber, - ) - } catch (exception: Exception) { - throw errorProcessor.getDetailedError(exception) - } + val timelineEvents = + try { + githubApiService.timelineEvents( + owner = repoOwner, + repo = repoId, + issue = prNumber, + page = pageNumber, + ) + } catch (exception: Exception) { + throw errorProcessor.getDetailedError(exception) + } allTimelineEvents.addAll(timelineEvents) diff --git a/src/main/kotlin/dev/hossain/githubstats/util/AppConfig.kt b/src/main/kotlin/dev/hossain/githubstats/util/AppConfig.kt index 52d933b..51833ed 100644 --- a/src/main/kotlin/dev/hossain/githubstats/util/AppConfig.kt +++ b/src/main/kotlin/dev/hossain/githubstats/util/AppConfig.kt @@ -35,9 +35,10 @@ class AppConfig constructor(localProperties: LocalProperties) { "Author/user list config is required in $LOCAL_PROPERTIES_FILE" } - val users = authors.split(",") - .filter { it.isNotEmpty() } - .map { it.trim() } + val users = + authors.split(",") + .filter { it.isNotEmpty() } + .map { it.trim() } if (users.isEmpty()) { throw IllegalArgumentException( @@ -54,9 +55,10 @@ class AppConfig constructor(localProperties: LocalProperties) { * or defaults to today's date. */ private fun requiredValidDateOrDefault(dateText: String?): String { - val dateFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern("uuuu-MM-dd", Locale.US) - .withResolverStyle(ResolverStyle.STRICT) + val dateFormatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("uuuu-MM-dd", Locale.US) + .withResolverStyle(ResolverStyle.STRICT) if (dateText.isNullOrBlank()) { val todayDate = dateFormatter.format(LocalDate.now()) @@ -82,9 +84,10 @@ class AppConfig constructor(localProperties: LocalProperties) { * Validates date format defined in the [LOCAL_PROPERTIES_FILE] config. */ private fun validateDate(dateText: String) { - val dateFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern("uuuu-MM-dd", Locale.US) - .withResolverStyle(ResolverStyle.STRICT) + val dateFormatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("uuuu-MM-dd", Locale.US) + .withResolverStyle(ResolverStyle.STRICT) try { dateFormatter.parse(dateText) diff --git a/src/main/kotlin/dev/hossain/githubstats/util/ErrorProcessor.kt b/src/main/kotlin/dev/hossain/githubstats/util/ErrorProcessor.kt index 825772c..c2a53a3 100644 --- a/src/main/kotlin/dev/hossain/githubstats/util/ErrorProcessor.kt +++ b/src/main/kotlin/dev/hossain/githubstats/util/ErrorProcessor.kt @@ -55,20 +55,21 @@ class ErrorProcessor { println("Error message: $errorMessage") return if (errorMessage.contains(TOKEN_ERROR_MESSAGE)) { """ - - - ------------------------------------------------------------------------------------------------ - ⚠️ NOTE: Your token likely have expired. - You can create a new token from GitHub settings page and provide it in `[$LOCAL_PROPERTIES_FILE]`. - See: $GITHUB_TOKEN_SETTINGS_URL - ------------------------------------------------------------------------------------------------ + + + ------------------------------------------------------------------------------------------------ + ⚠️ NOTE: Your token likely have expired. + You can create a new token from GitHub settings page and provide it in `[$LOCAL_PROPERTIES_FILE]`. + See: $GITHUB_TOKEN_SETTINGS_URL + ------------------------------------------------------------------------------------------------ """.trimIndent() } else { "" } } - private val httpResponseDebugGuide: String = """ + private val httpResponseDebugGuide: String = + """ ------------------------------------------------------------------------------------------------ NOTE: You can turn on HTTP request and response debugging that contains @@ -77,5 +78,5 @@ class ErrorProcessor { You can turn on this feature by opening `[$BUILD_CONFIG]` and setting `DEBUG_HTTP_REQUESTS = true`. ------------------------------------------------------------------------------------------------ - """.trimIndent() + """.trimIndent() } diff --git a/src/main/kotlin/dev/hossain/githubstats/util/PrAnalysisProgress.kt b/src/main/kotlin/dev/hossain/githubstats/util/PrAnalysisProgress.kt index d88c1ab..c5a133c 100644 --- a/src/main/kotlin/dev/hossain/githubstats/util/PrAnalysisProgress.kt +++ b/src/main/kotlin/dev/hossain/githubstats/util/PrAnalysisProgress.kt @@ -16,9 +16,10 @@ class PrAnalysisProgress(private val prs: List) : KoinComponent { * Provides a progress bar for provided [prs]. */ fun start() { - progressBar = getKoin().get() - .setInitialMax(prs.size.toLong()) - .build() + progressBar = + getKoin().get() + .setInitialMax(prs.size.toLong()) + .build() } /** diff --git a/src/main/kotlin/dev/hossain/githubstats/util/PropertiesReader.kt b/src/main/kotlin/dev/hossain/githubstats/util/PropertiesReader.kt index 75a4bad..9616db3 100644 --- a/src/main/kotlin/dev/hossain/githubstats/util/PropertiesReader.kt +++ b/src/main/kotlin/dev/hossain/githubstats/util/PropertiesReader.kt @@ -19,7 +19,9 @@ abstract class PropertiesReader(fileName: String) { if (System.getenv("IS_GITHUB_CI") == "true") { properties.load(File(LOCAL_PROPERTIES_SAMPLE_FILE).inputStream()) } else { - throw IllegalStateException("Please create `$LOCAL_PROPERTIES_FILE` with config values. See `$LOCAL_PROPERTIES_SAMPLE_FILE`.") + throw IllegalStateException( + "Please create `$LOCAL_PROPERTIES_FILE` with config values. See `$LOCAL_PROPERTIES_SAMPLE_FILE`.", + ) } } } @@ -36,14 +38,19 @@ class LocalProperties : PropertiesReader(LOCAL_PROPERTIES_FILE) { private const val KEY_DATE_LIMIT_BEFORE = "date_limit_before" } - fun getRepoOwner(): String = requireNotNull(getProperty(KEY_REPO_OWNER)) { - "Repository owner also known as Org ID config is required in $LOCAL_PROPERTIES_FILE" - } + fun getRepoOwner(): String = + requireNotNull(getProperty(KEY_REPO_OWNER)) { + "Repository owner also known as Org ID config is required in $LOCAL_PROPERTIES_FILE" + } + + fun getRepoId(): String = + requireNotNull(getProperty(KEY_REPO_ID)) { + "Repository ID config is required in $LOCAL_PROPERTIES_FILE" + } - fun getRepoId(): String = requireNotNull(getProperty(KEY_REPO_ID)) { - "Repository ID config is required in $LOCAL_PROPERTIES_FILE" - } fun getAuthors(): String? = getProperty(KEY_AUTHOR_IDS) + fun getDateLimitAfter(): String? = getProperty(KEY_DATE_LIMIT_AFTER) + fun getDateLimitBefore(): String? = getProperty(KEY_DATE_LIMIT_BEFORE) } diff --git a/src/main/kotlin/dev/hossain/i18n/Resources.kt b/src/main/kotlin/dev/hossain/i18n/Resources.kt index 110dba3..97ffda5 100644 --- a/src/main/kotlin/dev/hossain/i18n/Resources.kt +++ b/src/main/kotlin/dev/hossain/i18n/Resources.kt @@ -8,5 +8,8 @@ interface Resources { * Provides localized string for given key formatted with [args] if provided. * @see String.format */ - fun string(key: String, vararg args: Any?): String + fun string( + key: String, + vararg args: Any?, + ): String } diff --git a/src/main/kotlin/dev/hossain/i18n/ResourcesImpl.kt b/src/main/kotlin/dev/hossain/i18n/ResourcesImpl.kt index b437bf1..daca81d 100644 --- a/src/main/kotlin/dev/hossain/i18n/ResourcesImpl.kt +++ b/src/main/kotlin/dev/hossain/i18n/ResourcesImpl.kt @@ -1,6 +1,6 @@ package dev.hossain.i18n -import java.util.* +import java.util.ResourceBundle /** * Localized resource provider. @@ -9,7 +9,10 @@ import java.util.* class ResourcesImpl constructor( private val resourceBundle: ResourceBundle, ) : Resources { - override fun string(key: String, vararg args: Any?): String { + override fun string( + key: String, + vararg args: Any?, + ): String { return String.format(resourceBundle.getString(key), *args) } } diff --git a/src/main/kotlin/dev/hossain/time/TemporalsExtension.kt b/src/main/kotlin/dev/hossain/time/TemporalsExtension.kt index ea4ba30..e585960 100644 --- a/src/main/kotlin/dev/hossain/time/TemporalsExtension.kt +++ b/src/main/kotlin/dev/hossain/time/TemporalsExtension.kt @@ -92,6 +92,7 @@ object TemporalsExtension { } // ----------------------------------------------------------------------- + /** * Enum implementing the adjusters. */ @@ -183,8 +184,9 @@ object TemporalsExtension { PREV_WORKING_HOUR { override fun adjustInto(temporal: Temporal): Temporal { return when (val hour = temporal[ChronoField.HOUR_OF_DAY]) { - in 9..17 -> temporal.minus((hour - 9).toLong(), ChronoUnit.HOURS) - .minus(temporal[ChronoField.MINUTE_OF_HOUR].toLong(), ChronoUnit.MINUTES) + in 9..17 -> + temporal.minus((hour - 9).toLong(), ChronoUnit.HOURS) + .minus(temporal[ChronoField.MINUTE_OF_HOUR].toLong(), ChronoUnit.MINUTES) in 0..8 -> { // Set time to end of the day on previous day temporal.minus((hour + 7).toLong(), ChronoUnit.HOURS) diff --git a/src/main/kotlin/dev/hossain/time/UserTimeZone.kt b/src/main/kotlin/dev/hossain/time/UserTimeZone.kt index c82b5a8..d2fdc7d 100644 --- a/src/main/kotlin/dev/hossain/time/UserTimeZone.kt +++ b/src/main/kotlin/dev/hossain/time/UserTimeZone.kt @@ -34,10 +34,11 @@ class UserTimeZone { * * @see UserCity */ - private val userZones: Map = mapOf( - "user-id-1" to city(TORONTO), - "user-id-2" to city(VANCOUVER), - ) + private val userZones: Map = + mapOf( + "user-id-1" to city(TORONTO), + "user-id-2" to city(VANCOUVER), + ) /** * Provides user's time zone id, if configured in [userZones], otherwise [defaultZoneId] is used. diff --git a/src/main/kotlin/dev/hossain/time/Zone.kt b/src/main/kotlin/dev/hossain/time/Zone.kt index 97e7aa3..4eb29b8 100644 --- a/src/main/kotlin/dev/hossain/time/Zone.kt +++ b/src/main/kotlin/dev/hossain/time/Zone.kt @@ -19,16 +19,17 @@ object Zone { * Convenient map to get [ZoneId] for some known locations. * REF: https://mkyong.com/java8/java-display-all-zoneid-and-its-utc-offset/ */ - val cities = mapOf( - ATLANTA to ZoneId.of("America/New_York"), - CHICAGO to ZoneId.of("America/Chicago"), - DETROIT to ZoneId.of("America/Detroit"), - NEW_YORK to ZoneId.of("America/New_York"), - PHOENIX to ZoneId.of("America/Phoenix"), - SAN_FRANCISCO to ZoneId.of("America/Los_Angeles"), - TORONTO to ZoneId.of("America/Toronto"), - VANCOUVER to ZoneId.of("America/Vancouver"), - ) + val cities = + mapOf( + ATLANTA to ZoneId.of("America/New_York"), + CHICAGO to ZoneId.of("America/Chicago"), + DETROIT to ZoneId.of("America/Detroit"), + NEW_YORK to ZoneId.of("America/New_York"), + PHOENIX to ZoneId.of("America/Phoenix"), + SAN_FRANCISCO to ZoneId.of("America/Los_Angeles"), + TORONTO to ZoneId.of("America/Toronto"), + VANCOUVER to ZoneId.of("America/Vancouver"), + ) /** * Provides [ZoneId] based on known [cityName] defined in [UserCity]. diff --git a/src/main/kotlin/dev/hossain/time/ZonedDateTimeExtension.kt b/src/main/kotlin/dev/hossain/time/ZonedDateTimeExtension.kt index c92630f..cc213c7 100644 --- a/src/main/kotlin/dev/hossain/time/ZonedDateTimeExtension.kt +++ b/src/main/kotlin/dev/hossain/time/ZonedDateTimeExtension.kt @@ -176,9 +176,10 @@ internal fun ZonedDateTime.isAfterWorkingHour(): Boolean { * The format used is the full localized date-time format for the US locale. */ internal fun ZonedDateTime.format(): String { - val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) - .withLocale(Locale.US) - .withZone(zone) + val formatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) + .withLocale(Locale.US) + .withZone(zone) return this.format(formatter) } diff --git a/src/test/kotlin/StatsGeneratorApplicationTest.kt b/src/test/kotlin/StatsGeneratorApplicationTest.kt index 464a91e..92cfa11 100644 --- a/src/test/kotlin/StatsGeneratorApplicationTest.kt +++ b/src/test/kotlin/StatsGeneratorApplicationTest.kt @@ -20,7 +20,6 @@ import kotlin.time.Duration * Unit test for [StatsGeneratorApplication]. */ class StatsGeneratorApplicationTest { - private val prReviewerStatsService: PrReviewerStatsService = mockk(relaxed = true) private val prAuthorStatsService: PrAuthorStatsService = mockk(relaxed = true) private val resources: Resources = mockk(relaxed = true) @@ -31,64 +30,69 @@ class StatsGeneratorApplicationTest { @BeforeEach fun setUp() { - statsGeneratorApplication = StatsGeneratorApplication( - prReviewerStatsService, - prAuthorStatsService, - resources, - appConfig, - formatters, - ) + statsGeneratorApplication = + StatsGeneratorApplication( + prReviewerStatsService, + prAuthorStatsService, + resources, + appConfig, + formatters, + ) } @Test - fun `generateAuthorStats should call authorStats and formatAuthorStats for each user`() = runBlocking { - val userIds = listOf("user1", "user2") - val authorStats = AuthorStats( - AuthorPrStats( - authorUserId = "user1", - totalPrsCreated = 0, - totalIssueComments = 0, - totalPrSubmissionComments = 0, - totalCodeReviewComments = 0, - ), - emptyList(), - ) + fun `generateAuthorStats should call authorStats and formatAuthorStats for each user`() = + runBlocking { + val userIds = listOf("user1", "user2") + val authorStats = + AuthorStats( + AuthorPrStats( + authorUserId = "user1", + totalPrsCreated = 0, + totalIssueComments = 0, + totalPrSubmissionComments = 0, + totalCodeReviewComments = 0, + ), + emptyList(), + ) - coEvery { appConfig.get().userIds } returns userIds - coEvery { prAuthorStatsService.authorStats(any()) } returns authorStats + coEvery { appConfig.get().userIds } returns userIds + coEvery { prAuthorStatsService.authorStats(any()) } returns authorStats - statsGeneratorApplication.generateAuthorStats() + statsGeneratorApplication.generateAuthorStats() - userIds.forEach { userId -> - coVerify { prAuthorStatsService.authorStats(userId) } - formatters.forEach { formatter -> - verify { formatter.formatAuthorStats(authorStats) } + userIds.forEach { userId -> + coVerify { prAuthorStatsService.authorStats(userId) } + formatters.forEach { formatter -> + verify { formatter.formatAuthorStats(authorStats) } + } } } - } @Test - fun `generateReviewerStats should call reviewerStats and formatReviewerStats for each user`() = runBlocking { - val userIds = listOf("user1", "user2") - val reviewerStats = ReviewerReviewStats( - repoId = "repo_id", - reviewerId = "user1", - average = Duration.ZERO, - totalReviews = 1, - reviewedPrStats = emptyList(), - reviewedForPrStats = emptyMap(), - ) + fun `generateReviewerStats should call reviewerStats and formatReviewerStats for each user`() = + runBlocking { + val userIds = listOf("user1", "user2") + val reviewerStats = + ReviewerReviewStats( + repoId = "repo_id", + reviewerId = "user1", + average = Duration.ZERO, + totalReviews = 1, + reviewedPrStats = emptyList(), + reviewedForPrStats = emptyMap(), + ) - coEvery { appConfig.get().userIds } returns userIds - coEvery { prReviewerStatsService.reviewerStats(any()) } returns reviewerStats + coEvery { appConfig.get().userIds } returns userIds + coEvery { prReviewerStatsService.reviewerStats(any()) } returns reviewerStats - statsGeneratorApplication.generateReviewerStats() + statsGeneratorApplication.generateReviewerStats() - userIds.forEach { userId -> - coVerify { prReviewerStatsService.reviewerStats(userId) } - formatters.forEach { formatter -> - verify { formatter.formatReviewerStats(reviewerStats) } + userIds.forEach { userId -> + coVerify { prReviewerStatsService.reviewerStats(userId) } + formatters.forEach { formatter -> + verify { formatter.formatReviewerStats(reviewerStats) } + } } } - } } diff --git a/src/test/kotlin/dev/hossain/githubstats/PullRequestStatsRepoTest.kt b/src/test/kotlin/dev/hossain/githubstats/PullRequestStatsRepoTest.kt index 433d513..6b52e9c 100644 --- a/src/test/kotlin/dev/hossain/githubstats/PullRequestStatsRepoTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/PullRequestStatsRepoTest.kt @@ -7,7 +7,6 @@ import dev.hossain.githubstats.repository.PullRequestStatsRepoImpl import dev.hossain.githubstats.service.TimelineEventsPagerService import dev.hossain.githubstats.util.ErrorProcessor import dev.hossain.time.UserTimeZone -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -19,7 +18,7 @@ import kotlin.time.Duration /** * Tests Pull Request stats calculator [PullRequestStatsRepoImpl]. */ -@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("ktlint:standard:max-line-length") internal class PullRequestStatsRepoTest { // https://github.com/square/okhttp/tree/master/mockwebserver private lateinit var mockWebServer: MockWebServer @@ -36,14 +35,16 @@ internal class PullRequestStatsRepoTest { mockWebServer.start(60000) Client.baseUrl = mockWebServer.url("/") - pullRequestStatsRepo = PullRequestStatsRepoImpl( - githubApiService = Client.githubApiService, - timelinesPager = TimelineEventsPagerService( + pullRequestStatsRepo = + PullRequestStatsRepoImpl( githubApiService = Client.githubApiService, - errorProcessor = ErrorProcessor(), - ), - userTimeZone = UserTimeZone(), - ) + timelinesPager = + TimelineEventsPagerService( + githubApiService = Client.githubApiService, + errorProcessor = ErrorProcessor(), + ), + userTimeZone = UserTimeZone(), + ) } @AfterEach @@ -52,263 +53,281 @@ internal class PullRequestStatsRepoTest { } @Test - fun `stats - given pull request not merged - provides failure status`() = runTest { - // Uses data from https://github.com/jquery/jquery/pull/5046 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-48543-not-merged.json"))) + fun `stats - given pull request not merged - provides failure status`() = + runTest { + // Uses data from https://github.com/jquery/jquery/pull/5046 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-48543-not-merged.json"))) - val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(calculateStats).isInstanceOf(StatsResult.Failure::class.java) - } + assertThat(calculateStats).isInstanceOf(StatsResult.Failure::class.java) + } @Test - fun `stats - given pr review - calculates time taken to provide review`() = runTest { - // Uses data from https://github.com/jquery/jquery/pull/5046 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-jquery-5046.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jquery-5046.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given pr review - calculates time taken to provide review`() = + runTest { + // Uses data from https://github.com/jquery/jquery/pull/5046 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-jquery-5046.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jquery-5046.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) - } + assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) + } @Test - fun `stats - given many pr comments and review - calculates only the approval time`() = runTest { - // Uses data from https://github.com/opensearch-project/OpenSearch/pull/4515 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-opensearch-4515.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-opensearch-4515.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given many pr comments and review - calculates only the approval time`() = + runTest { + // Uses data from https://github.com/opensearch-project/OpenSearch/pull/4515 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-opensearch-4515.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-opensearch-4515.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - } + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + } @Test - fun `stats - given reviewer was added later - provides time after reviewer was added`() = runTest { - // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47594 - // User `naomi-lgbt` was added later in the PR. - // This is also interesting PR because changes was requested (See issue #51) + fun `stats - given reviewer was added later - provides time after reviewer was added`() = + runTest { + // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47594 + // User `naomi-lgbt` was added later in the PR. + // This is also interesting PR because changes was requested (See issue #51) - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47594.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47594.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47594.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47594.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - val result = statsResult as StatsResult.Success - val reviewTime = result.stats.prApprovalTime["naomi-lgbt"] - assertThat(reviewTime).isLessThan(Duration.parse("13h")) - } + val result = statsResult as StatsResult.Success + val reviewTime = result.stats.prApprovalTime["naomi-lgbt"] + assertThat(reviewTime).isLessThan(Duration.parse("13h")) + } @Test - fun `stats - given multiple reviews and dismissed reviews - provides stats accordingly`() = runTest { - // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 - // A lot of review comments, 2 people approved after dismissal - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given multiple reviews and dismissed reviews - provides stats accordingly`() = + runTest { + // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 + // A lot of review comments, 2 people approved after dismissal + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - val result = statsResult as StatsResult.Success - val reviewTime = result.stats.prApprovalTime - assertThat(reviewTime).hasSize(2) - } + val result = statsResult as StatsResult.Success + val reviewTime = result.stats.prApprovalTime + assertThat(reviewTime).hasSize(2) + } @Test - fun `stats - pr creator commented on PR - does not contain review metrics for pr creator`() = runTest { - // Uses data from https://github.com/square/retrofit/pull/3267 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-retrofit-3267.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-retrofit-3267.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - pr creator commented on PR - does not contain review metrics for pr creator`() = + runTest { + // Uses data from https://github.com/square/retrofit/pull/3267 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-retrofit-3267.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-retrofit-3267.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - val result = statsResult as StatsResult.Success - assertThat(result.stats.prApprovalTime) - .doesNotContainKey("JakeWharton") - } + val result = statsResult as StatsResult.Success + assertThat(result.stats.prApprovalTime) + .doesNotContainKey("JakeWharton") + } @Test - fun `stats - given merged with no reviewer - provides no related metrics`() = runTest { - // Uses data from https://github.com/hossain-khan/github-stats/pull/27 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-githubstats-27.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-githubstats-27.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given merged with no reviewer - provides no related metrics`() = + runTest { + // Uses data from https://github.com/hossain-khan/github-stats/pull/27 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-githubstats-27.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-githubstats-27.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) - } + assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) + } @Test - fun `stats - given pr was draft - provides time taken after pr was ready for review`() = runTest { - } + fun `stats - given pr was draft - provides time taken after pr was ready for review`() = + runTest { + } @Test - fun `stats - given no assigned reviewer added - provides metrics based on approval event`() = runTest { - // Uses data from https://github.com/square/retrofit/pull/3114 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-retrofit-3114.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-retrofit-3114.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given no assigned reviewer added - provides metrics based on approval event`() = + runTest { + // Uses data from https://github.com/square/retrofit/pull/3114 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-retrofit-3114.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-retrofit-3114.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val calculateStats = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) - } + assertThat(calculateStats).isInstanceOf(StatsResult.Success::class.java) + } @Test - fun `stats - given pr reviewed in time - provides correct review time`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47511.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-45711.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - val result = statsResult as StatsResult.Success - assertThat(result.stats.prApprovalTime).hasSize(2) - assertThat(result.stats.prApprovalTime["DanielRosa74"]).isEqualTo(Duration.parse("7m")) - } + fun `stats - given pr reviewed in time - provides correct review time`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47511.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-45711.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val result = statsResult as StatsResult.Success + assertThat(result.stats.prApprovalTime).hasSize(2) + assertThat(result.stats.prApprovalTime["DanielRosa74"]).isEqualTo(Duration.parse("7m")) + } @Test - fun `stats - given pr has multiple issue comments - provides comment count for each user`() = runTest { - // Lots of comments by different users - // Uses data from https://github.com/square/okhttp/pull/3873 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - val stats = (statsResult as StatsResult.Success).stats - assertThat(stats.comments).hasSize(5) - assertThat(stats.comments.keys) - .containsExactlyElementsIn(setOf("swankjesse", "jjshanks", "yschimke", "mjpitz", "JakeWharton")) - assertThat(stats.comments["swankjesse"]!!.issueComment).isEqualTo(3) - } + fun `stats - given pr has multiple issue comments - provides comment count for each user`() = + runTest { + // Lots of comments by different users + // Uses data from https://github.com/square/okhttp/pull/3873 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val stats = (statsResult as StatsResult.Success).stats + assertThat(stats.comments).hasSize(5) + assertThat(stats.comments.keys) + .containsExactlyElementsIn(setOf("swankjesse", "jjshanks", "yschimke", "mjpitz", "JakeWharton")) + assertThat(stats.comments["swankjesse"]!!.issueComment).isEqualTo(3) + } @Test - fun `stats - given pr has multiple issue and review comments - provides all comment count for each user`() = runTest { - // Lots of comments by different users - // Uses data from https://github.com/square/okhttp/pull/3873 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-num-comments-okhttp-3873.json"))) - - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - val stats = (statsResult as StatsResult.Success).stats - assertThat(stats.comments).hasSize(5) - assertThat(stats.comments.keys) - .containsExactlyElementsIn(setOf("swankjesse", "jjshanks", "yschimke", "mjpitz", "JakeWharton")) - - val userPrComment: UserPrComment = stats.comments["yschimke"]!! - - // yschimke made 9 PR comment, 14 pr review and 21 code review comment. Total: 44 comments. - assertThat(userPrComment.issueComment).isEqualTo(9) - assertThat(userPrComment.codeReviewComment).isEqualTo(21) - assertThat(userPrComment.prReviewSubmissionComment).isEqualTo(14) - assertThat(userPrComment.allComments).isEqualTo(44) - } + fun `stats - given pr has multiple issue and review comments - provides all comment count for each user`() = + runTest { + // Lots of comments by different users + // Uses data from https://github.com/square/okhttp/pull/3873 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-num-comments-okhttp-3873.json"))) + + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val stats = (statsResult as StatsResult.Success).stats + assertThat(stats.comments).hasSize(5) + assertThat(stats.comments.keys) + .containsExactlyElementsIn(setOf("swankjesse", "jjshanks", "yschimke", "mjpitz", "JakeWharton")) + + val userPrComment: UserPrComment = stats.comments["yschimke"]!! + + // yschimke made 9 PR comment, 14 pr review and 21 code review comment. Total: 44 comments. + assertThat(userPrComment.issueComment).isEqualTo(9) + assertThat(userPrComment.codeReviewComment).isEqualTo(21) + assertThat(userPrComment.prReviewSubmissionComment).isEqualTo(14) + assertThat(userPrComment.allComments).isEqualTo(44) + } @Test - fun `stats - given multiple reviewers - provides stats for initial response time accordingly`() = runTest { - // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 - // A lot of review comments by 5 people, and 2 people approved after dismissal - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - - val result = statsResult as StatsResult.Success - val initialResponseTime = result.stats.initialResponseTime - assertThat(initialResponseTime).hasSize(5) - - assertThat(initialResponseTime["ShaunSHamilton"]).isEqualTo(Duration.parse("0s")) // Before work-hour - assertThat(initialResponseTime["SaintPeter"]).isEqualTo(Duration.parse("2h 24m")) - assertThat(initialResponseTime["jeremylt"]).isEqualTo(Duration.parse("2h 49m")) - assertThat(initialResponseTime["naomi-lgbt"]).isEqualTo(Duration.parse("8h")) - assertThat(initialResponseTime["Sboonny"]).isEqualTo(Duration.parse("1d")) - } + fun `stats - given multiple reviewers - provides stats for initial response time accordingly`() = + runTest { + // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 + // A lot of review comments by 5 people, and 2 people approved after dismissal + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + + val result = statsResult as StatsResult.Success + val initialResponseTime = result.stats.initialResponseTime + assertThat(initialResponseTime).hasSize(5) + + assertThat(initialResponseTime["ShaunSHamilton"]).isEqualTo(Duration.parse("0s")) // Before work-hour + assertThat(initialResponseTime["SaintPeter"]).isEqualTo(Duration.parse("2h 24m")) + assertThat(initialResponseTime["jeremylt"]).isEqualTo(Duration.parse("2h 49m")) + assertThat(initialResponseTime["naomi-lgbt"]).isEqualTo(Duration.parse("8h")) + assertThat(initialResponseTime["Sboonny"]).isEqualTo(Duration.parse("1d")) + } @Test - fun `stats - given pr was reviewed earlier and then later approved by same reviewer - provides stats for initial response and review time accordingly`() = runTest { - // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 - // The reviewer `RandellDawson` has requested change first and later approved - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-48266.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-48266.json"))) - mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments + fun `stats - given pr was reviewed earlier and then later approved by same reviewer - provides stats for initial response and review time accordingly`() = + runTest { + // Uses data from https://github.com/freeCodeCamp/freeCodeCamp/pull/47550 + // The reviewer `RandellDawson` has requested change first and later approved + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-48266.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-48266.json"))) + mockWebServer.enqueue(MockResponse().setBody("[]")) // PR Review comments - val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) + val statsResult = pullRequestStatsRepo.stats(REPO_OWNER, REPO_ID, 123) - assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) + assertThat(statsResult).isInstanceOf(StatsResult.Success::class.java) - val result = statsResult as StatsResult.Success - val initialResponseTime = result.stats.initialResponseTime - val prApprovalTime = result.stats.prApprovalTime - assertThat(initialResponseTime).hasSize(3) - assertThat(prApprovalTime).hasSize(3) + val result = statsResult as StatsResult.Success + val initialResponseTime = result.stats.initialResponseTime + val prApprovalTime = result.stats.prApprovalTime + assertThat(initialResponseTime).hasSize(3) + assertThat(prApprovalTime).hasSize(3) - assertThat(initialResponseTime["RandellDawson"]).isEqualTo(Duration.parse("12h")) - assertThat(prApprovalTime["RandellDawson"]).isEqualTo(Duration.parse("1d 10h 23m")) - } + assertThat(initialResponseTime["RandellDawson"]).isEqualTo(Duration.parse("12h")) + assertThat(prApprovalTime["RandellDawson"]).isEqualTo(Duration.parse("1d 10h 23m")) + } @Test - fun `prReviewers - given pr has multiple reviewers - calculates all reviewer user-ids`() = runTest { - // Uses data from https://github.com/opensearch-project/OpenSearch/pull/4515 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) + fun `prReviewers - given pr has multiple reviewers - calculates all reviewer user-ids`() = + runTest { + // Uses data from https://github.com/opensearch-project/OpenSearch/pull/4515 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-freeCodeCamp-47550.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-freeCodeCamp-47550.json"))) - val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) + val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) - assertThat(prReviewers).hasSize(5) - assertThat(prReviewers).doesNotContain(pullRequest.user) - } + assertThat(prReviewers).hasSize(5) + assertThat(prReviewers).doesNotContain(pullRequest.user) + } @Test - fun `prReviewers - given PR did not have assigned reviewers but reviewers self reviewed - provides those reviewers user-ids`() = runTest { - // Uses data from https://github.com/square/okhttp/pull/3873 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `prReviewers - given PR did not have assigned reviewers but reviewers self reviewed - provides those reviewers user-ids`() = + runTest { + // Uses data from https://github.com/square/okhttp/pull/3873 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-3873.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) + val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) - assertThat(prReviewers).doesNotContain(pullRequest.user) - assertThat(prReviewers).hasSize(2) - assertThat(prReviewers.map { it.login }).containsExactly("yschimke", "swankjesse") - } + assertThat(prReviewers).doesNotContain(pullRequest.user) + assertThat(prReviewers).hasSize(2) + assertThat(prReviewers.map { it.login }).containsExactly("yschimke", "swankjesse") + } @Test - fun `prReviewers - given single reviewer approved PR - provides reviewers user-id`() = runTest { - // Uses data from https://github.com/square/okhttp/pull/7458 - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-7458.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-7458.json"))) + fun `prReviewers - given single reviewer approved PR - provides reviewers user-id`() = + runTest { + // Uses data from https://github.com/square/okhttp/pull/7458 + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-okhttp-7458.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-7458.json"))) - val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) + val prReviewers = pullRequestStatsRepo.prReviewers(pullRequest.user, timelineEvents) - assertThat(prReviewers).doesNotContain(pullRequest.user) - assertThat(prReviewers).hasSize(1) - assertThat(prReviewers.map { it.login }).containsExactly("swankjesse") - } + assertThat(prReviewers).doesNotContain(pullRequest.user) + assertThat(prReviewers).hasSize(1) + assertThat(prReviewers.map { it.login }).containsExactly("swankjesse") + } // region: Test Utility Functions + /** Provides response for given [jsonResponseFile] path in the test resources. */ private fun respond(jsonResponseFile: String): String { return PullRequestStatsRepoTest::class.java.getResource("/$jsonResponseFile")!!.readText() diff --git a/src/test/kotlin/dev/hossain/githubstats/io/ClientTest.kt b/src/test/kotlin/dev/hossain/githubstats/io/ClientTest.kt index 1f5752e..13c6621 100644 --- a/src/test/kotlin/dev/hossain/githubstats/io/ClientTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/io/ClientTest.kt @@ -39,132 +39,144 @@ internal class ClientTest { } @Test - fun `given timeline with review_requested event - parses review_requested event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-review-requested.json"))) + fun `given timeline with review_requested event - parses review_requested event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-review-requested.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is ReviewRequestedEvent } - assertThat(event).isNotNull() + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val event = timelineEvents.find { it is ReviewRequestedEvent } + assertThat(event).isNotNull() - assertThat((event as ReviewRequestedEvent).created_at).isEqualTo("2022-09-21T14:37:39Z") - } + assertThat((event as ReviewRequestedEvent).created_at).isEqualTo("2022-09-21T14:37:39Z") + } @Test - fun `given timeline with review_requested event from team - parses review_requested event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-review-requested-team.json"))) - - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is ReviewRequestedEvent } - assertThat(event).isNotNull() - - val reviewRequestedEvent = event as ReviewRequestedEvent - assertThat(reviewRequestedEvent.created_at).isEqualTo("2022-09-19T20:10:38Z") - assertThat(reviewRequestedEvent.requested_team).isNotNull() - assertThat(reviewRequestedEvent.requested_team!!.name).isEqualTo("opensearch-core") - assertThat(reviewRequestedEvent.requested_team!!.slug).isEqualTo("opensearch-core") - } + fun `given timeline with review_requested event from team - parses review_requested event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-review-requested-team.json"))) + + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val event = timelineEvents.find { it is ReviewRequestedEvent } + assertThat(event).isNotNull() + + val reviewRequestedEvent = event as ReviewRequestedEvent + assertThat(reviewRequestedEvent.created_at).isEqualTo("2022-09-19T20:10:38Z") + assertThat(reviewRequestedEvent.requested_team).isNotNull() + assertThat(reviewRequestedEvent.requested_team!!.name).isEqualTo("opensearch-core") + assertThat(reviewRequestedEvent.requested_team!!.slug).isEqualTo("opensearch-core") + } @Test - fun `given timeline with merged event - parses merged event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-merged.json"))) + fun `given timeline with merged event - parses merged event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-merged.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is MergedEvent } - assertThat(event).isNotNull() - assertThat((event as MergedEvent).created_at).isEqualTo("2021-02-22T07:43:05Z") - } + val event = timelineEvents.find { it is MergedEvent } + assertThat(event).isNotNull() + assertThat((event as MergedEvent).created_at).isEqualTo("2021-02-22T07:43:05Z") + } @Test - fun `given timeline with reviewed event - parses reviewed event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-reviewed.json"))) + fun `given timeline with reviewed event - parses reviewed event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-reviewed.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is ReviewedEvent } - assertThat(event).isNotNull() + val event = timelineEvents.find { it is ReviewedEvent } + assertThat(event).isNotNull() - val reviewedEvent = event as ReviewedEvent - assertThat(reviewedEvent.submitted_at).isEqualTo("2022-06-08T02:24:27Z") - assertThat(reviewedEvent.state).isEqualTo(ReviewState.APPROVED) - } + val reviewedEvent = event as ReviewedEvent + assertThat(reviewedEvent.submitted_at).isEqualTo("2022-06-08T02:24:27Z") + assertThat(reviewedEvent.state).isEqualTo(ReviewState.APPROVED) + } @Test - fun `given timeline with closed event - parses closed event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-closed.json"))) + fun `given timeline with closed event - parses closed event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-closed.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is ClosedEvent } - assertThat(event).isNotNull() + val event = timelineEvents.find { it is ClosedEvent } + assertThat(event).isNotNull() - assertThat((event as ClosedEvent).created_at).isEqualTo("2021-02-22T07:43:05Z") - } + assertThat((event as ClosedEvent).created_at).isEqualTo("2021-02-22T07:43:05Z") + } @Test - fun `given timeline with ready_for_review event - parses ready_for_review event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-ready-for-review.json"))) + fun `given timeline with ready_for_review event - parses ready_for_review event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-ready-for-review.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is ReadyForReviewEvent } - assertThat(event).isNotNull() + val event = timelineEvents.find { it is ReadyForReviewEvent } + assertThat(event).isNotNull() - assertThat((event as ReadyForReviewEvent).created_at).isEqualTo("2022-09-21T14:32:13Z") - } + assertThat((event as ReadyForReviewEvent).created_at).isEqualTo("2022-09-21T14:32:13Z") + } @Test - fun `given timeline with commented event - parses commented event successfully`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-commented.json"))) + fun `given timeline with commented event - parses commented event successfully`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-event-commented.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - val event = timelineEvents.find { it is CommentedEvent } - assertThat(event).isNotNull() + val event = timelineEvents.find { it is CommentedEvent } + assertThat(event).isNotNull() - val commentedEvent = event as CommentedEvent - assertThat(commentedEvent.created_at).isEqualTo("2022-10-20T11:42:19Z") - assertThat(commentedEvent.updated_at).isEqualTo("2022-10-20T12:22:19Z") - assertThat(commentedEvent.user.login).isEqualTo("ojeytonwilliams") - assertThat(commentedEvent.actor.login).isEqualTo("ojeytonwilliams") - } + val commentedEvent = event as CommentedEvent + assertThat(commentedEvent.created_at).isEqualTo("2022-10-20T11:42:19Z") + assertThat(commentedEvent.updated_at).isEqualTo("2022-10-20T12:22:19Z") + assertThat(commentedEvent.user.login).isEqualTo("ojeytonwilliams") + assertThat(commentedEvent.actor.login).isEqualTo("ojeytonwilliams") + } @Test - fun `given multiple timeline events - provides multiple parsed timeline events`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-response.json"))) + fun `given multiple timeline events - provides multiple parsed timeline events`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-response.json"))) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - assertThat(timelineEvents).isNotEmpty() - assertThat(timelineEvents).hasSize(3) - } + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + assertThat(timelineEvents).isNotEmpty() + assertThat(timelineEvents).hasSize(3) + } @Test - fun `given empty timeline response - provides empty timeline events`() = runTest { - mockWebServer.enqueue(MockResponse().setBody("[]")) + fun `given empty timeline response - provides empty timeline events`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody("[]")) - val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) - assertEquals(true, timelineEvents.isEmpty()) - } + val timelineEvents = Client.githubApiService.timelineEvents("X", "Y", 1) + assertEquals(true, timelineEvents.isEmpty()) + } @Test - fun `given pull request response - provides parsed PR data`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-number.json"))) + fun `given pull request response - provides parsed PR data`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-number.json"))) - val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) + val pullRequest = Client.githubApiService.pullRequest("X", "Y", 1) - assertThat(pullRequest.created_at).isEqualTo("2021-08-18T15:23:51Z") - } + assertThat(pullRequest.created_at).isEqualTo("2021-08-18T15:23:51Z") + } @Test - fun `given pull request comments - provides parsed PR comments`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("pulls-num-comments-freeCodeCamp-45530.json"))) + fun `given pull request comments - provides parsed PR comments`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("pulls-num-comments-freeCodeCamp-45530.json"))) - val comments = Client.githubApiService.prSourceCodeReviewComments("X", "Y", 1) + val comments = Client.githubApiService.prSourceCodeReviewComments("X", "Y", 1) - assertThat(comments.size).isEqualTo(4) - } + assertThat(comments.size).isEqualTo(4) + } // region: Test Utility Functions + /** Provides response for given [jsonResponseFile] path in the test resources. */ private fun respond(jsonResponseFile: String): String { return ClientTest::class.java.getResource("/$jsonResponseFile")!!.readText() diff --git a/src/test/kotlin/dev/hossain/githubstats/logging/LogTest.kt b/src/test/kotlin/dev/hossain/githubstats/logging/LogTest.kt index 51b7759..c2ac72b 100644 --- a/src/test/kotlin/dev/hossain/githubstats/logging/LogTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/logging/LogTest.kt @@ -16,7 +16,7 @@ class LogTest { @Test fun `logs verbose message when log level is verbose`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.VERBOSE + BuildConfig.logLevel = Log.VERBOSE Log.v("Verbose message") assertEquals("Verbose message\n", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -25,7 +25,7 @@ class LogTest { @Test fun `does not log verbose message when log level is debug`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.DEBUG + BuildConfig.logLevel = Log.DEBUG Log.v("Verbose message") assertEquals("", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -34,7 +34,7 @@ class LogTest { @Test fun `logs debug message when log level is debug`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.DEBUG + BuildConfig.logLevel = Log.DEBUG Log.d("Debug message") assertEquals("Debug message\n", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -43,7 +43,7 @@ class LogTest { @Test fun `does not log debug message when log level is info`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.INFO + BuildConfig.logLevel = Log.INFO Log.d("Debug message") assertEquals("", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -52,7 +52,7 @@ class LogTest { @Test fun `logs info message when log level is info`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.INFO + BuildConfig.logLevel = Log.INFO Log.i("Info message") assertEquals("Info message\n", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -61,7 +61,7 @@ class LogTest { @Test fun `does not log info message when log level is warning`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.WARNING + BuildConfig.logLevel = Log.WARNING Log.i("Info message") assertEquals("", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -70,7 +70,7 @@ class LogTest { @Test fun `logs warning message when log level is warning`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.WARNING + BuildConfig.logLevel = Log.WARNING Log.w("Warning message") assertEquals("Warning message\n", outputStreamCaptor.toString()) System.setOut(standardOut) @@ -79,7 +79,7 @@ class LogTest { @Test fun `does not log any message when log level is none`() { System.setOut(PrintStream(outputStreamCaptor)) - BuildConfig.LOG_LEVEL = Log.NONE + BuildConfig.logLevel = Log.NONE Log.v("Verbose message") Log.d("Debug message") Log.i("Info message") diff --git a/src/test/kotlin/dev/hossain/githubstats/model/timeline/TimelineEventTest.kt b/src/test/kotlin/dev/hossain/githubstats/model/timeline/TimelineEventTest.kt index 35320c7..cf10481 100644 --- a/src/test/kotlin/dev/hossain/githubstats/model/timeline/TimelineEventTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/model/timeline/TimelineEventTest.kt @@ -31,46 +31,51 @@ internal class TimelineEventTest { } @Test - fun `timelineEvents filterTo - given filtered to CommentedEvent - provides filtered items only`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `timelineEvents filterTo - given filtered to CommentedEvent - provides filtered items only`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) - val commentedEvents: List = timelineEvents.filterTo(CommentedEvent::class) - assertThat(commentedEvents).hasSize(24) - } + val commentedEvents: List = timelineEvents.filterTo(CommentedEvent::class) + assertThat(commentedEvents).hasSize(24) + } @Test - fun `timelineEvents filterTo - given filtered to ReviewedEvent - provides filtered items only`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `timelineEvents filterTo - given filtered to ReviewedEvent - provides filtered items only`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) - val commentedEvents: List = timelineEvents.filterTo(ReviewedEvent::class) - assertThat(commentedEvents).hasSize(20) - } + val commentedEvents: List = timelineEvents.filterTo(ReviewedEvent::class) + assertThat(commentedEvents).hasSize(20) + } @Test - fun `timelineEvents filterTo - given filtered to ClosedEvent - provides filtered items only`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `timelineEvents filterTo - given filtered to ClosedEvent - provides filtered items only`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) - val commentedEvents: List = timelineEvents.filterTo(ClosedEvent::class) - assertThat(commentedEvents).hasSize(1) - } + val commentedEvents: List = timelineEvents.filterTo(ClosedEvent::class) + assertThat(commentedEvents).hasSize(1) + } @Test - fun `timelineEvents filterTo - given filtered to MergedEvent - provides filtered items only`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `timelineEvents filterTo - given filtered to MergedEvent - provides filtered items only`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) + val timelineEvents: List = Client.githubApiService.timelineEvents("X", "Y", 1) - val commentedEvents: List = timelineEvents.filterTo(MergedEvent::class) - assertThat(commentedEvents).hasSize(1) - } + val commentedEvents: List = timelineEvents.filterTo(MergedEvent::class) + assertThat(commentedEvents).hasSize(1) + } // region: Test Utility Functions + /** Provides response for given [jsonResponseFile] path in the test resources. */ private fun respond(jsonResponseFile: String): String { return TimelineEventTest::class.java.getResource("/$jsonResponseFile")!!.readText() diff --git a/src/test/kotlin/dev/hossain/githubstats/service/IssueSearchPagerServiceTest.kt b/src/test/kotlin/dev/hossain/githubstats/service/IssueSearchPagerServiceTest.kt index 72310d6..b0aaaf0 100644 --- a/src/test/kotlin/dev/hossain/githubstats/service/IssueSearchPagerServiceTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/service/IssueSearchPagerServiceTest.kt @@ -18,7 +18,6 @@ import kotlin.test.assertFailsWith */ @OptIn(ExperimentalCoroutinesApi::class) internal class IssueSearchPagerServiceTest { - // https://github.com/square/okhttp/tree/master/mockwebserver private lateinit var mockWebServer: MockWebServer @@ -30,10 +29,11 @@ internal class IssueSearchPagerServiceTest { mockWebServer.start(60000) Client.baseUrl = mockWebServer.url("/") - issueSearchPager = IssueSearchPagerService( - githubApiService = Client.githubApiService, - errorProcessor = ErrorProcessor(), - ) + issueSearchPager = + IssueSearchPagerService( + githubApiService = Client.githubApiService, + errorProcessor = ErrorProcessor(), + ) } @AfterEach @@ -42,55 +42,62 @@ internal class IssueSearchPagerServiceTest { } @Test - fun `searchIssues - responds with error - throws error`() = runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(400) - .setBody("{ \"error\": 400 }"), - ) - - assertFailsWith(IllegalStateException::class) { - issueSearchPager.searchIssues("search-query") + fun `searchIssues - responds with error - throws error`() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody("{ \"error\": 400 }"), + ) + + assertFailsWith(IllegalStateException::class) { + issueSearchPager.searchIssues("search-query") + } } - } @Test - fun `searchIssues - given contains no items does not make next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody("{ \"total_count\": 0, \"items\": [] }")) + fun `searchIssues - given contains no items does not make next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody("{ \"total_count\": 0, \"items\": [] }")) - val githubIssueResults: List = issueSearchPager.searchIssues("search-query") + val githubIssueResults: List = issueSearchPager.searchIssues("search-query") - assertThat(githubIssueResults).hasSize(0) - } + assertThat(githubIssueResults).hasSize(0) + } @Test - fun `searchIssues - given contains less than max per page does not make next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("search-prs-freeCodeCamp-DanielRosa74-47511.json"))) + fun `searchIssues - given contains less than max per page does not make next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("search-prs-freeCodeCamp-DanielRosa74-47511.json"))) - val githubIssueResults: List = issueSearchPager.searchIssues("search-query") + val githubIssueResults: List = issueSearchPager.searchIssues("search-query") - assertThat(githubIssueResults).hasSize(1) - } + assertThat(githubIssueResults).hasSize(1) + } @Test - fun `searchIssues - given contains more than max per page makes next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-1.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-2.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-3.json"))) - - // Re-initializes the pager to reduce page size for testing - issueSearchPager = IssueSearchPagerService( - githubApiService = Client.githubApiService, - errorProcessor = ErrorProcessor(), - pageSize = 10, // sets the page size low based on unit test - ) - - val githubIssueResults: List = issueSearchPager.searchIssues("search-query") - - assertThat(githubIssueResults).hasSize(24) - } + fun `searchIssues - given contains more than max per page makes next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-1.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-2.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("search-issue-freeCodeCamp-naomi-lgbt-page-3.json"))) + + // Re-initializes the pager to reduce page size for testing + issueSearchPager = + IssueSearchPagerService( + githubApiService = Client.githubApiService, + errorProcessor = ErrorProcessor(), + // sets the page size low based on unit test + pageSize = 10, + ) + + val githubIssueResults: List = issueSearchPager.searchIssues("search-query") + + assertThat(githubIssueResults).hasSize(24) + } // region: Test Utility Functions + /** Provides response for given [jsonResponseFile] path in the test resources. */ private fun respond(jsonResponseFile: String): String { return requireNotNull(IssueSearchPagerServiceTest::class.java.getResource("/$jsonResponseFile")).readText() diff --git a/src/test/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerServiceTest.kt b/src/test/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerServiceTest.kt index d6d4cd9..af22427 100644 --- a/src/test/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerServiceTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/service/TimelineEventsPagerServiceTest.kt @@ -18,7 +18,6 @@ import kotlin.test.assertFailsWith */ @OptIn(ExperimentalCoroutinesApi::class) internal class TimelineEventsPagerServiceTest { - // https://github.com/square/okhttp/tree/master/mockwebserver private lateinit var mockWebServer: MockWebServer @@ -30,10 +29,11 @@ internal class TimelineEventsPagerServiceTest { mockWebServer.start(60000) Client.baseUrl = mockWebServer.url("/") - timelinePagerService = TimelineEventsPagerService( - githubApiService = Client.githubApiService, - errorProcessor = ErrorProcessor(), - ) + timelinePagerService = + TimelineEventsPagerService( + githubApiService = Client.githubApiService, + errorProcessor = ErrorProcessor(), + ) } @AfterEach @@ -42,47 +42,52 @@ internal class TimelineEventsPagerServiceTest { } @Test - fun `getAllTimelineEvents - responds with error - throws error`() = runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(400) - .setBody("{ \"error\": 400 }"), - ) - - assertFailsWith(IllegalStateException::class) { - timelinePagerService.getAllTimelineEvents("X", "Y", 1) + fun `getAllTimelineEvents - responds with error - throws error`() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(400) + .setBody("{ \"error\": 400 }"), + ) + + assertFailsWith(IllegalStateException::class) { + timelinePagerService.getAllTimelineEvents("X", "Y", 1) + } } - } @Test - fun `getAllTimelineEvents - given contains no items does not make next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody("[]")) + fun `getAllTimelineEvents - given contains no items does not make next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody("[]")) - val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) + val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) - assertThat(timelineEvents).hasSize(0) - } + assertThat(timelineEvents).hasSize(0) + } @Test - fun `getAllTimelineEvents - given contains less than max per page does not make next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) + fun `getAllTimelineEvents - given contains less than max per page does not make next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-okhttp-3873.json"))) - val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) + val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) - assertThat(timelineEvents).hasSize(93) - } + assertThat(timelineEvents).hasSize(93) + } @Test - fun `getAllTimelineEvents - given contains more than max per page makes next page request`() = runTest { - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jellyfin-2733-page1.json"))) - mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jellyfin-2733-page2.json"))) + fun `getAllTimelineEvents - given contains more than max per page makes next page request`() = + runTest { + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jellyfin-2733-page1.json"))) + mockWebServer.enqueue(MockResponse().setBody(respond("timeline-jellyfin-2733-page2.json"))) - val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) + val timelineEvents: List = timelinePagerService.getAllTimelineEvents("X", "Y", 1) - assertThat(timelineEvents).hasSize(195) - } + assertThat(timelineEvents).hasSize(195) + } // region: Test Utility Functions + /** Provides response for given [jsonResponseFile] path in the test resources. */ private fun respond(jsonResponseFile: String): String { return requireNotNull(TimelineEventsPagerServiceTest::class.java.getResource("/$jsonResponseFile")).readText() diff --git a/src/test/kotlin/dev/hossain/githubstats/util/AppConfigTest.kt b/src/test/kotlin/dev/hossain/githubstats/util/AppConfigTest.kt index 45b17ce..0fb1098 100644 --- a/src/test/kotlin/dev/hossain/githubstats/util/AppConfigTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/util/AppConfigTest.kt @@ -15,7 +15,6 @@ import java.util.Locale * Unit test for [AppConfig]. */ class AppConfigTest { - private lateinit var localProperties: LocalProperties private lateinit var appConfig: AppConfig @@ -76,9 +75,10 @@ class AppConfigTest { every { localProperties.getAuthors() } returns "author1,author2" every { localProperties.getDateLimitBefore() } returns null - val todayDate = DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.US) - .withResolverStyle(ResolverStyle.STRICT) - .format(LocalDate.now()) + val todayDate = + DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.US) + .withResolverStyle(ResolverStyle.STRICT) + .format(LocalDate.now()) appConfig = AppConfig(localProperties) val config = appConfig.get() diff --git a/src/test/kotlin/dev/hossain/githubstats/util/ErrorProcessorTest.kt b/src/test/kotlin/dev/hossain/githubstats/util/ErrorProcessorTest.kt index 3eeab2c..0f05fe0 100644 --- a/src/test/kotlin/dev/hossain/githubstats/util/ErrorProcessorTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/util/ErrorProcessorTest.kt @@ -13,7 +13,6 @@ import retrofit2.Response * Test for [ErrorProcessor] */ class ErrorProcessorTest { - @BeforeEach fun setUp() { } diff --git a/src/test/kotlin/dev/hossain/githubstats/util/TimeUtilTest.kt b/src/test/kotlin/dev/hossain/githubstats/util/TimeUtilTest.kt index ef849a4..34b92ab 100644 --- a/src/test/kotlin/dev/hossain/githubstats/util/TimeUtilTest.kt +++ b/src/test/kotlin/dev/hossain/githubstats/util/TimeUtilTest.kt @@ -28,6 +28,7 @@ import kotlin.time.Duration /** * Some random testing ground to test out date time library and functionality. */ +@Suppress("ktlint:standard:max-line-length") internal class TimeUtilTest { @Test fun testDateTime() { @@ -52,15 +53,18 @@ internal class TimeUtilTest { @Test fun testDateTimeFormat() { val instantNow = Clock.System.now() - val shortFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) - .withLocale(Locale.US) - .withZone(ZoneId.systemDefault()) - val mediumFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(Locale.US) - .withZone(ZoneId.systemDefault()) - val fullFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) - .withLocale(Locale.US) - .withZone(ZoneId.systemDefault()) + val shortFormat = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) + val mediumFormat = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) + val fullFormat = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) println(shortFormat.format(instantNow.toJavaInstant())) println(mediumFormat.format(instantNow.toJavaInstant())) @@ -76,9 +80,10 @@ internal class TimeUtilTest { // The difference should be 20 hours, // However, since it's in saturday, actual working hour is 5 hours!!! - val mediumFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) - .withLocale(Locale.US) - .withZone(ZoneId.systemDefault()) + val mediumFormat = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) // Sat Jan 01 2022 10:00:00 GMT-0500 (Eastern Standard Time) // Sat Jan 01 2022 15:00:00 GMT+0000 @@ -136,10 +141,11 @@ internal class TimeUtilTest { // Represent a span-of-time in terms of years-months-days. // Extract the date-only from the date-time-zone object. - val periodZ1Z2 = Period.between( - zonedDateTime1.toLocalDate(), - zonedDateTime2.toLocalDate(), - ) + val periodZ1Z2 = + Period.between( + zonedDateTime1.toLocalDate(), + zonedDateTime2.toLocalDate(), + ) println("periodZ1Z2=$periodZ1Z2") println("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -") } @@ -161,16 +167,10 @@ internal class TimeUtilTest { assertEquals(fourteenDaysAfterDate, result.toString()) } - var NEXT_WORKING_DAY = TemporalAdjusters.ofDateAdjuster { date: LocalDate -> - val dayOfWeek = date.dayOfWeek - val daysToAdd: Int = if (dayOfWeek == DayOfWeek.FRIDAY) 3 else if (dayOfWeek == DayOfWeek.SATURDAY) 2 else 1 - date.plusDays(daysToAdd.toLong()) - } - @Test fun whenAdjust_thenNextWorkingDay() { val localDate = LocalDate.of(2017, 7, 8) - val temporalAdjuster: TemporalAdjuster = NEXT_WORKING_DAY + val temporalAdjuster: TemporalAdjuster = nextWorkingDay val result = localDate.with(temporalAdjuster) assertEquals("2017-07-10", result.toString()) } @@ -196,36 +196,56 @@ internal class TimeUtilTest { /** * https://stackoverflow.com/questions/28995301/get-minutes-between-two-working-days-in-java */ - fun getWorkedMinutes(startTime: Calendar, endTime: Calendar): Int { - val BEGINWORKHOUR = 8 - val ENDWORKHOUR = 16 + private fun getWorkedMinutes( + startTime: Calendar, + endTime: Calendar, + ): Int { + val beginWorkHour = 8 + val endWOrkHour = 16 fun workedMinsDay(start: Calendar): Int { - return if (start[Calendar.DAY_OF_WEEK] == 1 || start[Calendar.DAY_OF_WEEK] == 6) 0 else 60 - start[Calendar.MINUTE] + (ENDWORKHOUR - start[Calendar.HOUR_OF_DAY] - 1) * 60 + return if (start[Calendar.DAY_OF_WEEK] == 1 || start[Calendar.DAY_OF_WEEK] == 6) 0 else 60 - start[Calendar.MINUTE] + (endWOrkHour - start[Calendar.HOUR_OF_DAY] - 1) * 60 } - fun sameDay(start: Calendar, end: Calendar): Boolean { + fun sameDay( + start: Calendar, + end: Calendar, + ): Boolean { return start[Calendar.YEAR] == end[Calendar.YEAR] && start[Calendar.MONTH] == end[Calendar.MONTH] && start[Calendar.DAY_OF_MONTH] == end[Calendar.DAY_OF_MONTH] } val start = startTime val end = endTime - if (start[Calendar.HOUR_OF_DAY] < BEGINWORKHOUR) { - start[Calendar.HOUR_OF_DAY] = BEGINWORKHOUR + if (start[Calendar.HOUR_OF_DAY] < beginWorkHour) { + start[Calendar.HOUR_OF_DAY] = beginWorkHour start[Calendar.MINUTE] = 0 } - if (end[Calendar.HOUR_OF_DAY] >= ENDWORKHOUR) { - end[Calendar.HOUR_OF_DAY] = ENDWORKHOUR + if (end[Calendar.HOUR_OF_DAY] >= endWOrkHour) { + end[Calendar.HOUR_OF_DAY] = endWOrkHour end[Calendar.MINUTE] = 0 } var workedMins = 0 while (!sameDay(start, end)) { workedMins += workedMinsDay(start) start.add(Calendar.DAY_OF_MONTH, 1) - start[Calendar.HOUR_OF_DAY] = BEGINWORKHOUR + start[Calendar.HOUR_OF_DAY] = beginWorkHour start[Calendar.MINUTE] = 0 } workedMins += end[Calendar.MINUTE] - start[Calendar.MINUTE] + (end[Calendar.HOUR_OF_DAY] - start[Calendar.HOUR_OF_DAY]) * 60 return workedMins } + + private val nextWorkingDay: TemporalAdjuster = + TemporalAdjusters.ofDateAdjuster { date: LocalDate -> + val dayOfWeek = date.dayOfWeek + val daysToAdd: Int = + if (dayOfWeek == DayOfWeek.FRIDAY) { + 3 + } else if (dayOfWeek == DayOfWeek.SATURDAY) { + 2 + } else { + 1 + } + date.plusDays(daysToAdd.toLong()) + } } diff --git a/src/test/kotlin/dev/hossain/time/.editorconfig b/src/test/kotlin/dev/hossain/time/.editorconfig new file mode 100644 index 0000000..e69de29 diff --git a/src/test/kotlin/dev/hossain/time/DateTimeDifferTest.kt b/src/test/kotlin/dev/hossain/time/DateTimeDifferTest.kt index e147415..c10a3e8 100644 --- a/src/test/kotlin/dev/hossain/time/DateTimeDifferTest.kt +++ b/src/test/kotlin/dev/hossain/time/DateTimeDifferTest.kt @@ -18,6 +18,7 @@ import kotlin.time.Duration * - https://time.lol/ (Custom format: `ddd, D MMM YYYY h:mm a`) * - https://timestampgenerator.com/1662386400/-04:00 */ +@Suppress("ktlint:standard:max-line-length") internal class DateTimeDifferTest { /* * System default on my machine is: `America/Toronto` diff --git a/src/test/kotlin/dev/hossain/time/DurationExtensionKtTest.kt b/src/test/kotlin/dev/hossain/time/DurationExtensionKtTest.kt index d17a83c..1ba8e81 100644 --- a/src/test/kotlin/dev/hossain/time/DurationExtensionKtTest.kt +++ b/src/test/kotlin/dev/hossain/time/DurationExtensionKtTest.kt @@ -10,7 +10,6 @@ import kotlin.time.Duration * Contains unit tests for [DurationExtension]. */ class DurationExtensionKtTest { - @BeforeEach fun setUp() { } diff --git a/src/test/kotlin/dev/hossain/time/ZonedDateTimeExtensionTest.kt b/src/test/kotlin/dev/hossain/time/ZonedDateTimeExtensionTest.kt index c5f3bb6..c5a2b02 100644 --- a/src/test/kotlin/dev/hossain/time/ZonedDateTimeExtensionTest.kt +++ b/src/test/kotlin/dev/hossain/time/ZonedDateTimeExtensionTest.kt @@ -7,8 +7,8 @@ import kotlinx.datetime.toInstant import org.junit.jupiter.api.Test import java.time.ZonedDateTime +@Suppress("ktlint:standard:max-line-length") internal class ZonedDateTimeExtensionTest { - @Test fun startOfDay() { val dateTime = Instant.parse("2022-09-05T10:00:00-04:00").toZdt() // 10:00am Monday