Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ Here's a list of features included in this project:
| Name | Description |
|------------------------------------------|--------------------------------------------|
| /health | Healthcheck endpoint returning HTTP OK 200. |
| /{conversation_id}/{conversation_domain} | Webhook endpoint. |
| /github/webhook | GitHub App repository webhook endpoint. |

When someone posts a GitHub pull request link in a Wire conversation, the app
creates a repository webhook for pull request events and routes future GitHub
events for that repository back to the conversation. Repository webhooks are
removed after the configured inactivity period.

## Building & Running

Expand All @@ -43,12 +48,22 @@ GHAPP_API_HOST=https://127.0.0.1/github
GHAPP_SERVER_PORT=8083
GHAPP_REDIS_HOST=redis://redis
GHAPP_REDIS_PORT=6380
GHAPP_GITHUB_CLIENT_ID=Iv1.example
GHAPP_GITHUB_PRIVATE_KEY_FILE=/run/secrets/github-app-private-key.pem
GHAPP_GITHUB_WEBHOOK_SECRET=exampleWebhookSecret
GHAPP_GITHUB_REPO_INACTIVITY_SECONDS=604800
WIRE_SDK_API_HOST=https://nginz-https.chala.wire.link
WIRE_SDK_API_TOKEN=myApiToken
WIRE_SDK_APP_ID=f562e146-dec2-4d85-93c7-7132746b5cca
WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
```

The GitHub App must be installed for the organization/repositories it should
manage and needs repository webhook write permissions. `GHAPP_GITHUB_CLIENT_ID`
and `GHAPP_GITHUB_PRIVATE_KEY_FILE` are used for GitHub App server-to-server
authentication; `GHAPP_GITHUB_WEBHOOK_SECRET` is configured on created
repository webhooks and used to validate incoming GitHub deliveries.

## Deployment
Currently, we are only deploying in our Integrations VM.

Expand Down
9 changes: 8 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,16 @@ tasks {
environment("GHAPP_SERVER_PORT", "8083")
environment("GHAPP_REDIS_HOST", "redis://localhost")
environment("GHAPP_REDIS_PORT", "6379")
environment("GHAPP_GITHUB_CLIENT_ID", "dummyClientId")
environment("GHAPP_GITHUB_PRIVATE_KEY_FILE", "/dev/null")
environment("GHAPP_GITHUB_WEBHOOK_SECRET", "dummyWebhookSecret")
environment("GHAPP_GITHUB_REPO_INACTIVITY_SECONDS", "604800")
environment("WIRE_SDK_API_HOST", "https://nginz-https.chala.wire.link")
environment("WIRE_SDK_API_TOKEN", "myApiToken")
environment("WIRE_SDK_APP_ID", "f562e146-dec2-4d85-93c7-7132746b5cca")
environment("WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
environment(
"WIRE_SDK_CRYPTOGRAPHY_STORAGE_KEY",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
)
}
}
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ services:
- GHAPP_SERVER_PORT=${GHAPP_SERVER_PORT}
- GHAPP_REDIS_HOST=${GHAPP_REDIS_HOST}
- GHAPP_REDIS_PORT=${GHAPP_REDIS_PORT}
- GHAPP_GITHUB_CLIENT_ID=${GHAPP_GITHUB_CLIENT_ID}
- GHAPP_GITHUB_PRIVATE_KEY_FILE=${GHAPP_GITHUB_PRIVATE_KEY_FILE}
- GHAPP_GITHUB_WEBHOOK_SECRET=${GHAPP_GITHUB_WEBHOOK_SECRET}
- GHAPP_GITHUB_REPO_INACTIVITY_SECONDS=${GHAPP_GITHUB_REPO_INACTIVITY_SECONDS}
- WIRE_SDK_API_HOST=${WIRE_SDK_API_HOST}
- WIRE_SDK_API_TOKEN=${WIRE_SDK_API_TOKEN}
- WIRE_SDK_APP_ID=${WIRE_SDK_APP_ID}
Expand Down
108 changes: 61 additions & 47 deletions src/main/kotlin/com/wire/github/EventsHandler.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
package com.wire.github

import com.wire.github.util.ENV_VAR_HOST
import com.wire.github.util.SessionIdentifierGenerator
import com.wire.github.util.toStorageKey
import com.wire.github.github.GitHubPullRequestLinkParser
import com.wire.github.github.GitHubRepository
import com.wire.github.github.GitHubWebhookManager
import com.wire.sdk.WireEventsHandlerSuspending
import com.wire.sdk.model.Conversation
import com.wire.sdk.model.ConversationMember
import com.wire.sdk.model.QualifiedId
import com.wire.sdk.model.WireMessage
import io.lettuce.core.api.StatefulRedisConnection
import org.koin.core.context.GlobalContext
import org.slf4j.LoggerFactory

class EventsHandler : WireEventsHandlerSuspending() {
private val logger = LoggerFactory.getLogger(this::class.java)
private val redisConnection = GlobalContext.get().get<StatefulRedisConnection<String, String>>()
private val storage = redisConnection.sync()
private val gitHubWebhookManager by lazy { GlobalContext.get().get<GitHubWebhookManager>() }

override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) {
if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) {
Expand All @@ -24,22 +22,30 @@ class EventsHandler : WireEventsHandlerSuspending() {
"conversationId: ${wireMessage.conversationId}, " +
"senderId: ${wireMessage.sender}"
)
val message = formatSetupInstructions(
sendMessage(
conversationId = wireMessage.conversationId,
secret = storage.get(wireMessage.conversationId.toStorageKey())
)

manager.sendMessage(
message = WireMessage.Text.create(
conversationId = wireMessage.conversationId,
text = message
)
text = USAGE_TEXT
)
logger.info(
"Event is processed successfully. Event: TextMessageReceived (HELP command), " +
"conversationId: ${wireMessage.conversationId}"
)
return
}

val repositories = GitHubPullRequestLinkParser.parse(wireMessage.text)
if (repositories.isEmpty()) {
return
}

val message = registerRepositories(
repositories = repositories,
conversationId = wireMessage.conversationId
)
sendMessage(
conversationId = wireMessage.conversationId,
text = message
)
}

override suspend fun onAppAddedToConversation(
Expand All @@ -52,62 +58,70 @@ class EventsHandler : WireEventsHandlerSuspending() {
)
val message = buildString {
appendLine(WELCOME_TEXT)
appendLine(formatSetupInstructions(conversationId = conversation.id))
appendLine(USAGE_TEXT)
appendLine()
append("Use the `$HELP_COMMAND` command to see the usage again.")
}

manager.sendMessage(
message = WireMessage.Text.create(
conversationId = conversation.id,
text = message
)
sendMessage(
conversationId = conversation.id,
text = message
)
logger.info(
"Event is processed successfully. Event: AppAddedToConversation, " +
"conversationId: ${conversation.id}"
)
}

private fun formatSetupInstructions(
conversationId: QualifiedId,
secret: String? = null
private fun registerRepositories(
repositories: Set<GitHubRepository>,
conversationId: QualifiedId
): String {
val generatedSecret = secret ?: run {
val generated = SessionIdentifierGenerator.generate()
storage.set(conversationId.toStorageKey(), generated)
generated
val results = repositories.map { repository ->
runCatching {
gitHubWebhookManager.ensureWebhookForConversation(
repository = repository,
conversationId = conversationId
)
}.fold(
onSuccess = {
"Receiving pull request notifications for `${repository.fullName}`."
},
onFailure = { exception ->
logger.warn(
"Failed to provision GitHub webhook for ${repository.fullName}",
exception
)
"Could not set up pull request notifications for `${repository.fullName}`."
}
)
}

val url = String.format(
HOST_URL_PATTERN,
ENV_VAR_HOST,
conversationId.id,
conversationId.domain
)
return results.joinToString(separator = "\n")
}

val setupInstructions = String.format(
"Here is how to set me up:\n\n" +
"1. Go to the repository that you would like to connect to\n" +
"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",
url,
generatedSecret
private fun sendMessage(
conversationId: QualifiedId,
text: String
) {
manager.sendMessage(
message = WireMessage.Text.create(
conversationId = conversationId,
text = text
)
)

return setupInstructions
}

private companion object {
const val HOST_URL_PATTERN = "%s/%s/%s"

const val WELCOME_TEXT =
"👋 Hi, I'm GitHub App. Thanks for adding me to the conversation.\n" +
"You can use me to receive GitHub notifications in Wire.\n" +
"I'm here to help make everyday work a little easier."

const val USAGE_TEXT =
"Post a GitHub pull request link in this conversation and I will set up " +
"pull request notifications for that repository."

const val HELP_COMMAND = "/github help"
}
}
56 changes: 21 additions & 35 deletions src/main/kotlin/com/wire/github/Routing.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.wire.github

import com.wire.github.github.GitHubWebhookManager
import com.wire.github.response.model.GitHubResponse
import com.wire.github.util.ENV_VAR_GITHUB_WEBHOOK_SECRET
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 io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
Expand All @@ -15,16 +16,13 @@ import io.ktor.server.application.log
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.application
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.util.UUID
import kotlinx.serialization.ExperimentalSerializationApi
import org.koin.core.context.GlobalContext

@Suppress("LongMethod")
@OptIn(ExperimentalSerializationApi::class)
fun Application.configureRouting() {
install(plugin = ContentNegotiation) {
Expand All @@ -34,6 +32,7 @@ fun Application.configureRouting() {
val wireAppSdk = GlobalContext.get().get<WireAppSdk>()
val signatureValidator = GlobalContext.get().get<SignatureValidator>()
val templateHandler = GlobalContext.get().get<TemplateHandler>()
val gitHubWebhookManager = GlobalContext.get().get<GitHubWebhookManager>()

routing {
trace {
Expand All @@ -44,47 +43,36 @@ fun Application.configureRouting() {
call.response.status(value = HttpStatusCode.OK)
}

post("/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") {
post("/${GitHubWebhookManager.GITHUB_WEBHOOK_PATH}") {
// Headers
val event = call.request.headers["X-GitHub-Event"]
val signature = call.request.headers["X-Hub-Signature"]
val signature = call.request.headers["X-Hub-Signature-256"]
val delivery = call.request.headers["X-GitHub-Delivery"]

requireNotNull(event)
requireNotNull(signature)
requireNotNull(delivery)

// Path Parameter
val conversationId = call.parameters[PARAM_CONVERSATION_ID]
?: return@post call.respondText(
status = HttpStatusCode.BadRequest,
text = "Missing $PARAM_CONVERSATION_ID"
)

val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN]
?: return@post call.respondText(
status = HttpStatusCode.BadRequest,
text = "Missing $PARAM_CONVERSATION_DOMAIN"
)

// Payload
val payload = call.receiveText()

// Validation of received signature
val isSignatureValid = signatureValidator.isValid(
conversationId = conversationId,
conversationDomain = conversationDomain,
signature = signature,
payload = payload
payload = payload,
secret = requireNotNull(ENV_VAR_GITHUB_WEBHOOK_SECRET) {
"GHAPP_GITHUB_WEBHOOK_SECRET must be set to validate GitHub webhooks"
}
)
if (!isSignatureValid) {
return@post call.respond(
status = HttpStatusCode.Forbidden,
message = "Invalid Signature for Conversation"
message = "Invalid GitHub webhook signature"
)
}

val response = KtxSerializer.json.decodeFromString<GitHubResponse>(payload)
gitHubWebhookManager.markRepositoryActive(response.repository.fullName)

// Handle event response and send message
val messageTemplate = templateHandler.handleEvent(
Expand All @@ -93,21 +81,19 @@ fun Application.configureRouting() {
)

messageTemplate?.let { message ->
wireAppSdk.getApplicationManager().sendMessage(
message = WireMessage.Text.create(
conversationId = QualifiedId(
id = UUID.fromString(conversationId),
domain = conversationDomain
),
text = message
)
)
gitHubWebhookManager
.conversationsForRepository(response.repository.fullName)
.forEach { conversationId ->
wireAppSdk.getApplicationManager().sendMessage(
message = WireMessage.Text.create(
conversationId = conversationId,
text = message
)
)
}
}

return@post call.response.status(HttpStatusCode.OK)
}
}
}

private const val PARAM_CONVERSATION_ID = "conversationId"
private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain"
4 changes: 4 additions & 0 deletions src/main/kotlin/com/wire/github/config/Modules.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.wire.github.config

import com.wire.github.EventsHandler
import com.wire.github.github.GitHubAppClient
import com.wire.github.github.GitHubWebhookManager
import com.wire.github.util.ENV_VAR_API_HOST
import com.wire.github.util.ENV_VAR_API_TOKEN
import com.wire.github.util.ENV_VAR_APPLICATION_ID
Expand All @@ -24,6 +26,8 @@ val projectModules = module {
single(createdAtStart = true) { initWireAppSdk() }
single { SignatureValidator() }
single { TemplateHandler() }
single { GitHubAppClient() }
single { GitHubWebhookManager(gitHubAppClient = get()) }
single { RedisClient.create("$ENV_VAR_REDIS_HOST:$ENV_VAR_REDIS_PORT") }
single<StatefulRedisConnection<String, String>> { get<RedisClient>().connect() }
}
Expand Down
Loading