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 support for global/project level webhooks #10

Merged
merged 11 commits into from
Nov 13, 2019
19 changes: 17 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<version>1</version>
</dependency>
<dependency>
<groupId>com.atlassian.bitbucket.server</groupId>
<artifactId>bitbucket-api</artifactId>
Expand All @@ -62,6 +67,12 @@
<scope>provided</scope>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.atlassian.activeobjects</groupId>
<artifactId>activeobjects-plugin</artifactId>
<version>1.2.3</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.atlassian.plugin</groupId>
<artifactId>atlassian-spring-scanner-annotation</artifactId>
Expand Down Expand Up @@ -90,7 +101,11 @@
<artifactId>sal-api</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.atlassian.bitbucket.server</groupId>
<artifactId>bitbucket-spi</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
Expand Down Expand Up @@ -152,7 +167,7 @@
<artifactId>atlassian-spring-scanner-external-jar</artifactId>
</dependency>
</scannedDependencies>
<verbose>false</verbose>
<verbose>true</verbose>
</configuration>
</plugin>
</plugins>
Expand Down
106 changes: 106 additions & 0 deletions src/main/java/com/sourcegraph/webhook/Dispatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.sourcegraph.webhook;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.net.Request;
import com.atlassian.sal.api.net.RequestFactory;
import com.atlassian.sal.api.net.Response;
import com.atlassian.sal.api.net.ResponseException;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.sourcegraph.webhook.registry.Webhook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.concurrent.ExecutorService;

@Component
public class Dispatcher {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add class and method doc comments here and everywhere else.

private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private static final int RETRY_DELAY = 10 * 1000;
private static final int MAX_ATTEMPTS = 5;
@ComponentImport
private static ExecutorService executor;
@ComponentImport
private static RequestFactory requestFactory;

@Autowired
public Dispatcher(ExecutorService executor, RequestFactory<?> requestFactory) {
Dispatcher.executor = executor;
Dispatcher.requestFactory = requestFactory;
}

private static String sign(String secret, String data) throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance("HmacSHA1");
SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
mac.init(secretKeySpec);
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}

private static Request createRequest(Webhook hook, EventSerializer serializer) {
Request request = requestFactory.createRequest(Request.MethodType.POST, hook.endpoint);
request.setHeader("X-Event-Key", serializer.getName());
request.setHeader("X-Hook-ID", String.valueOf(hook.id));
request.setHeader("X-Hook-Name", hook.name);

JsonObject payload = serializer.serialize();
Gson gson = new GsonBuilder()
.excludeFieldsWithoutExposeAnnotation()
.create();
String json = gson.toJson(payload);
request.setRequestBody(json);

try {
request.setHeader("X-Hub-Signature", sign(hook.secret, json));
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
log.error(e.toString());
return null;
}

return request;
}

public static void dispatch(Webhook hook, EventSerializer serializer) {
executor.submit(() -> {
Request request = createRequest(hook, serializer);
if (request == null) {
return;
}

int attempt = 0;
while (true) {
request.setHeader("X-Attempt-Number", String.valueOf(attempt));
try {
Response response = (Response) request.executeAndReturn((resp) -> resp);
if (response.isSuccessful()) {
log.debug("Dispatching webhook (" + serializer.getName() + ") data to URL: [" + hook.endpoint + "] succeeded.");
break;
}
} catch (ResponseException e) {
log.debug("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] failed with error:\n" + e);
}
attempt++;

if (attempt == MAX_ATTEMPTS) {
log.warn("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] failed after " + attempt + " attempts..");
break;
}

try {
Thread.sleep(RETRY_DELAY);
} catch (InterruptedException e) {
log.debug("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] was interrupted.");
break;
}
}
});
}
}
114 changes: 114 additions & 0 deletions src/main/java/com/sourcegraph/webhook/EventSerializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.sourcegraph.webhook;

import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.pull.*;
import com.atlassian.bitbucket.json.JsonRenderer;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.google.gson.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;

