diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index cec529b..539c1c2 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -7,7 +7,9 @@ import com.wire.github.util.KtxSerializer import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk +import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage +import com.wire.sdk.service.WireApplicationManager import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application @@ -21,6 +23,7 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing import kotlinx.serialization.ExperimentalSerializationApi +import java.util.UUID import org.koin.core.context.GlobalContext @OptIn(ExperimentalSerializationApi::class) @@ -81,19 +84,109 @@ fun Application.configureRouting() { ) messageTemplate?.let { message -> - gitHubWebhookManager - .conversationsForRepository(response.repository.fullName) - .forEach { conversationId -> - wireAppSdk.getApplicationManager().sendMessage( - message = WireMessage.Text.create( - conversationId = conversationId, - text = message - ) - ) - } + wireAppSdk.sendGitHubMessage( + gitHubWebhookManager = gitHubWebhookManager, + event = event, + response = response, + text = message + ) } return@post call.response.status(HttpStatusCode.OK) } } } + +private fun WireAppSdk.sendGitHubMessage( + gitHubWebhookManager: GitHubWebhookManager, + event: String, + response: GitHubResponse, + text: String +) { + gitHubWebhookManager + .conversationsForRepository(response.repository.fullName) + .forEach { conversationId -> + sendGitHubMessage( + gitHubWebhookManager = gitHubWebhookManager, + event = event, + response = response, + conversationId = conversationId, + text = text + ) + } +} + +private fun WireAppSdk.sendGitHubMessage( + gitHubWebhookManager: GitHubWebhookManager, + event: String, + response: GitHubResponse, + conversationId: QualifiedId, + text: String +) { + val workflowRunId = response.workflowRun?.id + val messageId = if (event == EVENT_WORKFLOW_RUN && workflowRunId != null) { + getApplicationManager().sendWorkflowRunMessage( + gitHubWebhookManager = gitHubWebhookManager, + repositoryFullName = response.repository.fullName, + workflowRunId = workflowRunId, + conversationId = conversationId, + text = text + ) + } else { + getApplicationManager().sendTextMessage( + conversationId = conversationId, + text = text + ) + } + + if (event == EVENT_WORKFLOW_RUN && workflowRunId != null) { + gitHubWebhookManager.rememberWorkflowRunMessageId( + fullName = response.repository.fullName, + workflowRunId = workflowRunId, + conversationId = conversationId, + messageId = messageId + ) + } +} + +private fun WireApplicationManager.sendWorkflowRunMessage( + gitHubWebhookManager: GitHubWebhookManager, + repositoryFullName: String, + workflowRunId: Long, + conversationId: QualifiedId, + text: String +): UUID { + val replacingMessageId = gitHubWebhookManager.workflowRunMessageId( + fullName = repositoryFullName, + workflowRunId = workflowRunId, + conversationId = conversationId + ) + + val message = replacingMessageId + ?.let { + WireMessage.TextEdited.create( + replacingMessageId = it, + conversationId = conversationId, + text = text + ) + } + ?: WireMessage.Text.create( + conversationId = conversationId, + text = text + ) + + return sendMessage(message = message) +} + +private fun WireApplicationManager.sendTextMessage( + conversationId: QualifiedId, + text: String +): UUID = + sendMessage( + message = WireMessage.Text.create( + conversationId = conversationId, + text = text + ) + ) + +private const val EVENT_WORKFLOW_RUN = "workflow_run" diff --git a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt index 00574cc..00af867 100644 --- a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt +++ b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt @@ -335,12 +335,10 @@ class GitHubAppClient( private fun repositoryEvents(): JsonArray = buildJsonArray { - add(JsonPrimitive("check_suite")) add(JsonPrimitive("issue_comment")) add(JsonPrimitive("pull_request")) add(JsonPrimitive("pull_request_review")) add(JsonPrimitive("pull_request_review_comment")) - add(JsonPrimitive("status")) add(JsonPrimitive("workflow_run")) } diff --git a/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt b/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt index 9ded774..ac796be 100644 --- a/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt +++ b/src/main/kotlin/com/wire/github/github/GitHubWebhookManager.kt @@ -94,6 +94,26 @@ class GitHubWebhookManager( storage.set(lastActivityKey(fullName), clock.millis().toString()) } + fun workflowRunMessageId( + fullName: String, + workflowRunId: Long, + conversationId: QualifiedId + ): UUID? = + storage + .get(workflowRunMessageIdKey(fullName, workflowRunId, conversationId)) + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + + fun rememberWorkflowRunMessageId( + fullName: String, + workflowRunId: Long, + conversationId: QualifiedId, + messageId: UUID + ) { + val key = workflowRunMessageIdKey(fullName, workflowRunId, conversationId) + storage.set(key, messageId.toString()) + storage.sadd(workflowRunMessageIdsKey(fullName), key) + } + fun cleanupInactiveRepositories() { val cutoff = clock.millis() - ENV_VAR_GITHUB_REPO_INACTIVITY_SECONDS * MILLIS_PER_SECOND storage.smembers(KNOWN_REPOSITORIES_KEY).forEach { fullName -> @@ -119,6 +139,10 @@ class GitHubWebhookManager( storage.del(webhookIdKey(fullName)) storage.del(conversationsKey(fullName)) storage.del(lastActivityKey(fullName)) + storage.smembers(workflowRunMessageIdsKey(fullName)).forEach { key -> + storage.del(key) + } + storage.del(workflowRunMessageIdsKey(fullName)) logger.info("Removed inactive GitHub webhook subscription for repository: $fullName") } @@ -149,6 +173,17 @@ class GitHubWebhookManager( private fun lastActivityKey(fullName: String): String = "$REPOSITORY_KEY_PREFIX:$fullName:last_activity" + private fun workflowRunMessageIdKey( + fullName: String, + workflowRunId: Long, + conversationId: QualifiedId + ): String = + "$REPOSITORY_KEY_PREFIX:$fullName:workflow_run:$workflowRunId:" + + conversationId.toStorageKey() + + private fun workflowRunMessageIdsKey(fullName: String): String = + "$REPOSITORY_KEY_PREFIX:$fullName:workflow_run_messages" + companion object { const val GITHUB_WEBHOOK_PATH = "github/webhook" diff --git a/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt b/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt deleted file mode 100644 index 7a4eea1..0000000 --- a/src/main/kotlin/com/wire/github/response/model/CheckSuite.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.wire.github.response.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class CheckSuite( - @SerialName("html_url") - val htmlUrl: String? = null, - @SerialName("head_branch") - val headBranch: String? = null, - @SerialName("head_sha") - val headSha: String? = null, - val status: String? = null, - val conclusion: String? = null -) { - val successful: Boolean - get() = conclusion == CHECK_SUITE_CONCLUSION_SUCCESS - - val failed: Boolean - get() = conclusion in CHECK_SUITE_FAILURE_CONCLUSIONS -} - -private const val CHECK_SUITE_CONCLUSION_SUCCESS = "success" - -private val CHECK_SUITE_FAILURE_CONCLUSIONS = setOf( - "failure", - "error", - "timed_out", - "action_required", - "startup_failure" -) diff --git a/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt b/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt index 0c5140d..ab40150 100644 --- a/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt +++ b/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt @@ -14,30 +14,9 @@ data class GitHubResponse( val sender: User, val compare: String? = null, val review: Review? = null, - @SerialName("check_suite") - val checkSuite: CheckSuite? = null, @SerialName("workflow_run") val workflowRun: WorkflowRun? = null, - val state: String? = null, - val context: String? = null, - val description: String? = null, - @SerialName("target_url") - val targetUrl: String? = null, - val sha: String? = null, val repository: Repository, val created: Boolean? = null, val deleted: Boolean? = null -) { - val statusSuccessful: Boolean - get() = state == GITHUB_STATUS_SUCCESS - - val statusFailed: Boolean - get() = state in GITHUB_STATUS_FAILURE_STATES -} - -private const val GITHUB_STATUS_SUCCESS = "success" - -private val GITHUB_STATUS_FAILURE_STATES = setOf( - "failure", - "error" ) diff --git a/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt index 7e97796..459c3cd 100644 --- a/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt +++ b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class WorkflowRun( + val id: Long? = null, val name: String? = null, @SerialName("html_url") val htmlUrl: String? = null, @@ -20,9 +21,23 @@ data class WorkflowRun( val failed: Boolean get() = conclusion in WORKFLOW_RUN_FAILURE_CONCLUSIONS + + val completed: Boolean + get() = status == WORKFLOW_RUN_STATUS_COMPLETED || conclusion != null + + val inProgress: Boolean + get() = !completed + + val cancelled: Boolean + get() = conclusion == WORKFLOW_RUN_CONCLUSION_CANCELLED + + val neutral: Boolean + get() = completed && !successful && !failed && !cancelled } private const val WORKFLOW_RUN_CONCLUSION_SUCCESS = "success" +private const val WORKFLOW_RUN_CONCLUSION_CANCELLED = "cancelled" +private const val WORKFLOW_RUN_STATUS_COMPLETED = "completed" private val WORKFLOW_RUN_FAILURE_CONCLUSIONS = setOf( "failure", diff --git a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt index 47c0519..2c79eda 100644 --- a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt +++ b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt @@ -42,17 +42,9 @@ class TemplateHandler { response: GitHubResponse ): String? = when (event) { - EVENT_CHECK_SUITE -> - response.checkSuite - ?.takeIf { it.successful || it.failed } - ?.let { eventTemplatePath(event) } - EVENT_STATUS -> - response - .takeIf { it.statusSuccessful || it.statusFailed } - ?.let { eventTemplatePath(event) } EVENT_WORKFLOW_RUN -> response.workflowRun - ?.takeIf { it.successful || it.failed } + ?.takeIf { it.id != null } ?.let { eventTemplatePath(event) } else -> response.action?.let { actionTemplatePath( @@ -96,8 +88,6 @@ class TemplateHandler { private companion object { const val LANGUAGE_ENGLISH = "en" const val TEMPLATE_DIRECTORY = "templates" - const val EVENT_CHECK_SUITE = "check_suite" - const val EVENT_STATUS = "status" const val EVENT_WORKFLOW_RUN = "workflow_run" } } diff --git a/src/main/resources/templates/en/check_suite.template b/src/main/resources/templates/en/check_suite.template deleted file mode 100644 index 1884105..0000000 --- a/src/main/resources/templates/en/check_suite.template +++ /dev/null @@ -1,12 +0,0 @@ -{{#checkSuite.successful}} -✅ **CI Passed!** -{{/checkSuite.successful}} -{{#checkSuite.failed}} -❌ **CI Failed!** -{{/checkSuite.failed}} - -📦 **Repository:** {{repository.fullName}} -{{#checkSuite.headBranch}}🌿 **Branch:** {{checkSuite.headBranch}} -{{/checkSuite.headBranch}}{{#checkSuite.headSha}}🔖 **Commit:** `{{checkSuite.headSha}}` -{{/checkSuite.headSha}}{{#checkSuite.htmlUrl}}🔗 **Checks:** {{checkSuite.htmlUrl}} -{{/checkSuite.htmlUrl}} diff --git a/src/main/resources/templates/en/status.template b/src/main/resources/templates/en/status.template deleted file mode 100644 index 9835c7f..0000000 --- a/src/main/resources/templates/en/status.template +++ /dev/null @@ -1,12 +0,0 @@ -{{#statusSuccessful}} -✅ **CI Passed!**{{#context}} {{context}}{{/context}} -{{/statusSuccessful}} -{{#statusFailed}} -❌ **CI Failed!**{{#context}} {{context}}{{/context}} -{{/statusFailed}} - -📦 **Repository:** {{repository.fullName}} -{{#description}}📝 **Status:** {{description}} -{{/description}}{{#sha}}🔖 **Commit:** `{{sha}}` -{{/sha}}{{#targetUrl}}🔗 **Details:** {{targetUrl}} -{{/targetUrl}} diff --git a/src/main/resources/templates/en/workflow_run.template b/src/main/resources/templates/en/workflow_run.template index dd1d523..bb3d52e 100644 --- a/src/main/resources/templates/en/workflow_run.template +++ b/src/main/resources/templates/en/workflow_run.template @@ -1,11 +1,23 @@ +{{#workflowRun.inProgress}} +⏳ **CI Running**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} +{{/workflowRun.inProgress}} {{#workflowRun.successful}} ✅ **CI Passed!**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} {{/workflowRun.successful}} {{#workflowRun.failed}} ❌ **CI Failed!**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} {{/workflowRun.failed}} +{{#workflowRun.cancelled}} +⚪ **CI Cancelled**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}} +{{/workflowRun.cancelled}} +{{#workflowRun.neutral}} +⚪ **CI Completed**{{#workflowRun.name}} {{workflowRun.name}}{{/workflowRun.name}}{{#workflowRun.conclusion}} ({{workflowRun.conclusion}}){{/workflowRun.conclusion}} +{{/workflowRun.neutral}} 📦 **Repository:** {{repository.fullName}} +{{#workflowRun.status}}📋 **Status:** {{workflowRun.status}} +{{/workflowRun.status}}{{#workflowRun.conclusion}}🏁 **Conclusion:** {{workflowRun.conclusion}} +{{/workflowRun.conclusion}} {{#workflowRun.headBranch}}🌿 **Branch:** {{workflowRun.headBranch}} {{/workflowRun.headBranch}}{{#workflowRun.headSha}}🔖 **Commit:** `{{workflowRun.headSha}}` {{/workflowRun.headSha}}{{#workflowRun.htmlUrl}}🔗 **Run:** {{workflowRun.htmlUrl}} diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index 5a0720c..4968946 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -5,7 +5,10 @@ import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId +import com.wire.sdk.model.WireMessage +import com.wire.sdk.service.WireApplicationManager import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -13,7 +16,6 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.server.testing.testApplication -import io.ktor.client.request.header import io.lettuce.core.RedisClient import io.lettuce.core.api.StatefulRedisConnection import io.lettuce.core.api.sync.RedisCommands @@ -30,6 +32,7 @@ import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module +import kotlin.test.assertIs class ApplicationTest { @BeforeTest @@ -157,16 +160,198 @@ class ApplicationTest { } } + @Test + fun `given workflow run has no stored message, then sends text message and stores id`() { + val signatureValidator = mockk() + every { + signatureValidator.isValid( + signature = any(), + payload = WORKFLOW_RUN_PAYLOAD, + secret = DUMMY_WEBHOOK_SECRET + ) + } returns true + + val sentMessageId = UUID.randomUUID() + val applicationManager = mockk() + val sentMessage = io.mockk.slot() + every { + applicationManager.sendMessage( + message = capture(sentMessage) + ) + } returns sentMessageId + + val wireAppSdk = mockk() + every { wireAppSdk.getApplicationManager() } returns applicationManager + + val templateHandler = mockk() + every { + templateHandler.handleEvent( + event = EVENT_WORKFLOW_RUN, + response = any() + ) + } returns DUMMY_TEMPLATE + + val gitHubWebhookManager = mockk() + every { + gitHubWebhookManager.conversationsForRepository(REPOSITORY_FULL_NAME) + } returns listOf(CONVERSATION_ID) + justRun { gitHubWebhookManager.markRepositoryActive(REPOSITORY_FULL_NAME) } + every { + gitHubWebhookManager.workflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID + ) + } returns null + justRun { + gitHubWebhookManager.rememberWorkflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID, + messageId = sentMessageId + ) + } + + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + single { templateHandler } + single { gitHubWebhookManager } + } + ) + + testApplication { + application { + configureRouting() + } + + val response = client.post("/github/webhook") { + contentType(ContentType.Application.Json) + + header("X-GitHub-Event", EVENT_WORKFLOW_RUN) + header("X-Hub-Signature-256", "sha256=$DUMMY_SIGNATURE") + header("X-GitHub-Delivery", "delivery") + + setBody(WORKFLOW_RUN_PAYLOAD) + } + + assertEquals(HttpStatusCode.OK, response.status) + assertIs(sentMessage.captured) + verify(exactly = 1) { + gitHubWebhookManager.rememberWorkflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID, + messageId = sentMessageId + ) + } + } + } + + @Test + fun `given workflow run has stored message, then edits message and stores new id`() { + val signatureValidator = mockk() + every { + signatureValidator.isValid( + signature = any(), + payload = WORKFLOW_RUN_PAYLOAD, + secret = DUMMY_WEBHOOK_SECRET + ) + } returns true + + val storedMessageId = UUID.randomUUID() + val editedMessageId = UUID.randomUUID() + val applicationManager = mockk() + val sentMessage = io.mockk.slot() + every { + applicationManager.sendMessage( + message = capture(sentMessage) + ) + } returns editedMessageId + + val wireAppSdk = mockk() + every { wireAppSdk.getApplicationManager() } returns applicationManager + + val templateHandler = mockk() + every { + templateHandler.handleEvent( + event = EVENT_WORKFLOW_RUN, + response = any() + ) + } returns DUMMY_TEMPLATE + + val gitHubWebhookManager = mockk() + every { + gitHubWebhookManager.conversationsForRepository(REPOSITORY_FULL_NAME) + } returns listOf(CONVERSATION_ID) + justRun { gitHubWebhookManager.markRepositoryActive(REPOSITORY_FULL_NAME) } + every { + gitHubWebhookManager.workflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID + ) + } returns storedMessageId + justRun { + gitHubWebhookManager.rememberWorkflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID, + messageId = editedMessageId + ) + } + + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + single { templateHandler } + single { gitHubWebhookManager } + } + ) + + testApplication { + application { + configureRouting() + } + + val response = client.post("/github/webhook") { + contentType(ContentType.Application.Json) + + header("X-GitHub-Event", EVENT_WORKFLOW_RUN) + header("X-Hub-Signature-256", "sha256=$DUMMY_SIGNATURE") + header("X-GitHub-Delivery", "delivery") + + setBody(WORKFLOW_RUN_PAYLOAD) + } + + assertEquals(HttpStatusCode.OK, response.status) + val editedMessage = assertIs(sentMessage.captured) + assertEquals(storedMessageId, editedMessage.replacingMessageId) + verify(exactly = 1) { + gitHubWebhookManager.rememberWorkflowRunMessageId( + fullName = REPOSITORY_FULL_NAME, + workflowRunId = WORKFLOW_RUN_ID, + conversationId = CONVERSATION_ID, + messageId = editedMessageId + ) + } + } + } + private companion object { val CONVERSATION_ID = QualifiedId( id = UUID.randomUUID(), domain = "conv_domain" ) const val DUMMY_EVENT = "pull_request" + const val EVENT_WORKFLOW_RUN = "workflow_run" const val DUMMY_SIGNATURE = "dummySignature" const val DUMMY_TEMPLATE = "dummyTemplate" const val DUMMY_WEBHOOK_SECRET = "dummyWebhookSecret" const val REPOSITORY_FULL_NAME = "dummy_repository_full_name" + const val WORKFLOW_RUN_ID = 1234L val DUMMY_PAYLOAD = """ { "action": "created", @@ -181,5 +366,27 @@ class ApplicationTest { "deleted": false } """.trimIndent() + val WORKFLOW_RUN_PAYLOAD = """ + { + "action": "in_progress", + "workflow_run": { + "id": $WORKFLOW_RUN_ID, + "name": "Build", + "html_url": "https://github.com/wireapp/github-app/actions/runs/$WORKFLOW_RUN_ID", + "head_branch": "main", + "head_sha": "1234567890abcdef", + "status": "in_progress", + "conclusion": null + }, + "sender": { + "avatar_url": "dummy_url", + "login": "dummy_login" + }, + "repository": { + "full_name": "$REPOSITORY_FULL_NAME", + "name": "repository_name" + } + } + """.trimIndent() } }