Add webhook to automatically import pull requests

Adds a new servlet which handles webhook events sent from Github.
The servlet forwards the payload to an EventHandler based on the type of
the event, then the handler does the actual work.

Currently "ping" and "pull_request" events are supported

Change-Id: I389f78c702dee1c59192768acffcff49e0c80a62
diff --git a/README.md b/README.md
index 5392da8..a914ac0 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,22 @@
 * ClientId []: <provided client id from previous step>
 * ClientSecret []: <provided client secret from previous step>
 
+### Receiving Pull Request events to automatically import
+
+* Create a github user account which automatic import operation uses.
+* Register the account to your gerrit site by logging into Gerrit with the
+  account.
+* [Create webhook](https://developer.github.com/webhooks/creating/) on your
+  github repository.
+  * The payload URL should be something like
+    http://*your-gerrit-host.example*/plugins/github-plugin-*version*/webhook.
+  * It is recommended to specify some webhook secret.
+* Edit `etc/gerrit.config` and `etc/secure.config` files in your `$gerrit_site`.
+  * Add the github user account name as `webhookUser` entry in `github` section
+    of `etc/gerrit.config`
+  * Add the webhook secret as `webhookSecret` entry in `github` section of
+    `etc/secure.config`.
+
 ### Contributing to the GitHub plugin
 
 The GitHub plugin uses the lombok library, which provides a set of
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
index 44a4b4e..249a61e 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GitHubConfig.java
@@ -47,6 +47,8 @@
       "repositoryListLimit";
   private static final String CONF_PUBLIC_BASE_PROJECT = "publicBaseProject";
   private static final String CONF_PRIVATE_BASE_PROJECT = "privateBaseProject";
+  private static final String CONF_WEBHOOK_SECRET = "webhookSecret";
+  private static final String CONF_WEBHOOK_USER = "webhookUser";
 
   public final Path gitDir;
   public final int jobPoolLimit;
@@ -57,6 +59,8 @@
   public final String privateBaseProject;
   public final String publicBaseProject;
   public final String allProjectsName;
+  public final String webhookSecret;
+  public final String webhookUser;
 
   public static class NextPage {
     public final String uri;
@@ -108,6 +112,8 @@
     publicBaseProject =
         config.getString(CONF_SECTION, null, CONF_PUBLIC_BASE_PROJECT);
     allProjectsName = allProjectsNameProvider.get().toString();
+    webhookSecret = config.getString(CONF_SECTION, null, CONF_WEBHOOK_SECRET);
+    webhookUser = config.getString(CONF_SECTION, null, CONF_WEBHOOK_USER);
   }
 
   private String getSeparator(boolean redirect) {
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GuiceHttpModule.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GuiceHttpModule.java
index 97bc6be..744823e 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GuiceHttpModule.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/GuiceHttpModule.java
@@ -29,6 +29,7 @@
 import com.googlesource.gerrit.plugins.github.git.GitImporter;
 import com.googlesource.gerrit.plugins.github.git.PullRequestImportJob;
 import com.googlesource.gerrit.plugins.github.git.ReplicateProjectStep;
+import com.googlesource.gerrit.plugins.github.notification.WebhookServlet;
 import com.googlesource.gerrit.plugins.github.oauth.GitHubLogin;
 import com.googlesource.gerrit.plugins.github.oauth.PooledHttpClientProvider;
 import com.googlesource.gerrit.plugins.github.oauth.ScopedProvider;
@@ -73,8 +74,9 @@
     serve("*.css", "*.js", "*.png", "*.jpg", "*.woff", "*.gif", "*.ttf").with(
         VelocityStaticServlet.class);
     serve("*.gh").with(VelocityControllerServlet.class);
+    serve("/webhook").with(WebhookServlet.class);
 
     serve("/static/*").with(VelocityViewServlet.class);
-    filter("*").through(GitHubOAuthFilter.class);
+    filterRegex("(?!/webhook).*").through(GitHubOAuthFilter.class);
   }
 }
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PingHandler.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PingHandler.java
new file mode 100644
index 0000000..bbedb71
--- /dev/null
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PingHandler.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.github.notification;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.Singleton;
+
+/**
+ * Handles ping event in github webhook.
+ *
+ * @see <a href="https://developer.github.com/webhooks/#ping-event">Ping
+ *      Event</a>
+ */
+@Singleton
+class PingHandler implements WebhookEventHandler<PingHandler.Ping> {
+  private static final Logger logger =
+      LoggerFactory.getLogger(PingHandler.class);
+
+  static class Ping {
+    String zen;
+    int hookId;
+
+    @Override
+    public String toString() {
+      return "Ping [zen=" + zen + ", hookId=" + hookId + "]";
+    }
+  }
+
+  @Override
+  public boolean doAction(Ping payload) throws IOException {
+    logger.info(payload.toString());
+    return true;
+  }
+
+  @Override
+  public Class<Ping> getPayloadType() {
+    return Ping.class;
+  }
+}
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PullRequestHandler.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PullRequestHandler.java
new file mode 100644
index 0000000..f0567a0
--- /dev/null
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/PullRequestHandler.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.github.notification;
+
+import java.io.IOException;
+
+import org.kohsuke.github.GHEventPayload.PullRequest;
+import org.kohsuke.github.GHRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.github.git.PullRequestImportType;
+import com.googlesource.gerrit.plugins.github.git.PullRequestImporter;
+
+/**
+ * Handles pull_request event in github webhook.
+ *
+ * @see <a href=
+ *      "https://developer.github.com/v3/activity/events/types/#pullrequestevent">
+ *      Pull Request Event</a>
+ */
+@Singleton
+class PullRequestHandler implements WebhookEventHandler<PullRequest> {
+  private static final Logger logger = LoggerFactory
+      .getLogger(PullRequestHandler.class);
+  private final Provider<PullRequestImporter> prImportProvider;
+
+  @Inject
+  public PullRequestHandler(Provider<PullRequestImporter> pullRequestsImporter) {
+    this.prImportProvider = pullRequestsImporter;
+  }
+
+  @Override
+  public boolean doAction(PullRequest payload) throws IOException {
+    String action = payload.getAction();
+    if (action.equals("opened") || action.equals("synchronize")) {
+      GHRepository repository = payload.getRepository();
+      int prNumber = payload.getNumber();
+      PullRequestImporter prImporter = prImportProvider.get();
+      String organization = repository.getOwnerName();
+      String name = repository.getName();
+      logger.info("Importing {}/{}#{}", organization, name, prNumber);
+      prImporter.importPullRequest(0, organization, name, prNumber,
+          PullRequestImportType.Commits);
+      logger.info("Imported {}/{}#{}", organization, name, prNumber);
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  @Override
+  public Class<PullRequest> getPayloadType() {
+    return PullRequest.class;
+  }
+}
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookEventHandler.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookEventHandler.java
new file mode 100644
index 0000000..b0ab094
--- /dev/null
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookEventHandler.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.github.notification;
+
+import java.io.IOException;
+
+import org.kohsuke.github.GHEvent;
+
+/**
+ * Abstract interface to handler which is responsible for a specific github
+ * webhook event type.
+ *
+ * Implementation classes must be named by the convention which
+ * {@link WebhookServlet#getWebhookClassName(GHEvent)} defines.
+ *
+ * @param <T> Type of payload. Must be consistent to the event type.
+ *
+ * @return true if the event has been successfully processed
+ */
+interface WebhookEventHandler<T> {
+  Class<T> getPayloadType();
+
+  boolean doAction(T payload)
+      throws IOException;
+}
\ No newline at end of file
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java
new file mode 100644
index 0000000..69819fd
--- /dev/null
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/notification/WebhookServlet.java
@@ -0,0 +1,253 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.github.notification;
+
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.github.GitHubConfig;
+import com.googlesource.gerrit.plugins.github.oauth.GitHubLogin;
+import com.googlesource.gerrit.plugins.github.oauth.ScopedProvider;
+import com.googlesource.gerrit.plugins.github.oauth.UserScopedProvider;
+/**
+ * Handles webhook callbacks sent from Github. Delegates requests to
+ * implementations of {@link WebhookEventHandler}.
+ */
+@Singleton
+public class WebhookServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+  private static final Logger logger =
+      LoggerFactory.getLogger(WebhookServlet.class);
+
+  private static final String PACKAGE_NAME =
+      WebhookServlet.class.getPackage().getName();
+  private static final String SIGNATURE_PREFIX = "sha1=";
+  private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1";
+
+  private final Gson gson;
+
+  private final Map<String, WebhookEventHandler<?>> handlerByName =
+      new ConcurrentHashMap<>();
+  private final Injector injector;
+  private final GitHubConfig config;
+
+  private final UserScopedProvider<GitHubLogin> loginProvider;
+  private final ScopedProvider<GitHubLogin> requestScopedLoginProvider;
+  private final DynamicItem<WebSession> session;
+
+  @Inject
+  public WebhookServlet(UserScopedProvider<GitHubLogin> loginProvider,
+      ScopedProvider<GitHubLogin> requestScopedLoginProvider,
+      GitHubConfig config, Gson gson, DynamicItem<WebSession> session,
+      Injector injector) {
+    this.loginProvider = loginProvider;
+    this.requestScopedLoginProvider = requestScopedLoginProvider;
+    this.injector = injector;
+    this.config = config;
+    this.gson = gson;
+    this.session = session;
+  }
+
+  private WebhookEventHandler<?> getWebhookHandler(String name) {
+    if (name == null) {
+      logger.error("Null event name: cannot find any handler for it");
+      return null;
+    }
+
+    WebhookEventHandler<?> handler = handlerByName.get(name);
+    if (handler != null) {
+      return handler;
+    }
+
+    try {
+      String className = eventClassName(name);
+      Class<?> clazz = Class.forName(className);
+      handler = (WebhookEventHandler<?>) injector.getInstance(clazz);
+      handlerByName.put(name, handler);
+      logger.info("Loaded {}", clazz.getName());
+    } catch (ClassNotFoundException e) {
+      logger.error("Handler '" + name + "' not found. Skipping", e);
+    }
+
+    return handler;
+  }
+
+  private String eventClassName(String name) {
+    String[] nameParts = name.split("_");
+    List<String> classNameParts =
+        Lists.transform(Arrays.asList(nameParts),
+            new Function<String, String>() {
+              @Override
+              public String apply(String part) {
+                return Character.toUpperCase(part.charAt(0))
+                    + part.substring(1);
+              }
+            });
+    return PACKAGE_NAME + "." + Joiner.on("").join(classNameParts)
+        + "Handler";
+  }
+
+  @Override
+  protected void doPost(HttpServletRequest req, HttpServletResponse resp)
+      throws ServletException, IOException {
+    if (Strings.emptyToNull(config.webhookUser) == null) {
+      logger.error("No webhookUser defined: cannot process GitHub events");
+      resp.sendError(SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    WebhookEventHandler<?> handler =
+        getWebhookHandler(req.getHeader("X-Github-Event"));
+    if (handler == null) {
+      resp.sendError(SC_NOT_FOUND);
+      return;
+    }
+
+    BufferedReader reader = req.getReader();
+    String body = Joiner.on("\n").join(CharStreams.readLines(reader));
+    if (!validateSignature(req.getHeader("X-Hub-Signature"), body,
+        req.getCharacterEncoding())) {
+      logger.error("Signature mismatch to the payload");
+      resp.sendError(SC_FORBIDDEN);
+      return;
+    }
+
+    session.get().setUserAccountId(Account.Id.fromRef(config.webhookUser));
+    GitHubLogin login = loginProvider.get(config.webhookUser);
+    if (login == null || !login.isLoggedIn()) {
+      logger.error(
+          "Cannot login to github as {}. {}.webhookUser is not correctly configured?",
+          config.webhookUser, GitHubConfig.CONF_SECTION);
+      resp.setStatus(SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+    requestScopedLoginProvider.get(req).login(login.getToken());
+
+    if (callHander(handler, body)) {
+      resp.setStatus(SC_NO_CONTENT);
+    } else {
+      resp.sendError(SC_INTERNAL_SERVER_ERROR);
+    }
+  }
+
+  private <T> boolean callHander(WebhookEventHandler<T> handler, String jsonBody)
+      throws IOException {
+    T payload = gson.fromJson(jsonBody, handler.getPayloadType());
+    if (payload != null) {
+      return handler.doAction(payload);
+    } else {
+      logger.error("Cannot decode JSON payload '" + jsonBody + "' into "
+          + handler.getPayloadType().getName());
+      return false;
+    }
+  }
+
+  /**
+   * validates callback signature sent from github
+   *
+   * @param signatureHeader signature HTTP request header of a github webhook
+   * @param payload HTTP request body
+   * @return true if webhook secret is not configured or signatureHeader is
+   *         valid against payload and the secret, false if otherwise.
+   * @throws UnsupportedEncodingException
+   */
+  private boolean validateSignature(String signatureHeader, String body,
+      String encoding) throws UnsupportedEncodingException {
+    byte[] payload = body.getBytes(encoding == null ? "UTF-8" : encoding);
+    if (config.webhookSecret == null || config.webhookSecret.equals("")) {
+      logger.debug("{}.webhookSecret not configured. Skip signature validation",
+          GitHubConfig.CONF_SECTION);
+      return true;
+    }
+
+    if (!StringUtils.startsWith(signatureHeader, SIGNATURE_PREFIX)) {
+      logger.error("Unsupported webhook signature type: {}", signatureHeader);
+      return false;
+    }
+    byte[] signature;
+    try {
+      signature = Hex.decodeHex(
+          signatureHeader.substring(SIGNATURE_PREFIX.length()).toCharArray());
+    } catch (DecoderException e) {
+      logger.error("Invalid signature: {}", signatureHeader);
+      return false;
+    }
+    return MessageDigest.isEqual(signature, getExpectedSignature(payload));
+  }
+
+  /**
+   * Calculates the expected signature of the payload
+   *
+   * @param payload payload to calculate a signature for
+   * @return signature of the payload
+   * @see <a href=
+   *      "https://developer.github.com/webhooks/securing/#validating-payloads-from-github">
+   *      Validating payloads from GitHub</a>
+   */
+  private byte[] getExpectedSignature(byte[] payload) {
+    SecretKeySpec key =
+        new SecretKeySpec(config.webhookSecret.getBytes(), HMAC_SHA1_ALGORITHM);
+    Mac hmac;
+    try {
+      hmac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
+      hmac.init(key);
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException("Hmac SHA1 must be supported", e);
+    } catch (InvalidKeyException e) {
+      throw new IllegalStateException(
+          "Hmac SHA1 must be compatible to Hmac SHA1 Secret Key", e);
+    }
+    return hmac.doFinal(payload);
+  }
+}