@Component
public class EventSerializer {
private static final SimpleDateFormat RFC3339 = new SimpleDateFormat("yyyy-MM-dd'T'h:m:ssZZZZZ");
private static Map<Class<?>, Adapter> adapters = new HashMap<>();
private static JsonRenderer renderer;

private ApplicationEvent event;
private String name;
private JsonObject payload;

private static JsonElement render(Object o) {
HashMap<String, Object> options = new HashMap<>();
String raw = renderer.render(o, options);
return raw == null ? null : new JsonParser().parse(raw);
}

private static Adapter<PullRequestUpdatedEvent> PullRequestUpdatedEventAdapter = (element, event) -> {
element.addProperty("previousTitle", event.getPreviousTitle());
element.addProperty("previousDescription", event.getPreviousDescription());
element.add("previousTarget", render(event.getPreviousToBranch()));
};

private static Adapter<PullRequestReviewersUpdatedEvent> PullRequestReviewersUpdatedEventAdapter = (element, event) -> {
element.add("removedReviewers", render(event.getRemovedReviewers()));
element.add("addedReviewers", render(event.getAddedReviewers()));
};

private static Adapter<PullRequestParticipantStatusUpdatedEvent> PullRequestParticipantStatusUpdatedEventAdapter = (element, event) -> {
element.add("participant", render(event.getParticipant()));
element.addProperty("previousStatus", event.getPreviousStatus().name());
};

private static Adapter<PullRequestCommentEvent> PullRequestCommentEventAdapter = (element, event) -> {
element.add("comment", render(event.getComment()));
if (event.getParent() != null) {
element.addProperty("commentParentId", event.getParent().getId());
}
};

private static Adapter<PullRequestCommentEditedEvent> PullRequestCommentEditedEventAdapter = (element, event) -> {
PullRequestCommentEventAdapter.apply(element, event);
element.addProperty("previousComment", event.getPreviousText());
};

static {
adapters.put(PullRequestUpdatedEvent.class, PullRequestUpdatedEventAdapter);
adapters.put(PullRequestReviewersUpdatedEvent.class, PullRequestReviewersUpdatedEventAdapter);
adapters.put(PullRequestParticipantApprovedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestParticipantUnapprovedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestParticipantReviewedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestCommentAddedEvent.class, PullRequestCommentEventAdapter);
adapters.put(PullRequestCommentDeletedEvent.class, PullRequestCommentEventAdapter);
adapters.put(PullRequestCommentEditedEvent.class, PullRequestCommentEditedEventAdapter);
}

@Autowired
public EventSerializer(@ComponentImport JsonRenderer renderer) {
EventSerializer.renderer = renderer;
}

public EventSerializer(String name, ApplicationEvent event) {
payload = new JsonObject();
this.name = name;
this.event = event;
}

public JsonObject serialize() {
buildApplicationEvent(this.event);
if (event instanceof PullRequestEvent) {
buildPullRequestEvent((PullRequestEvent) event);
}

Adapter adapter = adapters.get(event.getClass());
if (adapter != null) {
adapter.apply(payload, event);
}
return payload;
}

public String getName() {
return this.name;
}

public ApplicationEvent getEvent() {
return this.event;
}

private void buildApplicationEvent(ApplicationEvent event) {
payload.addProperty("date", RFC3339.format(event.getDate()));
payload.add("actor", render(event.getUser()));
}

private void buildPullRequestEvent(PullRequestEvent event) {
payload.add("pullRequest", render(event.getPullRequest()));
}

private interface Adapter<T> {
void apply(JsonObject element, T event);
}
}
71 changes: 71 additions & 0 deletions src/main/java/com/sourcegraph/webhook/WebhookListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.sourcegraph.webhook;

import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.pull.*;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.event.api.AsynchronousPreferred;
import com.atlassian.event.api.EventListener;
import com.sourcegraph.webhook.registry.Webhook;
import com.sourcegraph.webhook.registry.WebhookRegistry;

import javax.inject.Named;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@AsynchronousPreferred
@Named("WebhookListener")
public class WebhookListener {
private static Map<Class<?>, List<String>> triggers = new HashMap<>();

private static void register(Class<?> type, String key) {
// Enumerate all prefixes (or super/parent events) of event key.
// "pr:comment:added" -> ["pr", "pr:comment", "pr:comment:added"]
String[] split = key.split(":");
List<String> prefixes = new ArrayList<>();
for (int i = 0, index = 0; i < split.length; i++) {
index += split[i].length();
prefixes.add(key.substring(0, index));
index++;
}
triggers.put(type, prefixes);
}

static {
register(PullRequestReopenedEvent.class, "pr:opened");
register(PullRequestOpenedEvent.class, "pr:opened");
register(PullRequestUpdatedEvent.class, "pr:modified");
register(PullRequestReviewersUpdatedEvent.class, "pr:reviewer:updated");
register(PullRequestParticipantApprovedEvent.class, "pr:reviewer:approved");
register(PullRequestParticipantUnapprovedEvent.class, "pr:reviewer:unapproved");
register(PullRequestParticipantReviewedEvent.class, "pr:reviewer:needs_work");
register(PullRequestMergeActivityEvent.class, "pr:merged");
register(PullRequestDeclinedEvent.class, "pr:declined");
register(PullRequestDeletedEvent.class, "pr:deleted");
register(PullRequestCommentAddedEvent.class, "pr:comment:added");
register(PullRequestCommentEditedEvent.class, "pr:comment:edited");
register(PullRequestCommentDeletedEvent.class, "pr:comment:deleted");
}

@EventListener
public void onPullRequestEvent(PullRequestEvent event) {
handle(event, event.getPullRequest().getToRef().getRepository());
}

private void handle(ApplicationEvent event, Repository repository) {
List<String> keys = triggers.get(event.getClass());
if (keys == null || keys.isEmpty()) {
return;
}

List<Webhook> hooks = WebhookRegistry.getWebhooks(keys, repository);
if (hooks.isEmpty()) {
return;
}

String key = keys.get(keys.size() - 1);
EventSerializer serializer = new EventSerializer(key, event);
hooks.forEach(hook -> Dispatcher.dispatch(hook, serializer));
}
}
Loading