Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions src/main/kotlin/com/wire/github/EventsHandler.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<GitHubWebhookManager>() }
private val gitHubAppClient by lazy { GlobalContext.get().get<GitHubAppClient>() }
private val templateHandler by lazy { GlobalContext.get().get<TemplateHandler>() }

override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) {
if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) {
Expand All @@ -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(
Expand Down Expand Up @@ -104,6 +115,33 @@ class EventsHandler : WireEventsHandlerSuspending() {
return results.takeIf { it.isNotEmpty() }?.joinToString("\n")
}

private fun announcePullRequests(
pullRequests: Set<GitHubPullRequestReference>,
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
Expand Down
25 changes: 25 additions & 0 deletions src/main/kotlin/com/wire/github/github/GitHubAppClient.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
package com.wire.github.github

object GitHubPullRequestLinkParser {
fun parse(text: String): Set<GitHubRepository> =
fun parse(text: String): Set<GitHubPullRequestReference> =
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()

private val pullRequestUrlRegex =
Regex(
"""https://github\.com/""" +
"""(?<owner>[A-Za-z0-9_.-]+)/""" +
"""(?<repository>[A-Za-z0-9_.-]+)/pull/\d+\b"""
"""(?<repository>[A-Za-z0-9_.-]+)/pull/(?<number>\d+)\b"""
)

private const val OWNER_GROUP = "owner"
private const val REPOSITORY_GROUP = "repository"
private const val NUMBER_GROUP = "number"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.wire.github.github

data class GitHubPullRequestReference(
val repository: GitHubRepository,
val number: Int
)
38 changes: 30 additions & 8 deletions src/main/kotlin/com/wire/github/util/TemplateHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -78,7 +97,7 @@ class TemplateHandler {

private fun populateTemplate(
mustache: Mustache,
model: GitHubResponse
model: Any
): String? =
StringWriter()
.apply {
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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}}