diff --git a/README.md b/README.md index 5953749..33760bf 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,19 @@ Here's a list of features included in this project: | /health | Healthcheck endpoint returning HTTP OK 200. | | /{conversation_id}/{conversation_domain} | Webhook endpoint. | +### GitHub Actions notifications + +The existing webhook endpoint supports centralized notifications for these GitHub Actions events: + +| GitHub event | Supported action | Notification | +|--------------|------------------|--------------| +| Workflow runs (`workflow_run`) | `completed` | One summary for the completed workflow run. | +| Workflow jobs (`workflow_job`) | `completed` | One notification for each completed job in the workflow run. | + +All completed conclusions are reported, including successful, failed, cancelled, skipped, and timed-out +results when GitHub provides them. Other actions, such as `requested`, `queued`, and `in_progress`, are +accepted by the endpoint but do not produce a Wire message. + ## Building & Running To build or run the project, you can use the IDE Run configuration with environment variables. diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 55fcf26..94be414 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -96,7 +96,10 @@ class EventsHandler : WireEventsHandlerSuspending() { "2. Go to **Settings / Webhooks / Add webhook**\n" + "3. Add **Payload URL**: %s\n" + "4. Set **Content-Type**: application/json\n" + - "5. Set **Secret**: %s", + "5. Set **Secret**: %s\n" + + "6. Select the repository events you want to receive\n" + + "7. For GitHub Actions notifications, select **Workflow runs** and " + + "**Workflow jobs**. Completed results will be sent to this conversation.", url, generatedSecret ) diff --git a/src/main/kotlin/com/wire/github/response/model/Conclusion.kt b/src/main/kotlin/com/wire/github/response/model/Conclusion.kt new file mode 100644 index 0000000..06e73f7 --- /dev/null +++ b/src/main/kotlin/com/wire/github/response/model/Conclusion.kt @@ -0,0 +1,18 @@ +package com.wire.github.response.model + +internal data class Conclusion( + val emoji: String, + val text: String +) + +internal fun conclusionPresentation(conclusion: String?): Conclusion = + when (conclusion) { + "success" -> Conclusion(emoji = "✅", text = "succeeded") + "failure" -> Conclusion(emoji = "❌", text = "failed") + "cancelled" -> Conclusion(emoji = "đŸšĢ", text = "cancelled") + "timed_out" -> Conclusion(emoji = "âąī¸", text = "timed out") + "action_required" -> Conclusion(emoji = "âš ī¸", text = "requires action") + "skipped" -> Conclusion(emoji = "â­ī¸", text = "skipped") + "stale" -> Conclusion(emoji = "💤", text = "stale") + else -> Conclusion(emoji = "â„šī¸", text = "completed") + } 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 7be3b07..6887bda 100644 --- a/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt +++ b/src/main/kotlin/com/wire/github/response/model/GitHubResponse.kt @@ -15,6 +15,10 @@ data class GitHubResponse( val compare: String? = null, val review: Review? = null, val repository: Repository, + @SerialName("workflow_run") + val workflowRun: WorkflowRun? = null, + @SerialName("workflow_job") + val workflowJob: WorkflowJob? = null, val created: Boolean? = null, val deleted: Boolean? = null ) diff --git a/src/main/kotlin/com/wire/github/response/model/WorkflowJob.kt b/src/main/kotlin/com/wire/github/response/model/WorkflowJob.kt new file mode 100644 index 0000000..e7f9fad --- /dev/null +++ b/src/main/kotlin/com/wire/github/response/model/WorkflowJob.kt @@ -0,0 +1,25 @@ +package com.wire.github.response.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WorkflowJob( + val name: String? = null, + val conclusion: String? = null, + @SerialName("html_url") + val htmlUrl: String? = null, + @SerialName("workflow_name") + val workflowName: String? = null, + @SerialName("head_branch") + val headBranch: String? = null +) { + val emoji: String + get() = conclusionPresentation(conclusion).emoji + + val conclusionText: String + get() = conclusionPresentation(conclusion).text + + val displayName: String + get() = name?.takeIf { it.isNotBlank() } ?: "Workflow job" +} diff --git a/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt new file mode 100644 index 0000000..fbfe38c --- /dev/null +++ b/src/main/kotlin/com/wire/github/response/model/WorkflowRun.kt @@ -0,0 +1,27 @@ +package com.wire.github.response.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WorkflowRun( + val name: String? = null, + val conclusion: String? = null, + val event: String? = null, + val actor: User? = null, + @SerialName("head_branch") + val headBranch: String? = null, + @SerialName("html_url") + val htmlUrl: String? = null, + @SerialName("run_number") + val runNumber: Int? = null +) { + val emoji: String + get() = conclusionPresentation(conclusion).emoji + + val conclusionText: String + get() = conclusionPresentation(conclusion).text + + val displayName: String + get() = name?.takeIf { it.isNotBlank() } ?: "Workflow run" +} diff --git a/src/main/resources/templates/en/workflow_job.completed.template b/src/main/resources/templates/en/workflow_job.completed.template new file mode 100644 index 0000000..297a245 --- /dev/null +++ b/src/main/resources/templates/en/workflow_job.completed.template @@ -0,0 +1,5 @@ +{{workflowJob.emoji}} **Job {{workflowJob.conclusionText}}:** {{{workflowJob.displayName}}}{{#workflowJob.htmlUrl}} — [view job]({{workflowJob.htmlUrl}}){{/workflowJob.htmlUrl}} +**Repository:** {{repository.fullName}} +{{#workflowJob.workflowName}}**Workflow:** {{{workflowJob.workflowName}}} +{{/workflowJob.workflowName}}{{#workflowJob.headBranch}}**Branch:** `{{workflowJob.headBranch}}` +{{/workflowJob.headBranch}} diff --git a/src/main/resources/templates/en/workflow_run.completed.template b/src/main/resources/templates/en/workflow_run.completed.template new file mode 100644 index 0000000..ee56c73 --- /dev/null +++ b/src/main/resources/templates/en/workflow_run.completed.template @@ -0,0 +1,6 @@ +{{workflowRun.emoji}} **Workflow {{workflowRun.conclusionText}}:** {{{workflowRun.displayName}}}{{#workflowRun.runNumber}} #{{workflowRun.runNumber}}{{/workflowRun.runNumber}}{{#workflowRun.htmlUrl}} — [view run]({{workflowRun.htmlUrl}}){{/workflowRun.htmlUrl}} +**Repository:** {{repository.fullName}} +{{#workflowRun.headBranch}}**Branch:** `{{workflowRun.headBranch}}` +{{/workflowRun.headBranch}}{{#workflowRun.event}}**Trigger:** {{workflowRun.event}} +{{/workflowRun.event}}{{#workflowRun.actor}}**Started by:** {{workflowRun.actor.login}} +{{/workflowRun.actor}} diff --git a/src/test/fixtures/events/workflow_job.completed.json b/src/test/fixtures/events/workflow_job.completed.json new file mode 100644 index 0000000..dd0091f --- /dev/null +++ b/src/test/fixtures/events/workflow_job.completed.json @@ -0,0 +1,18 @@ +{ + "action": "completed", + "workflow_job": { + "name": "unit-tests (JDK 21)", + "conclusion": "failure", + "html_url": "https://github.com/wireapp/example/actions/runs/123456789/job/987654321", + "workflow_name": "Build and test", + "head_branch": "feature/actions-notifications" + }, + "repository": { + "name": "example", + "full_name": "wireapp/example" + }, + "sender": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + } +} diff --git a/src/test/fixtures/events/workflow_job.completed.minimal.json b/src/test/fixtures/events/workflow_job.completed.minimal.json new file mode 100644 index 0000000..19b4b98 --- /dev/null +++ b/src/test/fixtures/events/workflow_job.completed.minimal.json @@ -0,0 +1,14 @@ +{ + "action": "completed", + "workflow_job": { + "conclusion": null + }, + "repository": { + "name": "example", + "full_name": "wireapp/example" + }, + "sender": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + } +} diff --git a/src/test/fixtures/events/workflow_run.completed.json b/src/test/fixtures/events/workflow_run.completed.json new file mode 100644 index 0000000..8e023d2 --- /dev/null +++ b/src/test/fixtures/events/workflow_run.completed.json @@ -0,0 +1,23 @@ +{ + "action": "completed", + "workflow_run": { + "name": "Build and test", + "conclusion": "success", + "event": "push", + "actor": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + }, + "head_branch": "main", + "html_url": "https://github.com/wireapp/example/actions/runs/123456789", + "run_number": 42 + }, + "repository": { + "name": "example", + "full_name": "wireapp/example" + }, + "sender": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + } +} diff --git a/src/test/fixtures/events/workflow_run.completed.minimal.json b/src/test/fixtures/events/workflow_run.completed.minimal.json new file mode 100644 index 0000000..e56e403 --- /dev/null +++ b/src/test/fixtures/events/workflow_run.completed.minimal.json @@ -0,0 +1,14 @@ +{ + "action": "completed", + "workflow_run": { + "conclusion": null + }, + "repository": { + "name": "example", + "full_name": "wireapp/example" + }, + "sender": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + } +} diff --git a/src/test/fixtures/messages/workflow_job.completed.minimal.txt b/src/test/fixtures/messages/workflow_job.completed.minimal.txt new file mode 100644 index 0000000..df77136 --- /dev/null +++ b/src/test/fixtures/messages/workflow_job.completed.minimal.txt @@ -0,0 +1,2 @@ +â„šī¸ **Job completed:** Workflow job +**Repository:** wireapp/example diff --git a/src/test/fixtures/messages/workflow_job.completed.txt b/src/test/fixtures/messages/workflow_job.completed.txt new file mode 100644 index 0000000..54f5a04 --- /dev/null +++ b/src/test/fixtures/messages/workflow_job.completed.txt @@ -0,0 +1,4 @@ +❌ **Job failed:** unit-tests (JDK 21) — [view job](https://github.com/wireapp/example/actions/runs/123456789/job/987654321) +**Repository:** wireapp/example +**Workflow:** Build and test +**Branch:** `feature/actions-notifications` diff --git a/src/test/fixtures/messages/workflow_run.completed.minimal.txt b/src/test/fixtures/messages/workflow_run.completed.minimal.txt new file mode 100644 index 0000000..8cf6150 --- /dev/null +++ b/src/test/fixtures/messages/workflow_run.completed.minimal.txt @@ -0,0 +1,2 @@ +â„šī¸ **Workflow completed:** Workflow run +**Repository:** wireapp/example diff --git a/src/test/fixtures/messages/workflow_run.completed.txt b/src/test/fixtures/messages/workflow_run.completed.txt new file mode 100644 index 0000000..78ac3e9 --- /dev/null +++ b/src/test/fixtures/messages/workflow_run.completed.txt @@ -0,0 +1,5 @@ +✅ **Workflow succeeded:** Build and test #42 — [view run](https://github.com/wireapp/example/actions/runs/123456789) +**Repository:** wireapp/example +**Branch:** `main` +**Trigger:** push +**Started by:** octocat diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index 9e7d198..a776ebe 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -290,6 +290,46 @@ class ApplicationTest { } } + @Test + fun `given completed GitHub Actions events, when received, then messages are sent`() { + val signatureValidator = mockk() + every { signatureValidator.isValid(any(), any(), any(), any()) } returns true + + val wireAppSdk = mockk() + every { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } returns UUID.randomUUID() + + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { + configureRouting() + } + + listOf("workflow_run", "workflow_job").forEach { event -> + val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("X-GitHub-Event", event) + header("X-Hub-Signature", "sha1=$DUMMY_SIGNATURE") + header("X-GitHub-Delivery", "delivery-$event") + setBody(TestFixtures.event("$event.completed")) + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + verify(exactly = 2) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + private companion object { val CONVERSATION_ID = QualifiedId( id = UUID.randomUUID(), diff --git a/src/test/kotlin/com/wire/github/TestFixtures.kt b/src/test/kotlin/com/wire/github/TestFixtures.kt new file mode 100644 index 0000000..6ff1889 --- /dev/null +++ b/src/test/kotlin/com/wire/github/TestFixtures.kt @@ -0,0 +1,13 @@ +package com.wire.github + +import java.io.File + +internal object TestFixtures { + private const val FIXTURES_DIRECTORY = "src/test/fixtures" + + fun event(name: String): String = read("events/$name.json") + + fun message(name: String): String = read("messages/$name.txt").trim() + + private fun read(path: String): String = File("$FIXTURES_DIRECTORY/$path").readText() +} diff --git a/src/test/kotlin/com/wire/github/util/TemplateHandlerTest.kt b/src/test/kotlin/com/wire/github/util/TemplateHandlerTest.kt index 33609eb..748545c 100644 --- a/src/test/kotlin/com/wire/github/util/TemplateHandlerTest.kt +++ b/src/test/kotlin/com/wire/github/util/TemplateHandlerTest.kt @@ -1,13 +1,17 @@ package com.wire.github.util +import com.wire.github.TestFixtures import com.wire.github.response.model.Commit import com.wire.github.response.model.GitHubResponse import com.wire.github.response.model.PullRequest import com.wire.github.response.model.Repository import com.wire.github.response.model.Review import com.wire.github.response.model.User +import com.wire.github.response.model.WorkflowJob +import com.wire.github.response.model.WorkflowRun import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertNull class TemplateHandlerTest { @@ -84,6 +88,122 @@ class TemplateHandlerTest { assertContains(message.orEmpty(), "Add feature") } + @Test + fun `renders a completed workflow run`() { + val message = renderFixture(event = "workflow_run") + + assertEquals(messageFixture("workflow_run.completed"), message?.trim()) + } + + @Test + fun `renders a completed workflow job`() { + val message = renderFixture(event = "workflow_job") + + assertEquals(messageFixture("workflow_job.completed"), message?.trim()) + } + + @Test + fun `does not render a workflow run for an unsupported action`() { + val response = KtxSerializer.json.decodeFromString( + TestFixtures + .event("workflow_run.completed") + .replace("\"action\": \"completed\"", "\"action\": \"in_progress\"") + ) + + val message = templateHandler.handleEvent( + event = "workflow_run", + response = response + ) + + assertNull(message) + } + + @Test + fun `does not render a workflow job for an unsupported action`() { + val response = KtxSerializer.json.decodeFromString( + TestFixtures + .event("workflow_job.completed") + .replace("\"action\": \"completed\"", "\"action\": \"in_progress\"") + ) + + val message = templateHandler.handleEvent( + event = "workflow_job", + response = response + ) + + assertNull(message) + } + + @Test + fun `maps workflow run conclusions to emojis`() { + mapOf( + "success" to ("✅" to "succeeded"), + "failure" to ("❌" to "failed"), + "cancelled" to ("đŸšĢ" to "cancelled"), + "timed_out" to ("âąī¸" to "timed out"), + "action_required" to ("âš ī¸" to "requires action"), + "skipped" to ("â­ī¸" to "skipped"), + "stale" to ("💤" to "stale"), + "neutral" to ("â„šī¸" to "completed") + ).forEach { (conclusion, presentation) -> + assertEquals(presentation.first, workflowRun(conclusion).emoji) + assertEquals(presentation.second, workflowRun(conclusion).conclusionText) + } + } + + @Test + fun `maps workflow job conclusions to emojis`() { + mapOf( + "success" to ("✅" to "succeeded"), + "failure" to ("❌" to "failed"), + "cancelled" to ("đŸšĢ" to "cancelled"), + "timed_out" to ("âąī¸" to "timed out"), + "action_required" to ("âš ī¸" to "requires action"), + "skipped" to ("â­ī¸" to "skipped"), + "stale" to ("💤" to "stale"), + "neutral" to ("â„šī¸" to "completed") + ).forEach { (conclusion, presentation) -> + assertEquals(presentation.first, workflowJob(conclusion).emoji) + assertEquals(presentation.second, workflowJob(conclusion).conclusionText) + } + } + + @Test + fun `renders a completed workflow run without optional fields`() { + val message = renderFixture( + event = "workflow_run", + fixtureName = "workflow_run.completed.minimal" + ) + + assertEquals(TestFixtures.message("workflow_run.completed.minimal"), message?.trim()) + } + + @Test + fun `renders a completed workflow job without optional fields`() { + val message = renderFixture( + event = "workflow_job", + fixtureName = "workflow_job.completed.minimal" + ) + + assertEquals(TestFixtures.message("workflow_job.completed.minimal"), message?.trim()) + } + + private fun renderFixture( + event: String, + fixtureName: String = "$event.completed" + ): String? { + val response = KtxSerializer.json.decodeFromString( + TestFixtures.event(fixtureName) + ) + + return templateHandler.handleEvent( + event = event, + response = response + ) + } + + private fun messageFixture(name: String): String = TestFixtures.message(name) + private fun reviewResponse( body: String?, state: String = "approved" @@ -118,6 +238,24 @@ class TemplateHandlerTest { ) ) + private fun workflowRun(conclusion: String?) = + WorkflowRun( + name = "Build and test", + conclusion = conclusion, + event = "push", + actor = user, + headBranch = "main", + htmlUrl = "https://github.com/wire/example/actions/runs/1", + runNumber = 1 + ) + + private fun workflowJob(conclusion: String?) = + WorkflowJob( + name = "unit-tests", + conclusion = conclusion, + htmlUrl = "https://github.com/wire/example/actions/runs/1/job/1" + ) + private companion object { val user = User( avatarUrl = "https://github.com/wire.png",