diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 5d97eea..606e9c8 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -1,8 +1,11 @@ package com.wire.github +import com.wire.github.github.GitHubAppClient import com.wire.github.github.GitHubPullRequestLinkParser +import com.wire.github.github.GitHubPullRequestReference import com.wire.github.github.GitHubRepository import com.wire.github.github.GitHubWebhookManager +import com.wire.github.util.TemplateHandler import com.wire.sdk.WireEventsHandlerSuspending import com.wire.sdk.model.Conversation import com.wire.sdk.model.ConversationMember @@ -14,6 +17,8 @@ import org.slf4j.LoggerFactory class EventsHandler : WireEventsHandlerSuspending() { private val logger = LoggerFactory.getLogger(this::class.java) private val gitHubWebhookManager by lazy { GlobalContext.get().get() } + private val gitHubAppClient by lazy { GlobalContext.get().get() } + private val templateHandler by lazy { GlobalContext.get().get() } override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) { if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) { @@ -32,20 +37,26 @@ class EventsHandler : WireEventsHandlerSuspending() { ) return } - val repositories = GitHubPullRequestLinkParser.parse(wireMessage.text) - if (repositories.isEmpty()) { + val pullRequests = GitHubPullRequestLinkParser.parse(wireMessage.text) + if (pullRequests.isEmpty()) { return } + val conversationId = wireMessage.conversationId registerRepositories( - repositories = repositories, - conversationId = wireMessage.conversationId - )?.let { message -> + repositories = pullRequests.map { it.repository }.toSet(), + conversationId = conversationId + )?.let { statusMessage -> sendMessage( - conversationId = wireMessage.conversationId, - text = message + conversationId = conversationId, + text = statusMessage ) } + + announcePullRequests( + pullRequests = pullRequests, + conversationId = conversationId + ) } override suspend fun onAppAddedToConversation( @@ -104,6 +115,33 @@ class EventsHandler : WireEventsHandlerSuspending() { return results.takeIf { it.isNotEmpty() }?.joinToString("\n") } + private fun announcePullRequests( + pullRequests: Set, + conversationId: QualifiedId + ) { + pullRequests.forEach { reference -> + val pullRequest = runCatching { + gitHubAppClient.fetchPullRequest( + repository = reference.repository, + number = reference.number + ) + }.onFailure { exception -> + logger.warn( + "Failed to fetch pull request " + + "${reference.repository.fullName}#${reference.number}", + exception + ) + }.getOrNull() ?: return@forEach + + templateHandler.renderPullRequest(pullRequest)?.let { message -> + sendMessage( + conversationId = conversationId, + text = message + ) + } + } + } + private fun sendMessage( conversationId: QualifiedId, text: String diff --git a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt index 00af867..c02c3f3 100644 --- a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt +++ b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt @@ -1,5 +1,6 @@ package com.wire.github.github +import com.wire.github.response.model.PullRequest import com.wire.github.util.ENV_VAR_GITHUB_CLIENT_ID import com.wire.github.util.ENV_VAR_GITHUB_PRIVATE_KEY import com.wire.github.util.KtxSerializer @@ -60,6 +61,30 @@ class GitHubAppClient( ) } + fun fetchPullRequest( + repository: GitHubRepository, + number: Int + ): PullRequest { + val token = createInstallationAccessToken(repository) + val response = send( + request = requestBuilder( + "/repos/${repository.owner}/${repository.name}/pulls/$number" + ).GET() + .withBearer(token) + .build() + ) + + require(response.statusCode() in SUCCESS_STATUS_CODES) { + "GitHub failed to fetch pull request ${repository.fullName}#$number: " + + "${response.statusCode()} ${response.body()}" + } + + return KtxSerializer.json.decodeFromString( + PullRequest.serializer(), + response.body() + ) + } + fun deleteRepositoryWebhook( repository: GitHubRepository, webhookId: Long diff --git a/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt b/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt index 67a49a3..b425569 100644 --- a/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt +++ b/src/main/kotlin/com/wire/github/github/GitHubPullRequestLinkParser.kt @@ -1,13 +1,19 @@ package com.wire.github.github object GitHubPullRequestLinkParser { - fun parse(text: String): Set = + fun parse(text: String): Set = pullRequestUrlRegex .findAll(text) - .map { match -> - GitHubRepository( - owner = match.groups[OWNER_GROUP]?.value.orEmpty(), - name = match.groups[REPOSITORY_GROUP]?.value.orEmpty() + .mapNotNull { match -> + val number = match.groups[NUMBER_GROUP]?.value?.toIntOrNull() + ?: return@mapNotNull null + + GitHubPullRequestReference( + repository = GitHubRepository( + owner = match.groups[OWNER_GROUP]?.value.orEmpty(), + name = match.groups[REPOSITORY_GROUP]?.value.orEmpty() + ), + number = number ) }.toSet() @@ -15,9 +21,10 @@ object GitHubPullRequestLinkParser { Regex( """https://github\.com/""" + """(?[A-Za-z0-9_.-]+)/""" + - """(?[A-Za-z0-9_.-]+)/pull/\d+\b""" + """(?[A-Za-z0-9_.-]+)/pull/(?\d+)\b""" ) private const val OWNER_GROUP = "owner" private const val REPOSITORY_GROUP = "repository" + private const val NUMBER_GROUP = "number" } diff --git a/src/main/kotlin/com/wire/github/github/GitHubPullRequestReference.kt b/src/main/kotlin/com/wire/github/github/GitHubPullRequestReference.kt new file mode 100644 index 0000000..7ce0fcb --- /dev/null +++ b/src/main/kotlin/com/wire/github/github/GitHubPullRequestReference.kt @@ -0,0 +1,6 @@ +package com.wire.github.github + +data class GitHubPullRequestReference( + val repository: GitHubRepository, + val number: Int +) diff --git a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt index 2c79eda..ec572fd 100644 --- a/src/main/kotlin/com/wire/github/util/TemplateHandler.kt +++ b/src/main/kotlin/com/wire/github/util/TemplateHandler.kt @@ -4,6 +4,7 @@ import com.github.mustachejava.DefaultMustacheFactory import com.github.mustachejava.Mustache import com.github.mustachejava.MustacheNotFoundException import com.wire.github.response.model.GitHubResponse +import com.wire.github.response.model.PullRequest import java.io.PrintWriter import java.io.StringWriter import java.util.Locale @@ -22,20 +23,38 @@ class TemplateHandler { response = response ) ?: return null - return try { - val template = compileTemplate( - path = path - ) + return render( + path = path, + model = response + ) + } + + /** + * Renders the message for a pull request fetched directly (e.g. when its link is + * pasted into the chat) rather than received via a webhook event. + */ + fun renderPullRequest(pullRequest: PullRequest): String? = + render( + path = actionTemplatePath( + event = PULL_REQUEST_EVENT, + action = LINKED_ACTION + ), + model = mapOf(PULL_REQUEST_MODEL_KEY to pullRequest) + ) + private fun render( + path: String, + model: Any + ): String? = + try { populateTemplate( - mustache = template, - model = response + mustache = compileTemplate(path = path), + model = model )?.trim()?.takeIf { it.isNotBlank() } } catch (exception: MustacheNotFoundException) { logger.error("MustacheNotFoundException: $exception") null } - } private fun templatePath( event: String, @@ -78,7 +97,7 @@ class TemplateHandler { private fun populateTemplate( mustache: Mustache, - model: GitHubResponse + model: Any ): String? = StringWriter() .apply { @@ -88,6 +107,9 @@ class TemplateHandler { private companion object { const val LANGUAGE_ENGLISH = "en" const val TEMPLATE_DIRECTORY = "templates" + const val PULL_REQUEST_EVENT = "pull_request" + const val LINKED_ACTION = "linked" + const val PULL_REQUEST_MODEL_KEY = "pullRequest" const val EVENT_WORKFLOW_RUN = "workflow_run" } } diff --git a/src/main/resources/templates/en/pull_request.linked.template b/src/main/resources/templates/en/pull_request.linked.template new file mode 100644 index 0000000..4b34b01 --- /dev/null +++ b/src/main/resources/templates/en/pull_request.linked.template @@ -0,0 +1,7 @@ +{{! Rendered when a PR link is pasted into the chat — an active fetch of the PR's current state, not a GitHub webhook event. }} +{{! Always announces the PR. The body is shown only when the PR is not a draft; a draft shows the header alone, and its body arrives later via pull_request.ready_for_review once it is marked ready. }} +[**{{pullRequest.title}}**]({{pullRequest.htmlUrl}}) (+{{pullRequest.additions}}/-{{pullRequest.deletions}}) by **@{{pullRequest.user.login}}** {{#pullRequest.draft}}(**DRAFT**){{/pullRequest.draft}} +{{^pullRequest.draft}} + +{{{pullRequest.body}}} +{{/pullRequest.draft}}