-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from 4 commits
dc5606a
d62b653
289a9b4
a76c23b
ec7f52b
cd01a27
34e3762
67bf8c3
e419d04
99b191d
a109cc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
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)); | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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)); | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} catch (NoSuchAlgorithmException | InvalidKeyException e) { | ||
log.error(e.toString()); | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
for (int retries = 0; retries < MAX_RETRIES; retries++) { | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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()) { | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
} | ||
} | ||
}); | ||
|
||
} | ||
} |
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<>()); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Java noob question: What is a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Do we really have to indirect through a JSON string? There is no method to go directly from a regular object to a JsonElement? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The bitbucket API seems to only expose the 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) -> { | ||||||||
tsenart marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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); | ||||||||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
} | ||||||||
|
||||||||
@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) { | ||||||||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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); | ||||||||
} | ||||||||
} |
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++; | ||
kzh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
triggers.put(type, prefixes); | ||
} | ||
|
||
static { | ||
register(PullRequestOpenedEvent.class, "pr:opened"); | ||
register(PullRequestUpdatedEvent.class, "pr:modified"); | ||
tsenart marked this conversation as resolved.
Show resolved
Hide resolved
|
||
register(PullRequestReviewersUpdatedEvent.class, "pr:reviewer:updated"); | ||
tsenart marked this conversation as resolved.
Show resolved
Hide resolved
tsenart marked this conversation as resolved.
Show resolved
Hide resolved
|
||
register(PullRequestParticipantApprovedEvent.class, "pr:reviewer:approved"); | ||
register(PullRequestParticipantUnapprovedEvent.class, "pr:reviewer:unapproved"); | ||
register(PullRequestParticipantReviewedEvent.class, "pr:reviewer:needs_work"); | ||
tsenart marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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)); | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep!
(https://docs.atlassian.com/bitbucket-server/rest/5.16.0/bitbucket-rest.html) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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(); | ||
} | ||
} |
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); | ||
} |
There was a problem hiding this comment.
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.