Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Cloud Function to intercept GitHub Webhooks to Mixpanel #355

Merged
merged 2 commits into from
Nov 26, 2020
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
36 changes: 36 additions & 0 deletions server/analytics/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Drafted from https://github.com/GoogleCloudPlatform/java-docs-samples/blob/master/functions/helloworld/helloworld-gradle/build.gradle

// Separate buildscript as this is the only file gcloud will see
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.20"
}
}

apply plugin: 'java'
apply plugin: 'kotlin'

repositories {
jcenter()
mavenCentral()
}
configurations {
invoker
}

dependencies {
compileOnly 'com.google.cloud.functions:functions-framework-api:1.0.1'

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.20"
implementation 'com.mixpanel:mixpanel-java:1.4.4'
implementation 'com.google.code.gson:gson:2.8.6'

// These dependencies are only used by the tests.
testImplementation 'com.google.cloud.functions:functions-framework-api:1.0.1'
testImplementation 'junit:junit:4.13.1'
testImplementation 'org.assertj:assertj-core:3.18.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
}
1 change: 1 addition & 0 deletions server/analytics/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
gcloud functions deploy github-client-webhooks --entry-point com.jraska.devanalytics.github.GitHubEventsMixpanelFunction --runtime java11 --memory 256MB --trigger-http --allow-unauthenticated
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.jraska.devanalytics.github

import com.google.cloud.functions.HttpFunction
import com.google.cloud.functions.HttpRequest
import com.google.cloud.functions.HttpResponse
import com.jraska.devanalytics.github.model.Environment
import com.jraska.devanalytics.github.report.GitHubEventMixpanelInterceptor

