diff --git a/pom.xml b/pom.xml
index 37d2e3f..290f69b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -46,6 +46,11 @@
+
+ javax.inject
+ javax.inject
+ 1
+
com.atlassian.bitbucket.server
bitbucket-api
@@ -62,6 +67,12 @@
provided
2.0
+
+ com.atlassian.activeobjects
+ activeobjects-plugin
+ 1.2.3
+ provided
+
com.atlassian.plugin
atlassian-spring-scanner-annotation
@@ -90,7 +101,11 @@
sal-api
provided
-
+
+ com.atlassian.bitbucket.server
+ bitbucket-spi
+ provided
+
@@ -152,7 +167,7 @@
atlassian-spring-scanner-external-jar
- false
+ true
diff --git a/src/main/java/com/sourcegraph/webhook/Dispatcher.java b/src/main/java/com/sourcegraph/webhook/Dispatcher.java
new file mode 100644
index 0000000..86577ba
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/Dispatcher.java
@@ -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 {
+ 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;
+ }
+ }
+ });
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/EventSerializer.java b/src/main/java/com/sourcegraph/webhook/EventSerializer.java
new file mode 100644
index 0000000..71463de
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/EventSerializer.java
@@ -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, Adapter> adapters = new HashMap<>();
+ private static JsonRenderer renderer;
+
+ private ApplicationEvent event;
+ private String name;
+ private JsonObject payload;
+
+ private static JsonElement render(Object o) {
+ HashMap options = new HashMap<>();
+ String raw = renderer.render(o, options);
+ return raw == null ? null : new JsonParser().parse(raw);
+ }
+
+ private static Adapter PullRequestUpdatedEventAdapter = (element, event) -> {
+ element.addProperty("previousTitle", event.getPreviousTitle());
+ element.addProperty("previousDescription", event.getPreviousDescription());
+ element.add("previousTarget", render(event.getPreviousToBranch()));
+ };
+
+ private static Adapter PullRequestReviewersUpdatedEventAdapter = (element, event) -> {
+ element.add("removedReviewers", render(event.getRemovedReviewers()));
+ element.add("addedReviewers", render(event.getAddedReviewers()));
+ };
+
+ private static Adapter PullRequestParticipantStatusUpdatedEventAdapter = (element, event) -> {
+ element.add("participant", render(event.getParticipant()));
+ element.addProperty("previousStatus", event.getPreviousStatus().name());
+ };
+
+ private static Adapter PullRequestCommentEventAdapter = (element, event) -> {
+ element.add("comment", render(event.getComment()));
+ if (event.getParent() != null) {
+ element.addProperty("commentParentId", event.getParent().getId());
+ }
+ };
+
+ private static Adapter 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 {
+ void apply(JsonObject element, T event);
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/WebhookListener.java b/src/main/java/com/sourcegraph/webhook/WebhookListener.java
new file mode 100644
index 0000000..5507db9
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/WebhookListener.java
@@ -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, List> 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 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 keys = triggers.get(event.getClass());
+ if (keys == null || keys.isEmpty()) {
+ return;
+ }
+
+ List 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));
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/WebhookRouter.java b/src/main/java/com/sourcegraph/webhook/WebhookRouter.java
new file mode 100644
index 0000000..0c631d4
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/WebhookRouter.java
@@ -0,0 +1,60 @@
+package com.sourcegraph.webhook;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonIOException;
+import com.google.gson.JsonSyntaxException;
+import com.sourcegraph.webhook.registry.Webhook;
+import com.sourcegraph.webhook.registry.WebhookException;
+import com.sourcegraph.webhook.registry.WebhookRegistry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.ws.rs.*;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.util.List;
+
+@Path("/webhook")
+public class WebhookRouter {
+ private static final Logger log = LoggerFactory.getLogger(WebhookRouter.class);
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response get() {
+ List hooks = WebhookRegistry.getWebhooks();
+ String resp = new Gson().toJson(hooks);
+ return Response.ok(resp).build();
+ }
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ public Response put(String raw) throws WebhookException {
+ try {
+ Gson gson = new Gson();
+ Webhook hook = gson.fromJson(raw, Webhook.class);
+ if (hook == null) {
+ throw new WebhookException(Response.Status.BAD_REQUEST, "Invalid JSON");
+ }
+ log.info("Registering webhook: " + raw);
+ WebhookRegistry.register(hook);
+ } catch (JsonIOException e) {
+ throw new WebhookException(Response.Status.INTERNAL_SERVER_ERROR, "");
+ } catch (JsonSyntaxException e) {
+ throw new WebhookException(Response.Status.BAD_REQUEST, "Invalid JSON");
+ }
+
+ return Response.ok().build();
+ }
+
+ @DELETE
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+ public Response delete(@FormParam("id") int id, @FormParam("name") String name) {
+ if (name != null) {
+ WebhookRegistry.deregister(name);
+ } else {
+ WebhookRegistry.deregister(id);
+ }
+
+ return Response.noContent().build();
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/registry/EventEntity.java b/src/main/java/com/sourcegraph/webhook/registry/EventEntity.java
new file mode 100644
index 0000000..dfd9d06
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/registry/EventEntity.java
@@ -0,0 +1,13 @@
+package com.sourcegraph.webhook.registry;
+
+import net.java.ao.Entity;
+
+public interface EventEntity extends Entity {
+ String getEvent();
+
+ void setEvent(String event);
+
+ WebhookEntity getWebhook();
+
+ void setWebhook(WebhookEntity hook);
+}
diff --git a/src/main/java/com/sourcegraph/webhook/registry/Webhook.java b/src/main/java/com/sourcegraph/webhook/registry/Webhook.java
new file mode 100644
index 0000000..9a70035
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/registry/Webhook.java
@@ -0,0 +1,32 @@
+package com.sourcegraph.webhook.registry;
+
+import com.google.gson.annotations.Expose;
+
+import java.util.Set;
+
+public class Webhook {
+ @Expose
+ public int id;
+ @Expose
+ public String name;
+ @Expose
+ public String scope;
+ @Expose
+ public String identifier;
+ @Expose
+ public Set events;
+ @Expose
+ public String endpoint;
+ @Expose(serialize = false)
+ public String secret;
+
+ public Webhook(int id, String name, String scope, String identifier, Set events, String endpoint, String secret) {
+ this.id = id;
+ this.name = name;
+ this.scope = scope;
+ this.identifier = identifier;
+ this.events = events;
+ this.endpoint = endpoint;
+ this.secret = secret;
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/registry/WebhookEntity.java b/src/main/java/com/sourcegraph/webhook/registry/WebhookEntity.java
new file mode 100644
index 0000000..35cc63d
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/registry/WebhookEntity.java
@@ -0,0 +1,19 @@
+package com.sourcegraph.webhook.registry;
+
+import net.java.ao.Entity;
+import net.java.ao.OneToMany;
+
+public interface WebhookEntity extends Entity {
+ String getName();
+
+ String getScope();
+
+ int getIdentifier();
+
+ @OneToMany
+ EventEntity[] getEvents();
+
+ String getEndpoint();
+
+ String getSecret();
+}
diff --git a/src/main/java/com/sourcegraph/webhook/registry/WebhookException.java b/src/main/java/com/sourcegraph/webhook/registry/WebhookException.java
new file mode 100644
index 0000000..895e8ed
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/registry/WebhookException.java
@@ -0,0 +1,51 @@
+package com.sourcegraph.webhook.registry;
+
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+
+@Provider
+public class WebhookException extends Exception implements ExceptionMapper {
+ private Response.StatusType status;
+
+ public WebhookException() {
+ }
+
+ public WebhookException(Response.StatusType status, String message) {
+ super(message);
+ this.status = status;
+ }
+
+ public Response.StatusType getStatus() {
+ return status;
+ }
+
+ @Override
+ public Response toResponse(WebhookException e) {
+ return Response.status(e.getStatus()).entity(e.getMessage()).build();
+ }
+
+ public enum Status implements Response.StatusType {
+ UNPROCESSABLE_ENTITY(422, "Unprocessable Entity");
+
+ private final int code;
+ private final String reason;
+
+ Status(int status, String reason) {
+ this.code = status;
+ this.reason = reason;
+ }
+
+ public int getStatusCode() {
+ return this.code;
+ }
+
+ public Response.Status.Family getFamily() {
+ return null;
+ }
+
+ public String getReasonPhrase() {
+ return this.reason;
+ }
+ }
+}
diff --git a/src/main/java/com/sourcegraph/webhook/registry/WebhookRegistry.java b/src/main/java/com/sourcegraph/webhook/registry/WebhookRegistry.java
new file mode 100644
index 0000000..4de29f4
--- /dev/null
+++ b/src/main/java/com/sourcegraph/webhook/registry/WebhookRegistry.java
@@ -0,0 +1,157 @@
+package com.sourcegraph.webhook.registry;
+
+import com.atlassian.activeobjects.external.ActiveObjects;
+import com.atlassian.bitbucket.project.Project;
+import com.atlassian.bitbucket.project.ProjectService;
+import com.atlassian.bitbucket.repository.Repository;
+import com.atlassian.bitbucket.repository.RepositoryService;
+import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
+import com.google.common.base.Joiner;
+import com.google.common.collect.Iterables;
+import net.java.ao.Query;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.core.Response;
+import java.util.*;
+
+@Component
+public class WebhookRegistry {
+ @ComponentImport
+ private static ActiveObjects activeObjects;
+ @ComponentImport
+ private static ProjectService projects;
+ @ComponentImport
+ private static RepositoryService repositories;
+
+ @Autowired
+ public WebhookRegistry(ActiveObjects ao, ProjectService projects, RepositoryService repositories) {
+ WebhookRegistry.activeObjects = ao;
+ WebhookRegistry.projects = projects;
+ WebhookRegistry.repositories = repositories;
+ }
+
+ public static List getWebhooks() {
+ WebhookEntity[] entities = activeObjects.find(WebhookEntity.class);
+ return getWebhooksFromEntities(entities);
+ }
+
+ public static List getWebhooks(List keys, Repository repository) {
+ String params = Joiner.on(", ").join(Collections.nCopies(keys.size(), "?"));
+ Iterable