diff --git a/.github/workflows/full_suite_integration_tests.yml b/.github/workflows/full_suite_integration_tests.yml new file mode 100644 index 0000000000..9ffc68ec56 --- /dev/null +++ b/.github/workflows/full_suite_integration_tests.yml @@ -0,0 +1,62 @@ +name: Full Suite Integration Tests + +on: + schedule: + - cron: '0 0 * * 1-5' # At 00:00 on every day-of-week from Monday through Friday + workflow_dispatch: # or manually + +jobs: + run-it-full-suite: + if: github.ref == 'refs/heads/master' # for now we want this workflow to run only on master, to be removed once support for different branches is implemented + runs-on: ubuntu-latest + outputs: + build-scan-url: ${{ steps.run-it.outputs.build-scan-url }} + it-status: ${{ steps.status.outputs.it-status }} + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.5.0 + with: + access_token: '${{ secrets.GITHUB_TOKEN }}' + + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: true + + - name: Download flankScripts and add it to PATH + run: | + ./gradlew :flank-scripts:download + echo "./flank-scripts/bash" >> $GITHUB_PATH + + - uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ubuntu-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ubuntu-gradle- + + - name: Gradle clean build + uses: eskatos/gradle-command-action@v1 + id: build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.ref }} + with: + arguments: "clean assemble" + + - name: Gradle integration tests + uses: eskatos/gradle-command-action@v1 + id: run-it + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.ref }} + with: + arguments: "integrationTests" + + - name: Process IT results + run: | + flankScripts integration processResults \ + --result ${{ job.status }} \ + --url ${{ steps.run-it.outputs.build-scan-url }} \ + --github-token ${{ secrets.GITHUB_TOKEN }} \ + --run-id ${{ github.run_id }} diff --git a/flank-scripts/README.md b/flank-scripts/README.md index a33dcdb23e..a1a4a1069e 100644 --- a/flank-scripts/README.md +++ b/flank-scripts/README.md @@ -264,3 +264,22 @@ or | --github-token | GitHub Token | | --zenhub-token | ZenHub api Token | | --pr-number | Pull request number | + +### integration (currently, available only on the master branch) +To show all available commands for `integration` use: +`flankScripts integration` + +Available commands are: Process results from `full_suite_integration_tests` workflow +- `processResults` + +#### `processResults` +If IT ended up with failure a new issue is created with some basic info: build scan URL, commit list, timestamp. +If there is an issue already created comment is posted to the existing one (so we avoid multiple copies of the same ticket). +Once integration tests end with success issue is closed. + +| Option | Description | +|----------------|---------------------| +| --github-token | GitHub Token | +| --result | Status of IT step from workflow. Can be either `success` or `failure`| +| --url | Build scan url from IT step| +| --run-id | `job.run_id` value from workflow context. Used for comment posting| diff --git a/flank-scripts/build.gradle.kts b/flank-scripts/build.gradle.kts index 396957f89a..6b05fb5231 100644 --- a/flank-scripts/build.gradle.kts +++ b/flank-scripts/build.gradle.kts @@ -28,7 +28,7 @@ shadowJar.apply { } } // .. -version = "1.1.5" +version = "1.2.0" group = "com.github.flank" application { diff --git a/flank-scripts/src/main/kotlin/flank/scripts/Main.kt b/flank-scripts/src/main/kotlin/flank/scripts/Main.kt index 69a188e2c8..f2135b2b72 100644 --- a/flank-scripts/src/main/kotlin/flank/scripts/Main.kt +++ b/flank-scripts/src/main/kotlin/flank/scripts/Main.kt @@ -4,6 +4,7 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands import flank.scripts.ci.CiCommand import flank.scripts.dependencies.DependenciesCommand +import flank.scripts.integration.IntegrationCommand import flank.scripts.pullrequest.PullRequestCommand import flank.scripts.release.ReleaseCommand import flank.scripts.shell.ShellCommand @@ -21,6 +22,7 @@ fun main(args: Array) { DependenciesCommand, TestArtifactsCommand(), ShellCommand, - PullRequestCommand + PullRequestCommand, + IntegrationCommand ).main(args) } diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/CommitList.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/CommitList.kt new file mode 100644 index 0000000000..347ddb76f3 --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/CommitList.kt @@ -0,0 +1,30 @@ +package flank.scripts.integration + +import com.github.kittinunf.result.getOrElse +import com.github.kittinunf.result.onError +import flank.scripts.github.getGitHubCommitList +import flank.scripts.github.getPrDetailsByCommit +import flank.scripts.github.objects.GithubPullRequest +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +suspend fun getCommitListSinceDate( + token: String, + since: String +): List> = coroutineScope { + getGitHubCommitList(token, listOf("since" to since)) + .onError { println(it.message) } + .getOrElse { emptyList() } + .map { + async { + it.sha to getPrDetailsByCommit(it.sha, token).getOrElse { emptyList() } + } + } + .awaitAll() + .flatMap { (commit, prs) -> + if (prs.isEmpty()) listOf(commit to null) + else prs.map { commit to it } + } + .toList() +} diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/Extensions.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/Extensions.kt new file mode 100644 index 0000000000..784b3350ce --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/Extensions.kt @@ -0,0 +1,72 @@ +package flank.scripts.integration + +import com.github.kittinunf.result.onError +import flank.scripts.github.objects.GitHubCreateIssueCommentRequest +import flank.scripts.github.objects.GitHubCreateIssueRequest +import flank.scripts.github.objects.GitHubCreateIssueResponse +import flank.scripts.github.objects.GitHubUpdateIssueRequest +import flank.scripts.github.objects.IssueState +import flank.scripts.github.patchIssue +import flank.scripts.github.postNewIssue +import flank.scripts.github.postNewIssueComment +import flank.scripts.utils.toJson +import kotlinx.coroutines.coroutineScope +import kotlin.system.exitProcess + +internal suspend fun IntegrationContext.createNewIssue(): GitHubCreateIssueCommentRequest = + createAndPostNewIssue().postComment() + +internal suspend fun IntegrationContext.postComment(): GitHubCreateIssueCommentRequest = + createCommentPayload().also { payload -> + postNewIssueComment(token, issueNumber, payload) + println("** Comment posted") + println(payload.toJson()) + } + +internal suspend fun IntegrationContext.closeIssue(): ByteArray = + postComment().run { + println("** Closing issue") + patchIssue(token, issueNumber, GitHubUpdateIssueRequest(state = IssueState.CLOSED)).get() + } + +private suspend fun IntegrationContext.createAndPostNewIssue(payload: GitHubCreateIssueRequest = createIssuePayload()) = + postNewIssue(token, payload) + .onError { + println(it.message) + exitProcess(-1) + } + .get() + .run { + logIssueCreated(this) + copy(openedIssue = number) + } + +private fun createIssuePayload(): GitHubCreateIssueRequest { + println("** Creating new issue") + val issuePayload = GitHubCreateIssueRequest( + title = "Full Suite integration tests failed on master", + body = "### Integration Test failed on master", + labels = listOf("IT_Failed", "bug") + ) + println(issuePayload.toJson()) + return issuePayload +} + +private suspend fun IntegrationContext.createCommentPayload() = coroutineScope { + val message = when (result) { + ITResults.SUCCESS -> prepareSuccessMessage(lastRun, runID, url) + ITResults.FAILURE -> { + val commitList = getCommitListSinceDate(token, lastRun) + prepareFailureMessage(lastRun, runID, url, commitList) + } + } + GitHubCreateIssueCommentRequest(message) +} + +private fun logIssueCreated(issue: GitHubCreateIssueResponse) = println( + """ +** Issue created: + url: ${issue.htmlUrl} + number: ${issue.number} +""".trimIndent() +) diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationCommand.kt new file mode 100644 index 0000000000..e0cb5593c2 --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationCommand.kt @@ -0,0 +1,14 @@ +package flank.scripts.integration + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands + +object IntegrationCommand : CliktCommand(name = "integration") { + + init { + subcommands(ProcessResultCommand) + } + + @Suppress("EmptyFunctionBlock") + override fun run() {} +} diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationContext.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationContext.kt new file mode 100644 index 0000000000..772982f19c --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/IntegrationContext.kt @@ -0,0 +1,13 @@ +package flank.scripts.integration + +data class IntegrationContext( + val result: ITResults, + val token: String, + val url: String, + val runID: String, + val lastRun: String, + private val openedIssue: Int?, +) { + val issueNumber: Int + get() = requireNotNull(openedIssue) +} diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/IssueList.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/IssueList.kt new file mode 100644 index 0000000000..c3e29a123f --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/IssueList.kt @@ -0,0 +1,21 @@ +package flank.scripts.integration + +import com.github.kittinunf.result.getOrElse +import com.github.kittinunf.result.onError +import flank.scripts.github.getGitHubIssueList + +suspend fun checkForOpenedITIssues(token: String): Int? = getGitHubIssueList( + githubToken = token, + parameters = listOf( + "creator" to "github-actions[bot]", + "state" to "open", + "labels" to "IT_Failed" + ) +) + .onError { println(it.message) } + .getOrElse { emptyList() } + .firstOrNull() + .also { + if (it != null) println("** Issue found: ${it.htmlUrl}") + else println("** No opened issue") + }?.number diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/PrepareMessage.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/PrepareMessage.kt new file mode 100644 index 0000000000..1445d60f4a --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/PrepareMessage.kt @@ -0,0 +1,54 @@ +package flank.scripts.integration + +import flank.scripts.github.objects.GithubPullRequest +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +fun prepareSuccessMessage( + lastRun: String, + runId: String, + url: String +): String = successTemplate(lastRun, runId, url) + +fun prepareFailureMessage( + lastRun: String, + runId: String, + url: String, + prs: List> +): String = buildString { + appendLine(failureTemplate(lastRun, runId, url)) + if (prs.isEmpty()) appendLine("No new commits") + else { + appendLine("|commit SHA|PR|") + appendLine("|---|:---:|") + prs.forEach { (commit, pr) -> + appendLine("|$commit|${pr?.let { "[${it.title}](${it.htmlUrl})" } ?: "---"}") + } + } +} + +private val successTemplate = { lastRun: String, runId: String, url: String -> + """ + |### Full suite IT run :white_check_mark: SUCCEEDED :white_check_mark: + |**Timestamp:** ${makeHumanFriendly(lastRun)} + |**Job run:** [$runId](https://github.com/Flank/flank/actions/runs/$runId + |**Build scan URL:** $url + |**Closing issue** +""".trimMargin() +} + +private val failureTemplate = { lastRun: String, runId: String, url: String -> + """ + |### Full suite IT run :x: FAILED :x: + |**Timestamp:** ${makeHumanFriendly(lastRun)} + |**Job run:** [$runId](https://github.com/Flank/flank/actions/runs/$runId + |**Build scan URL:** $url +""".trimMargin() +} + +private fun makeHumanFriendly(date: String) = + LocalDateTime + .ofInstant(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(date)), ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/ProcessResultCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/ProcessResultCommand.kt new file mode 100644 index 0000000000..b1892bdfcc --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/ProcessResultCommand.kt @@ -0,0 +1,60 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE") + +package flank.scripts.integration + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.enum +import kotlinx.coroutines.runBlocking + +object ProcessResultCommand : CliktCommand(name = "processResults") { + + private val itResult by option(help = "IT run job status", names = arrayOf("--result")) + .enum(ignoreCase = true) + .required() + + private val buildScanURL by option(help = "Gradle build scan URL", names = arrayOf("--url")) + .required() + + private val githubToken by option(help = "Git Token").required() + private val runID by option(help = "Workflow job ID").required() + private val openedIssue by lazy { runBlocking { checkForOpenedITIssues(githubToken) } } + private val lastRun by lazy { runBlocking { getLastITWorkflowRunDate(githubToken) } } + + override fun run() { + runBlocking { + logArgs() + with(makeContext()) { + when { + itResult == ITResults.FAILURE && openedIssue == null -> createNewIssue() + itResult == ITResults.FAILURE && openedIssue != null -> postComment() + itResult == ITResults.SUCCESS && openedIssue != null -> closeIssue() + else -> return@runBlocking + } + } + } + } + + private fun makeContext() = IntegrationContext( + result = itResult, + token = githubToken, + url = buildScanURL, + runID = runID, + lastRun = lastRun, + openedIssue = openedIssue + ) + + private fun logArgs() = println( + """ + ** Parameters: + result: $itResult + url: $buildScanURL + runID: $runID + """.trimIndent() + ) +} + +enum class ITResults { + SUCCESS, FAILURE +} diff --git a/flank-scripts/src/main/kotlin/flank/scripts/integration/WorkflowSummary.kt b/flank-scripts/src/main/kotlin/flank/scripts/integration/WorkflowSummary.kt new file mode 100644 index 0000000000..0ac62ee675 --- /dev/null +++ b/flank-scripts/src/main/kotlin/flank/scripts/integration/WorkflowSummary.kt @@ -0,0 +1,49 @@ +package flank.scripts.integration + +import com.github.kittinunf.result.getOrNull +import flank.scripts.github.getGitHubWorkflowRunsSummary +import flank.scripts.github.objects.GitHubWorkflowRun +import java.time.Instant +import java.time.format.DateTimeFormatter + +suspend fun getLastITWorkflowRunDate(token: String) = getLastWorkflowRunDate( + token = token, + workflowFileName = "full_suite_integration_tests.yml" +) + +suspend fun getLastWorkflowRunDate( + token: String, + workflowName: String? = null, + workflowFileName: String? = null +): String = getGitHubWorkflowRunsSummary( + githubToken = token, + workflow = requireNotNull( + workflowName ?: workflowFileName + ) { "** Missing either workflow name or workflow file name. Both can not be null" }, + parameters = listOf( + "per_page" to 20, + "page" to 1 + ) +).getOrNull() + ?.run { + workflowRuns + .filter { it.status != "in_progress" } + .filter { it.conclusion != "cancelled" } + .getOrNull(0) + .logRun() + ?.createdAt.run { DateTimeFormatter.ISO_INSTANT.format(Instant.parse(this)) } + } ?: run { + println("** No workflow run found for ${workflowName ?: workflowFileName}") + DateTimeFormatter.ISO_INSTANT.format(Instant.now()) +} + +private fun GitHubWorkflowRun?.logRun() = this?.also { + println( + """ +** Last workflow run: + name: ${it.name} + last run: ${it.createdAt} + url: ${it.htmlUrl} +""".trimIndent() + ) +} diff --git a/flank-scripts/src/test/kotlin/flank/scripts/GithubMockServerHandler.kt b/flank-scripts/src/test/kotlin/flank/scripts/GithubMockServerHandler.kt index ff1c986781..6bc4ecf23d 100644 --- a/flank-scripts/src/test/kotlin/flank/scripts/GithubMockServerHandler.kt +++ b/flank-scripts/src/test/kotlin/flank/scripts/GithubMockServerHandler.kt @@ -7,6 +7,7 @@ import flank.scripts.github.objects.GitHubCommit import flank.scripts.github.objects.GitHubCreateIssueCommentResponse import flank.scripts.github.objects.GitHubCreateIssueResponse import flank.scripts.github.objects.GitHubLabel +import flank.scripts.github.objects.GitHubWorkflowRun import flank.scripts.github.objects.GitHubWorkflowRunsSummary import flank.scripts.github.objects.GithubPullRequest import flank.scripts.github.objects.GithubUser @@ -14,15 +15,22 @@ import flank.scripts.utils.toJson import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +// fixme: needs small refactor to increase readability, will do it in a following PR @Suppress("ComplexMethod") fun handleGithubMockRequest(url: String, request: Request) = when { request.isFailedGithubRequest() -> request.buildResponse(githubErrorBody, 422) - url ends """/actions/workflows/[a-zA-Z.-]*/runs""" -> request.buildResponse(workflowSummary, 200) - url ends """/issues/\d*/comments""" && request.isGET -> request.buildResponse( + url ends """/actions/workflows/[a-zA-Z_.-]*/runs""" -> request.buildResponse(workflowSummary, 200) + url ends """/issues/[0-9]*/comments""" && request.isGET -> request.buildResponse( testGithubIssueCommentList, 200 ) + url ends """/issues\?creator=github-actions""" -> request.buildResponse( + if (request.noIssueHeader()) "[]" else githubPullRequestTest.toJson(), + 200 + ) + url ends """/commits/[a-zA-Z0-9]*/pulls""" -> request.buildResponse(Json.encodeToString(githubPullRequestTest), 200) url ends """/issues/\d*/comments""" && request.isPOST -> request.buildResponse(createComment, 200) + url ends """/commits\?since=*""" -> request.buildResponse(testGithubIssueList, 200) url.endsWith("/git/refs/tags/success") -> request.buildResponse("", 200) url.endsWith("/releases/latest") && request.containsSuccessHeader() -> request.buildResponse(GitHubRelease("v20.08.0").toJson(), 200) @@ -36,7 +44,7 @@ fun handleGithubMockRequest(url: String, request: Request) = when { url.endsWith("/labels") && request.containsSuccessHeader() -> request.buildResponse(testGithubLabels.toJson(), 200) (url.contains("/pulls/") || url.contains("/issues/")) && request.containsSuccessHeader() -> request.buildResponse(githubPullRequestTest.first().toJson(), 200) - else -> error("Not supported request") + else -> error("Not supported request: $request") } private infix fun String.ends(suffix: String) = suffix.toRegex().containsMatchIn(this) @@ -47,9 +55,10 @@ private fun Request.isFailedGithubRequest() = url.toString() == "https://api.github.com/repos/Flank/flank/git/refs/tags/failure" || request.headers["Authorization"].contains("token failure") +private fun Request.noIssueHeader() = request.headers["Authorization"].contains("token no-issue") + private val testGithubIssueList = listOf( - GitHubCommit("123abc"), - GitHubCommit("456def") + GitHubCommit("aaaaaaaaa") ).toJson() private val testGithubIssueCommentList = listOf( @@ -63,7 +72,18 @@ private val Request.isPOST: Boolean private val Request.isGET: Boolean get() = method == Method.GET -private val workflowSummary = GitHubWorkflowRunsSummary(1, emptyList()).toJson() +private val workflowSummary = GitHubWorkflowRunsSummary( + totalCount = 1, + workflowRuns = listOf( + GitHubWorkflowRun( + status = "completed", + conclusion = "success", + createdAt = "2020-12-10T09:51:56.797534Z", + htmlUrl = "http://workflow.run/123", + name = "any-name" + ) + ) +).toJson() private val createIssue = GitHubCreateIssueResponse(1, "https://bla.org", "any body", 123).toJson() private val createComment = GitHubCreateIssueCommentResponse(2, "https://bla.org", "any body").toJson() diff --git a/flank-scripts/src/test/kotlin/flank/scripts/integration/ProcessResultTest.kt b/flank-scripts/src/test/kotlin/flank/scripts/integration/ProcessResultTest.kt new file mode 100644 index 0000000000..7b3806465e --- /dev/null +++ b/flank-scripts/src/test/kotlin/flank/scripts/integration/ProcessResultTest.kt @@ -0,0 +1,88 @@ +package flank.scripts.integration + +import com.google.common.truth.Truth.assertThat +import flank.scripts.FuelTestRunner +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.contrib.java.lang.system.SystemOutRule +import org.junit.runner.RunWith + +@RunWith(FuelTestRunner::class) +class ProcessResultTest { + + @get:Rule + val output: SystemOutRule = SystemOutRule().enableLog() + + private val ctx: IntegrationContext + get() = IntegrationContext( + result = ITResults.FAILURE, + token = "success", + url = "http://any.url", + runID = "123abc", + lastRun = "2000-10-10T12:33:17Z", + openedIssue = null + ) + + + @Test + fun `should create new issue for failed result`() { + runBlocking { + ctx.createNewIssue() + + assertThat(output.log).contains(issueCreated) + } + } + + @Test + fun `should post new comment`() { + runBlocking { + ctx.copy(openedIssue = 123).postComment() + + assertThat(output.log).contains(commentPosted) + } + } + + @Test + fun `should close issue`() { + runBlocking { + ctx.copy(openedIssue = 123, result = ITResults.SUCCESS).closeIssue() + + assertThat(output.log).contains(issueClosed) + } + } +} + +private val issueCreated = """ + ** Creating new issue + { + "title": "Full Suite integration tests failed on master", + "body": "### Integration Test failed on master", + "labels": [ + "IT_Failed", + "bug" + ] + } + ** Issue created: + url: https://bla.org + number: 123 + ** Comment posted + { + "body": "### Full suite IT run :x: FAILED :x:\n**Timestamp:** 2000-10-10 12:33:17\n**Job run:** [123abc](https://github.com/Flank/flank/actions/runs/123abc\n**Build scan URL:** http://any.url\n|commit SHA|PR|\n|---|:---:|\n|aaaaaaaaa|[feat: new Feature](www.pull.request)\n" + } +""".trimIndent() + +private val commentPosted = """ + ** Comment posted + { + "body": "### Full suite IT run :x: FAILED :x:\n**Timestamp:** 2000-10-10 12:33:17\n**Job run:** [123abc](https://github.com/Flank/flank/actions/runs/123abc\n**Build scan URL:** http://any.url\n|commit SHA|PR|\n|---|:---:|\n|aaaaaaaaa|[feat: new Feature](www.pull.request)\n" + } +""".trimIndent() + +private val issueClosed = """ + ** Comment posted + { + "body": "### Full suite IT run :white_check_mark: SUCCEEDED :white_check_mark:\n**Timestamp:** 2000-10-10 12:33:17\n**Job run:** [123abc](https://github.com/Flank/flank/actions/runs/123abc\n**Build scan URL:** http://any.url\n**Closing issue**" + } + ** Closing issue +""".trimIndent()