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
93 changes: 93 additions & 0 deletions src/main/java/com/sourcegraph/webhook/Dispatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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.common.hash.Hashing;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
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.io.UnsupportedEncodingException;
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_RETRIES = 5;
@ComponentImport
private static ExecutorService executor;
@ComponentImport
private static RequestFactory requestFactory;

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

public 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)));
}

public static void dispatch(Webhook hook, JsonObject payload) {
// System.out.println(external + " " + new Gson().toJson(payload));

executor.submit(() -> {
Request request = requestFactory.createRequest(Request.MethodType.POST, hook.endpoint);

String json = new Gson().toJson(payload);
request.setRequestBody(json);

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

for (int retries = 0; retries < MAX_RETRIES; retries++) {
Response response = null;
try {
response = (Response) request.executeAndReturn((resp) -> resp);
} catch (ResponseException e) {
log.debug("Dispatching webhook data to URL: [" + hook.endpoint + "] failed with error:\n" + e);
}

if (response != null && response.isSuccessful()) {
log.debug("Dispatching webhook data to URL: [" + hook.endpoint + "] succeeded.");
break;
}

if (retries == MAX_RETRIES - 1) {
log.warn("Dispatching webhook data to URL: [" + hook.endpoint + "] failed after " + MAX_RETRIES + " attempts..");
break;
}

try {
Thread.sleep(RETRY_DELAY);
} catch (InterruptedException e) {
log.debug("Dispatching webhook data to URL: [" + hook.endpoint + "] was interrupted.");
break;
}
}
});

}
}
103 changes: 103 additions & 0 deletions src/main/java/com/sourcegraph/webhook/EventSerializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 JsonObject payload;

private static JsonElement render(Object o) {
String raw = renderer.render(o, new HashMap<>());
Copy link

Choose a reason for hiding this comment

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

Java noob question: What is a HashMap doing here? Aren't we rendering an Object directly to a JSON String?

Copy link
Contributor Author

@kzh kzh Nov 6, 2019

Choose a reason for hiding this comment

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

The HashMap is for the options parameter of the render function. I think passing in a null will throw a null pointer exception, so I just pass in an empty one.

I'm not exactly sure how this parameter works either since the docs don't say much 🤔https://docs.atlassian.com/bitbucket-server/javadoc/4.0.2/spi/reference/com/atlassian/bitbucket/json/JsonRenderer.html

Choose a reason for hiding this comment

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

I had a similar question. Can you assign this to a variable so it is clearer?

Suggested change
String raw = renderer.render(o, new HashMap<>());
HashMap<> options = new HashMap<>();
String raw = renderer.render(o, options);

Do we really have to indirect through a JSON string? There is no method to go directly from a regular object to a JsonElement?

Copy link
Contributor Author

@kzh kzh Nov 13, 2019

Choose a reason for hiding this comment

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

The bitbucket API seems to only expose the render function which converts bitbucket objects (PullRequest, User, Ref, etc) directly to a JSON string, so there isn't much flexibility.

The other approach would be manually serializing these objects like how events are currently being handled to avoid incurring this redundant operation.

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) {
payload = new JsonObject();
payload.addProperty("eventKey", name);
}

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

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

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);
}
}
72 changes: 72 additions & 0 deletions src/main/java/com/sourcegraph/webhook/WebhookListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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.google.gson.JsonObject;
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(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);
JsonObject payload = serializer.serialize(event);
hooks.forEach(hook -> Dispatcher.dispatch(hook, payload));
}
}
63 changes: 63 additions & 0 deletions src/main/java/com/sourcegraph/webhook/WebhookRouter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.sourcegraph.webhook;

import com.google.gson.Gson;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.stream.JsonReader;
import com.sourcegraph.webhook.registry.Webhook;
import com.sourcegraph.webhook.registry.WebhookException;
import com.sourcegraph.webhook.registry.WebhookRegistry;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
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<Webhook> 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 {
Copy link

Choose a reason for hiding this comment

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

We'll need some basic documentation on how to use this API (but that can come in a follow up PR).

Copy link

Choose a reason for hiding this comment

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

Question that comes to mind: Are requests authenticated in the same way as the normal Bitbucket Server API?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep!

Authentication
Any authentication that works against Bitbucket will work against the REST API. The preferred authentication methods are HTTP Basic (when using SSL) and OAuth. Other supported methods include: HTTP Cookies and Trusted Applications.

You can find OAuth code samples in several programming languages at bitbucket.org/atlassian_tutorial/atlassian-oauth-examples.

The log-in page uses cookie-based authentication, so if you are using Bitbucket in a browser you can call REST from JavaScript on the page and rely on the authentication that the browser has established.

(https://docs.atlassian.com/bitbucket-server/rest/5.16.0/bitbucket-rest.html)

Copy link

Choose a reason for hiding this comment

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

This talks about the REST API. Does this plugin "extend" the same REST API? Or is it served under a different path? I guess all of these usage questions need to be documented.

Copy link
Contributor Author

@kzh kzh Nov 13, 2019

Choose a reason for hiding this comment

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

Yes, I believe the plugin extend the native bitbucket REST API. They're both served under the /rest/ path which requires authentication (else 401s).

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) {
WebhookRegistry.deregister(id);
return Response.noContent().build();
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/sourcegraph/webhook/registry/EventEntity.java
Original file line number Diff line number Diff line change
@@ -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);
}
Loading