class GitHubEventsMixpanelFunction : HttpFunction {
override fun service(gitHubWebHook: HttpRequest, response: HttpResponse) {
val interceptor = GitHubEventMixpanelInterceptor.create(Environment.create())

val event = interceptor.intercept(gitHubWebHook.reader)

response.writer.write("Event $event reported to Mixpanel")
response.setStatusCode(200)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.jraska.devanalytics.github.model

const val MIXPANEL_KEY_ENV_VAR = "MIXPANEL_API_KEY"

class Environment(
val mixpanelUrl: String?,
val mixpanelToken: String
) {
companion object {
fun create(): Environment {
val mixpanelKey = System.getenv(MIXPANEL_KEY_ENV_VAR)
return Environment(null, mixpanelKey)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.jraska.devanalytics.github.model

import com.google.gson.Gson
import java.io.BufferedReader

class EventReader(
private val gson: Gson
) {
fun parse(reader: BufferedReader): GitHubPrEvent {
val dto = gson.getAdapter(GitHubEventDto::class.java).fromJson(reader)

val action = dto.action
var comment = dto.comment?.body
if (comment == null && action == "opened") {
comment = dto.pullRequest?.body
}

if (comment == null && action == "created") {
comment = dto.review?.body
}

val login = dto.review?.user?.login ?: dto.sender.login

return GitHubPrEvent(
action,
login,
dto.pullRequest?.prUrl ?: dto.issue?.pullRequest?.prUrl ?: throw UnsupportedOperationException("PR url not found"),
dto.pullRequest?.number ?: dto.issue?.number ?: throw IllegalStateException("PR number not found"),
comment,
dto.review?.state
)
}

companion object {
fun create(): EventReader {
return EventReader(Gson())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.jraska.devanalytics.github.model

import com.google.gson.annotations.SerializedName

class GitHubEventDto {
@SerializedName("action")
lateinit var action: String

@SerializedName("sender")
lateinit var sender: UserDto

@SerializedName("pull_request")
var pullRequest: PullRequestDto? = null

@SerializedName("issue")
var issue: IssueDto? = null

@SerializedName("comment")
var comment: CommentDto? = null

@SerializedName("review")
var review: ReviewDto? = null
}

class UserDto {
@SerializedName("login")
lateinit var login: String
}

class PullRequestDto {
@SerializedName("html_url")
lateinit var prUrl: String

@SerializedName("number")
var number: Int = 0

@SerializedName("body")
var body: String = ""
}

class IssueDto {
@SerializedName("pull_request")
var pullRequest: PullRequestDto? = null

@SerializedName("number")
var number: Int = 0
}

class CommentDto {
@SerializedName("body")
lateinit var body: String
}

class ReviewDto {
@SerializedName("user")
lateinit var user: UserDto

@SerializedName("state")
lateinit var state: String

@SerializedName("body")
lateinit var body: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.jraska.devanalytics.github.model

data class GitHubPrEvent(
val action: String,
val author: String,
val prUrl: String,
val prNumber: Int,
val comment: String?,
val state: String?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.jraska.devanalytics.github.report

import com.jraska.devanalytics.github.model.Environment
import com.jraska.devanalytics.github.model.EventReader
import com.jraska.devanalytics.github.model.GitHubPrEvent
import java.io.BufferedReader

class GitHubEventMixpanelInterceptor(
private val reporter: MixpanelReporter,
private val eventReader: EventReader
) {
fun intercept(reader: BufferedReader): GitHubPrEvent {
val event = eventReader.parse(reader)
reporter.report(event)

return event
}

companion object {
fun create(environment: Environment): GitHubEventMixpanelInterceptor {
val reporter = MixpanelReporter.create(environment)
val eventReader = EventReader.create()

return GitHubEventMixpanelInterceptor(reporter, eventReader)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.jraska.devanalytics.github.report

import com.jraska.devanalytics.github.model.Environment
import com.jraska.devanalytics.github.model.GitHubPrEvent
import com.mixpanel.mixpanelapi.ClientDelivery
import com.mixpanel.mixpanelapi.MessageBuilder
import com.mixpanel.mixpanelapi.MixpanelAPI
import org.json.JSONObject

class MixpanelReporter(
private val api: MixpanelAPI,
private val apiKey: String
) {
fun report(event: GitHubPrEvent) {
val delivery = ClientDelivery()

val properties = convertEvent(event)
val mixpanelEvent = MessageBuilder(apiKey)
.event(SINGLE_NAME_FOR_ONE_USER, "PR Event", JSONObject(properties))

delivery.addMessage(mixpanelEvent)

api.deliver(delivery)
}

private fun convertEvent(event: GitHubPrEvent): Map<String, Any?> {
return mapOf(
"action" to event.action,
"prUrl" to event.prUrl,
"author" to event.author,
"comment" to event.comment,
"state" to event.state,
"prNumber" to event.prNumber
).filter { it.value != null }
}

companion object {
fun create(environment: Environment): MixpanelReporter {
val mixpanelApi = if (environment.mixpanelUrl == null) {
MixpanelAPI()
} else {
MixpanelAPI(environment.mixpanelUrl, environment.mixpanelUrl)
}

return MixpanelReporter(mixpanelApi, environment.mixpanelToken)
}

private val SINGLE_NAME_FOR_ONE_USER = "GitHub PRs Reporter"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.jraska.devanalytics.github

import com.jraska.devanalytics.github.model.EventReader
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import java.io.BufferedReader
import java.io.File

class EventReaderTest {
@Test
fun canReadPrOpenEvent() {
val jsonReader = json("response/pr_open.json")

val event = EventReader.create().parse(jsonReader)

assertThat(event.action).isEqualTo("opened")
assertThat(event.author).isEqualTo("Codertocat")
assertThat(event.prUrl).isEqualTo("https://github.com/Codertocat/Hello-World/pull/2")
assertThat(event.prNumber).isEqualTo(2)
assertThat(event.comment).isEqualTo("This is a pretty simple change that we need to pull into master.")
assertThat(event.state).isNull()
}

@Test
fun canReadPrCommentEvent() {
val jsonReader = json("response/pr_comment.json")

val event = EventReader.create().parse(jsonReader)

assertThat(event.action).isEqualTo("created")
assertThat(event.author).isEqualTo("jraska")
assertThat(event.prUrl).isEqualTo("https://github.com/jraska/github-client/pull/353")
assertThat(event.prNumber).isEqualTo(353)
assertThat(event.comment).isEqualTo("Another test comment")
}

@Test
fun canReadPrReviewRequestedEvent() {
val jsonReader = json("response/review_requested.json")

val event = EventReader.create().parse(jsonReader)

assertThat(event.action).isEqualTo("review_requested")
assertThat(event.author).isEqualTo("jraska")
assertThat(event.prUrl).isEqualTo("https://github.com/jraska/github-client/pull/353")
assertThat(event.prNumber).isEqualTo(353)
assertThat(event.comment).isNull()
}

@Test
fun canReadPrCommentDeletedEvent() {
val jsonReader = json("response/comment_deleted.json")

val event = EventReader.create().parse(jsonReader)

assertThat(event.action).isEqualTo("deleted")
assertThat(event.author).isEqualTo("jraska")
assertThat(event.prUrl).isEqualTo("https://github.com/jraska/github-client/pull/353")
assertThat(event.prNumber).isEqualTo(353)
assertThat(event.comment).isEqualTo("Test comment for webhook")
}

@Test
fun canReadReviewCreatedDeletedEvent() {
val jsonReader = json("response/review_created.json")

val event = EventReader.create().parse(jsonReader)

assertThat(event.action).isEqualTo("created")
assertThat(event.author).isEqualTo("mikehardy")
assertThat(event.prUrl).isEqualTo("https://github.com/ankidroid/Anki-Android/pull/7765")
assertThat(event.prNumber).isEqualTo(7765)
assertThat(event.comment).isEqualTo("Yeah this all seems fine, including the default setting\r\n\r\n> I'm bad at days off\r\n\r\nhahaha you and me both, I suppose as long as it's fun, is it a day on?")
assertThat(event.state).isEqualTo("approved")
}

companion object {
fun json(path: String): BufferedReader {
val uri = this.javaClass.classLoader.getResource(path)
val file = File(uri!!.path)
return file.bufferedReader()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.jraska.devanalytics.github.report

import com.jraska.devanalytics.github.EventReaderTest
import com.jraska.devanalytics.github.model.Environment
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test

class GitHubEventMixpanelInterceptorTest {
@get:Rule
val mockWebServer = MockWebServer()

@Test
fun reportsToFakeServer() {
val fakeEnvironment = Environment(mockWebServer.url("/mixpanel/").toString(), "fakeToken")
val interceptor = GitHubEventMixpanelInterceptor.create(fakeEnvironment)

mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("1"))

val event = interceptor.intercept(EventReaderTest.json("response/pr_comment.json"))

assertThat(event.action).isEqualTo("created")
assertThat(event.author).isEqualTo("jraska")
assertThat(event.prUrl).isEqualTo("https://github.com/jraska/github-client/pull/353")
assertThat(event.prNumber).isEqualTo(353)
assertThat(event.comment).isEqualTo("Another test comment")
}
}
Loading