From 9ae4882da9aa346817c42e04da22f88f921a9b3b Mon Sep 17 00:00:00 2001 From: Peter Goodspeed-Niklaus Date: Thu, 18 Jun 2026 16:23:04 +0200 Subject: [PATCH 1/2] feat: actively fetch a PR on first paste If we only register webhooks on first pasting the PR link, we'll never receive the "pr opened" event. This fixes that by adding an active fetch in that case. On pasting a draft link, we respond with a summary so users have a heads-up, but then depend on the "ready for review" event to post the full thing. --- .../kotlin/com/wire/github/EventsHandler.kt | 57 ++++++++++++++++--- .../com/wire/github/github/GitHubAppClient.kt | 25 ++++++++ .../github/GitHubPullRequestLinkParser.kt | 19 +++++-- .../github/GitHubPullRequestReference.kt | 6 ++ .../com/wire/github/util/TemplateHandler.kt | 38 ++++++++++--- .../templates/en/pull_request.linked.template | 7 +++ 6 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/com/wire/github/github/GitHubPullRequestReference.kt create mode 100644 src/main/resources/templates/en/pull_request.linked.template diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 8f19a9e..3781c2a 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,27 @@ class EventsHandler : WireEventsHandlerSuspending() { ) return } - val repositories = GitHubPullRequestLinkParser.parse(wireMessage.text) - if (repositories.isEmpty()) { + val pullRequests = GitHubPullRequestLinkParser.parse(wireMessage.text) + if (pullRequests.isEmpty()) { return } - registerRepositories( - repositories = repositories, - conversationId = wireMessage.conversationId - )?.let { message -> + val conversationId = wireMessage.conversationId + val statusMessage = registerRepositories( + repositories = pullRequests.map { it.repository }.toSet(), + conversationId = conversationId + ) + if (statusMessage.isNotBlank()) { sendMessage( - conversationId = wireMessage.conversationId, - text = message + conversationId = conversationId, + text = statusMessage ) } + + announcePullRequests( + pullRequests = pullRequests, + conversationId = conversationId + ) } override suspend fun onAppAddedToConversation( @@ -101,7 +113,34 @@ class EventsHandler : WireEventsHandlerSuspending() { ) } - return results.joinToString(separator = "\n") + return results.filterNotNull().joinToString(separator = "\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( diff --git a/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt b/src/main/kotlin/com/wire/github/github/GitHubAppClient.kt index 00574cc..ac2bdc3 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 47c0519..f66ff6b 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, @@ -86,7 +105,7 @@ class TemplateHandler { private fun populateTemplate( mustache: Mustache, - model: GitHubResponse + model: Any ): String? = StringWriter() .apply { @@ -96,6 +115,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_CHECK_SUITE = "check_suite" const val EVENT_STATUS = "status" 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..9321b36 --- /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}} + +{{{pullRequest.body}}} +{{/pullRequest.draft}} From 93cfa9f059acd51f8439a446b1fee2d2795aa826 Mon Sep 17 00:00:00 2001 From: Peter Goodspeed-Niklaus Date: Thu, 18 Jun 2026 16:36:16 +0200 Subject: [PATCH 2/2] feat: ensure linked draft PR is marked as one --- src/main/resources/templates/en/pull_request.linked.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/en/pull_request.linked.template b/src/main/resources/templates/en/pull_request.linked.template index 9321b36..4b34b01 100644 --- a/src/main/resources/templates/en/pull_request.linked.template +++ b/src/main/resources/templates/en/pull_request.linked.template @@ -1,6 +1,6 @@ {{! 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.title}}**]({{pullRequest.htmlUrl}}) (+{{pullRequest.additions}}/-{{pullRequest.deletions}}) by **@{{pullRequest.user.login}}** {{#pullRequest.draft}}(**DRAFT**){{/pullRequest.draft}} {{^pullRequest.draft}} {{{pullRequest.body}}}