diff --git a/plugins/jenkins/pom.xml b/plugins/jenkins/pom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c53a9ce52eec6a4af853147f839c0a7984ec3211
--- /dev/null
+++ b/plugins/jenkins/pom.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.jenkins-ci.plugins</groupId>
+        <artifactId>plugin</artifactId>
+        <version>2.33</version>
+        <relativePath />
+    </parent>
+    <groupId>org.reviewboard</groupId>
+    <artifactId>rbjenkins</artifactId>
+    <version>1.0-SNAPSHOT</version>
+    <packaging>hpi</packaging>
+    <properties>
+        <!-- Baseline Jenkins version you use to build the plugin. Users must have this version or newer to run. -->
+        <jenkins.version>2.7.3</jenkins.version>
+        <java.level>8</java.level>
+        <!-- Other properties you may want to use:
+          ~ jenkins-test-harness.version: Jenkins Test Harness version you use to test the plugin. For Jenkins version >= 1.580.1 use JTH 2.0 or higher.
+          ~ hpi-plugin.version: The HPI Maven Plugin version used by the plugin..
+          ~ stapler-plugin.version: The Stapler Maven plugin version required by the plugin.
+     -->
+    </properties>
+    <name>Review Board Plugin for Jenkins</name>
+    <description>Adds Review Board integration to Jenkins</description>
+    <url></url>
+    <licenses>
+        <license>
+            <name>MIT License</name>
+            <url>http://opensource.org/licenses/MIT</url>
+        </license>
+    </licenses>
+    <dependencies>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins</groupId>
+            <artifactId>structs</artifactId>
+            <version>1.7</version>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins</groupId>
+            <artifactId>credentials</artifactId>
+            <version>2.1.10</version>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins</groupId>
+            <artifactId>plain-credentials</artifactId>
+            <version>1.1</version>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-step-api</artifactId>
+            <version>2.12</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-cps</artifactId>
+            <version>2.39</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-job</artifactId>
+            <version>2.11.2</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-basic-steps</artifactId>
+            <version>2.6</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-durable-task-step</artifactId>
+            <version>2.13</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-api</artifactId>
+            <version>2.20</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jenkins-ci.plugins.workflow</groupId>
+            <artifactId>workflow-support</artifactId>
+            <version>2.14</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>2.15.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <!-- If you want this to appear on the wiki page:
+    <developers>
+      <developer>
+        <id>bhacker</id>
+        <name>Bob Q. Hacker</name>
+        <email>bhacker@nowhere.net</email>
+      </developer>
+    </developers> -->
+
+    <!-- Assuming you want to host on @jenkinsci:
+    <scm>
+        <connection>scm:git:git://github.com/jenkinsci/${project.artifactId}-plugin.git</connection>
+        <developerConnection>scm:git:git@github.com:jenkinsci/${project.artifactId}-plugin.git</developerConnection>
+        <url>https://github.com/jenkinsci/${project.artifactId}-plugin</url>
+    </scm>
+    -->
+    <repositories>
+        <repository>
+            <id>repo.jenkins-ci.org</id>
+            <url>https://repo.jenkins-ci.org/public/</url>
+        </repository>
+    </repositories>
+    <pluginRepositories>
+        <pluginRepository>
+            <id>repo.jenkins-ci.org</id>
+            <url>https://repo.jenkins-ci.org/public/</url>
+        </pluginRepository>
+    </pluginRepositories>
+</project>
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardException.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardException.java
new file mode 100644
index 0000000000000000000000000000000000000000..2e6b1512a013e4fc3853835a5189ce6dee44b673
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardException.java
@@ -0,0 +1,15 @@
+package org.reviewboard.rbjenkins.common;
+
+/**
+ * A ReviewBoardException is thrown when an error occurs while communicating
+ * with a Review Board server.
+ */
+public class ReviewBoardException extends Exception {
+    /**
+     * Construct the ReviewBoardException with the given error message.
+     * @param message Error message
+     */
+    public ReviewBoardException(String message) {
+        super(message);
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardUtils.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..efc5e803f14a01580e117a3bbfebaa0fbc21b345
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewBoardUtils.java
@@ -0,0 +1,62 @@
+package org.reviewboard.rbjenkins.common;
+
+import hudson.model.Action;
+import hudson.model.ParameterValue;
+import hudson.model.ParametersAction;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+
+/**
+ * Contains common utility functions.
+ */
+public class ReviewBoardUtils {
+    private static final String REVIEWBOARD_DIFF_REVISION =
+        "REVIEWBOARD_DIFF_REVISION";
+    private static final String REVIEWBOARD_REVIEW_ID =
+        "REVIEWBOARD_REVIEW_ID";
+    private static final String REVIEWBOARD_STATUS_UPDATE_ID =
+        "REVIEWBOARD_STATUS_UPDATE_ID";
+    private static final String REVIEWBOARD_SERVER =
+        "REVIEWBOARD_SERVER";
+
+    /**
+     * Parse the review request details from the build parameters.
+     * @param actions List of ParametersAction actions from the build
+     * @return ReviewRequest object
+     */
+    public static ReviewRequest parseReviewRequestFromParameters(
+        final List<ParametersAction> actions) throws MalformedURLException {
+        int reviewId = -1;
+        int revision = -1;
+        int statusUpdateId = -1;
+        URL serverURL = null;
+
+        for (Action action : actions) {
+            final ParametersAction pAction = (ParametersAction) action;
+            for (ParameterValue parameterValue : pAction.getParameters()) {
+                final Object value = parameterValue.getValue();
+                switch (parameterValue.getName()) {
+                    case REVIEWBOARD_REVIEW_ID:
+                        reviewId = Integer.parseInt((String) value);
+                        break;
+                    case REVIEWBOARD_DIFF_REVISION:
+                        revision = Integer.parseInt((String) value);
+                        break;
+                    case REVIEWBOARD_STATUS_UPDATE_ID:
+                        statusUpdateId = Integer.parseInt((String) value);
+                        break;
+                    case REVIEWBOARD_SERVER:
+                        serverURL = new URL((String) value);
+                        break;
+                    default:
+                        break;
+                }
+            }
+        }
+
+        return new ReviewRequest(reviewId, revision, statusUpdateId,
+                                 serverURL);
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewRequest.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewRequest.java
new file mode 100644
index 0000000000000000000000000000000000000000..8fe5a2337577ff1e10fdd5092d48d21c928b12fd
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/common/ReviewRequest.java
@@ -0,0 +1,90 @@
+package org.reviewboard.rbjenkins.common;
+
+import java.net.URL;
+
+/**
+ * Stores information about the Review Request which triggered the Jenkins
+ * build.
+ */
+public class ReviewRequest {
+    final private int reviewId;
+    final private int revision;
+    final private int statusUpdateId;
+    final private URL serverURL;
+
+    /**
+     * Enumerates the possible states for a status update to be in.
+     */
+    public enum StatusUpdateState {
+        SUCCESS_STATE("done-success"),
+        FAILURE_STATE("done-failure"),
+        TIMED_OUT_STATE("timed-out"),
+        ERROR_STATE("error"),
+        PENDING_STATE("pending");
+
+        private String value;
+
+        /**
+         * Constructs the status update with the given value.
+         * @param value Enum value
+         */
+        StatusUpdateState(final String value) {
+            this.value = value;
+        }
+
+        @Override
+        public String toString() {
+            return value;
+        }
+    }
+
+    /**
+     * Construct the ReviewRequest object with information about the review
+     * request.
+     * @param reviewId Review request ID
+     * @param revision Revision of the review request
+     * @param statusUpdateId Review request's status update ID
+     * @param serverURL Review Board server URL
+     */
+    public ReviewRequest(final int reviewId,
+                         final int revision,
+                         final int statusUpdateId,
+                         final URL serverURL) {
+        this.reviewId = reviewId;
+        this.revision = revision;
+        this.statusUpdateId = statusUpdateId;
+        this.serverURL = serverURL;
+    }
+
+    /**
+     * Returns the review ID of the review request.
+     * @return Review request ID
+     */
+    public int getReviewId() {
+        return reviewId;
+    }
+
+    /**
+     * Returns the revision of the review request.
+     * @return Review request revision
+     */
+    public int getRevision() {
+        return revision;
+    }
+
+    /**
+     * Returns the status update ID for the review request.
+     * @return Status update ID
+     */
+    public int getStatusUpdateId() {
+        return statusUpdateId;
+    }
+
+    /**
+     * Returns the server URL for the review request.
+     * @return Server URL
+     */
+    public URL getServerURL() {
+        return serverURL;
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..b568c04e9f9385776ffab4dd48b406e545d38838
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration.java
@@ -0,0 +1,82 @@
+package org.reviewboard.rbjenkins.config;
+
+import hudson.Extension;
+import jenkins.model.GlobalConfiguration;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides a global configuration for ReviewBoard servers.
+ */
+@Extension
+public class ReviewBoardGlobalConfiguration extends GlobalConfiguration {
+    private final Object serverConfigurationsLock = new Object();
+    private List<ReviewBoardServerConfiguration> serverConfigurations =
+        new ArrayList<>();
+
+    /**
+     * Construct the configuration from prior saved entries.
+     */
+    public ReviewBoardGlobalConfiguration() {
+        load();
+    }
+
+    /**
+     * Construct the global configuration with the given server configurations.
+     * @param serverConfigurations List of Review Board server configurations
+     */
+    public ReviewBoardGlobalConfiguration(
+        final List<ReviewBoardServerConfiguration> serverConfigurations) {
+        synchronized (serverConfigurationsLock) {
+            this.serverConfigurations = serverConfigurations;
+        }
+    }
+
+    /**
+     * Set the server configurations list then save the entries.
+     * @param serverConfigurations List of Review Board server configurations
+     */
+    public void setServerConfigurations(
+        final List<ReviewBoardServerConfiguration> serverConfigurations) {
+        synchronized (serverConfigurationsLock) {
+            this.serverConfigurations = serverConfigurations;
+            save();
+        }
+    }
+
+    /**
+     * Fetch the server configurations.
+     * @return Review Board server configurations
+     */
+    public List<ReviewBoardServerConfiguration> getServerConfigurations() {
+        return serverConfigurations;
+    }
+
+    /**
+     * Fetch the server configuration that matches the given name, returning
+     * null if one is not found.
+     * @param serverURL Review Board server URL
+     * @return server configuration or null
+     */
+    public ReviewBoardServerConfiguration getServerConfiguration(
+        final URL serverURL) {
+        synchronized (serverConfigurationsLock) {
+            for (ReviewBoardServerConfiguration config : serverConfigurations) {
+                try {
+                    if (new URI(config.getReviewBoardURL()).
+                        equals(serverURL.toURI())) {
+                        return config;
+                    }
+                } catch (URISyntaxException e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..20e1e7be7a113d214156d764461d5caed9fe1a78
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration.java
@@ -0,0 +1,156 @@
+package org.reviewboard.rbjenkins.config;
+
+import com.cloudbees.plugins.credentials.CredentialsMatchers;
+import com.cloudbees.plugins.credentials.CredentialsProvider;
+import com.cloudbees.plugins.credentials.common.StandardListBoxModel;
+import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder;
+import hudson.Extension;
+import hudson.model.*;
+import hudson.security.ACL;
+import hudson.util.FormValidation;
+import hudson.util.ListBoxModel;
+import jenkins.model.Jenkins;
+import org.jenkinsci.plugins.plaincredentials.StringCredentials;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.QueryParameter;
+import org.reviewboard.rbjenkins.Messages;
+
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.List;
+
+/**
+ * Stores configuration details for a Review Board server.
+ */
+public class ReviewBoardServerConfiguration extends
+    AbstractDescribableImpl<ReviewBoardServerConfiguration> {
+    private final String reviewBoardURL;
+    private final String credentialsId;
+
+    /**
+     * Constructs the server configuration with the given name, Review Board
+     * URL and API token.
+     * @param reviewBoardURL Review Board server URL
+     * @param credentialsId Credentials identifier
+     */
+    @DataBoundConstructor
+    public ReviewBoardServerConfiguration(final String reviewBoardURL,
+                                          final String credentialsId) {
+        this.reviewBoardURL = reviewBoardURL;
+        this.credentialsId = credentialsId;
+    }
+
+    /**
+     * Returns the Review Board endpoint. This is required for Jenkins to
+     * display the endpoint details in the GUI.
+     * @return Review Board endpoint
+     */
+    public String getReviewBoardURL() {
+        return reviewBoardURL;
+    }
+
+    /**
+     * Returns the credentials ID, which is used to store the API token.
+     * @return Credentials ID
+     */
+    public String getCredentialsId() {
+        return credentialsId;
+    }
+
+    /**
+     * Fetch the Review Board API token from the credential provider.
+     * @return The API token, or "UNKNOWN" if not found.
+     */
+    public String getReviewBoardAPIToken() {
+        final List<StringCredentials> credentials = CredentialsMatchers.filter(
+            CredentialsProvider.lookupCredentials(
+                StringCredentials.class,
+                Jenkins.getInstance(),
+                ACL.SYSTEM,
+                URIRequirementBuilder.fromUri(reviewBoardURL).build()),
+            CredentialsMatchers.withId(credentialsId));
+
+        if (!credentials.isEmpty()) {
+            return credentials.get(0).getSecret().getPlainText();
+        } else {
+            return "UNKNOWN";
+        }
+    }
+
+    /**
+     * Provides the description of the notification build step and validation
+     * functions for fields in its configuration form.
+     */
+    @Extension
+    public static final class DescriptorImpl
+        extends Descriptor<ReviewBoardServerConfiguration> {
+        /**
+         * Returns the display name for this configuration, as shown in the
+         * Jenkins GUI.
+         * @return Notification build step display name
+         */
+        @Override
+        public String getDisplayName() {
+            return Messages.
+                ReviewBoardServerConfiguration_DescriptorImpl_DisplayName();
+        }
+
+        /**
+         * Validates the given config name specified in the form.
+         * @param value Configuration name
+         * @return FormValidation status
+         */
+        public FormValidation doCheckName(final @QueryParameter String value) {
+            if (value.isEmpty()) {
+                return FormValidation.error(
+                    Messages.ReviewBoard_Error_InvalidName());
+            } else {
+                return FormValidation.ok();
+            }
+        }
+
+        /**
+         * Validates the given Review Board URL specified in the form.
+         * @param value Review Board URL
+         * @return FormValidation status
+         */
+        public FormValidation doCheckReviewBoardURL(
+            final @QueryParameter String value) {
+            try {
+                final URL url = new URL(value);
+                url.toURI();
+            } catch (final MalformedURLException | URISyntaxException e) {
+                return FormValidation.error(
+                    Messages.ReviewBoard_Error_InvalidURL());
+            }
+            return FormValidation.ok();
+        }
+
+        /**
+         * Fills the API token credentials dropdown box with credentials
+         * that are valid for the Review Board endpoint.
+         * @param reviewBoardURL Review Board server URL
+         * @param credentialsId Credentials identifier
+         * @return ListBoxModel containing credential IDs
+         */
+        public ListBoxModel doFillCredentialsIdItems(
+            @QueryParameter String reviewBoardURL,
+            @QueryParameter String credentialsId) {
+            if (!Jenkins.getInstance().hasPermission(Jenkins.ADMINISTER)) {
+                return new StandardListBoxModel().
+                    includeCurrentValue(credentialsId);
+            }
+
+            return new StandardListBoxModel()
+                .includeEmptyValue()
+                .includeMatchingAs(
+                    ACL.SYSTEM,
+                    Jenkins.getInstance(),
+                    StringCredentials.class,
+                    URIRequirementBuilder.fromUri(reviewBoardURL).build(),
+                    CredentialsMatchers.always()
+                );
+        }
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier.java
new file mode 100644
index 0000000000000000000000000000000000000000..ae1180dc708a290d95aa7ec8de4917fee5efef35
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier.java
@@ -0,0 +1,265 @@
+package org.reviewboard.rbjenkins.steps;
+
+import hudson.Extension;
+import hudson.Launcher;
+import hudson.model.*;
+import hudson.tasks.*;
+import hudson.util.FormValidation;
+import jenkins.model.GlobalConfiguration;
+import org.apache.commons.httpclient.HttpStatus;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.QueryParameter;
+import org.reviewboard.rbjenkins.Messages;
+import org.reviewboard.rbjenkins.common.ReviewBoardException;
+import org.reviewboard.rbjenkins.common.ReviewBoardUtils;
+import org.reviewboard.rbjenkins.common.ReviewRequest;
+import org.reviewboard.rbjenkins.config.ReviewBoardGlobalConfiguration;
+import org.reviewboard.rbjenkins.config.ReviewBoardServerConfiguration;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Creates a post-build step in Jenkins for notifying Review Board of the
+ * status of the triggered build.
+ */
+public class ReviewBoardNotifier extends Notifier {
+    /**
+     * Constructs the notifier.
+     */
+    @DataBoundConstructor
+    public ReviewBoardNotifier() {
+    }
+
+    /**
+     * This function will be called as part of the post-build step. It will
+     * notify Review Board of the status of the build and update the status
+     * update resource.
+     * @param build The active Jenkins build
+     * @param launcher Process launcher
+     * @param listener Logger
+     * @return True if build can continue
+     * @throws IOException
+     */
+    @Override
+    public boolean perform(final AbstractBuild<?, ?> build,
+                           final Launcher launcher,
+                           final BuildListener listener)
+        throws IOException {
+        final ReviewRequest reviewRequest;
+
+        try {
+            reviewRequest = ReviewBoardUtils.parseReviewRequestFromParameters(
+                build.getActions(ParametersAction.class));
+        } catch (MalformedURLException e) {
+            listener.error("URL provided in REVIEWBOARD_SERVER is not a " +
+                           "valid URL.");
+
+            // Return true so we don't kill the build, as this is a post-build
+            // step and isn't essential.
+            return true;
+        }
+
+        // Check that we've successfully received all parameters.
+        if (reviewRequest.getReviewId() == -1 ||
+            reviewRequest.getStatusUpdateId() == -1 ||
+            reviewRequest.getServerURL() == null) {
+            listener.error("REVIEWBOARD_REVIEW_ID, or " +
+                           "REVIEWBOARD_STATUS_UPDATE_ID, or " +
+                           "REVIEWBOARD_SERVER not provided in parameters");
+
+            // Return true so we don't kill the build, as this is a post-build
+            // step and isn't essential.
+            return true;
+        }
+
+        final Result result = build.getResult();
+        final ReviewRequest.StatusUpdateState state;
+        final String description;
+
+        if (result == Result.SUCCESS) {
+            state = ReviewRequest.StatusUpdateState.SUCCESS_STATE;
+            description = Messages.ReviewBoard_Job_Success();
+        } else {
+            state = ReviewRequest.StatusUpdateState.FAILURE_STATE;
+            if (result == Result.ABORTED) {
+                description = Messages.ReviewBoard_Job_Aborted();
+            } else if (result == Result.NOT_BUILT) {
+                description = Messages.ReviewBoard_Job_NotBuilt();
+            } else if (result == Result.UNSTABLE) {
+                description = Messages.ReviewBoard_Job_Unstable();
+            } else {
+                description = Messages.ReviewBoard_Job_Failure();
+            }
+        }
+
+        // Notify review board of the build result
+        try {
+            updateStatusUpdate(reviewRequest, state, description);
+        } catch (final ReviewBoardException e) {
+            listener.error("Unable to notify Review Board of the result of " +
+                           "the build. Cause: " + e.getMessage());
+        }
+
+        return true;
+    }
+
+    /**
+     * Updates a review request's status update object. This is what is shown
+     * on the review request page detailing the integration's status.
+     * @param reviewRequest Review request
+     * @param state Status update state
+     * @param description Status update description
+     * @throws IOException
+     * @throws ReviewBoardException
+     */
+    public void updateStatusUpdate(
+        final ReviewRequest reviewRequest,
+        final ReviewRequest.StatusUpdateState state,
+        final String description) throws IOException, ReviewBoardException {
+        final String path = String.format(
+            "/api/review-requests/%d/status-updates/%d/",
+            reviewRequest.getReviewId(),
+            reviewRequest.getStatusUpdateId());
+
+        final ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+        if (globalConfig == null) {
+            throw new ReviewBoardException(
+                "No Review Board server configurations found.");
+        }
+
+        final ReviewBoardServerConfiguration serverConfig =
+            globalConfig.getServerConfiguration(reviewRequest.getServerURL());
+        if (serverConfig == null) {
+            throw new ReviewBoardException(
+                String.format("No Review Board server configuration found " +
+                              "for server URL '%s'.",
+                              reviewRequest.getServerURL().toString()));
+        }
+
+        final String token = String.format(
+            "token %s", serverConfig.getReviewBoardAPIToken());
+        final URL url = new URL(
+            new URL(serverConfig.getReviewBoardURL()), path);
+        final HttpURLConnection conn =
+            (HttpURLConnection) url.openConnection();
+        conn.setRequestMethod("PUT");
+        conn.setRequestProperty("Authorization", token);
+        conn.setRequestProperty("Content-Type",
+                                "application/x-www-form-urlencoded");
+        conn.setDoOutput(true);
+
+        // Generate our form content with the state and description of the
+        // build result.
+        final String encodedState = URLEncoder.encode(
+            state.toString(), StandardCharsets.UTF_8.toString());
+        final String encodedDescription = URLEncoder.encode(
+            description, StandardCharsets.UTF_8.toString());
+        final String content = String.format(
+            "state=%s&description=%s",
+            encodedState,
+            encodedDescription);
+
+        try {
+            final BufferedWriter writer = new BufferedWriter(
+                new OutputStreamWriter(conn.getOutputStream(),
+                                       StandardCharsets.UTF_8));
+            writer.write(content);
+            writer.flush();
+            writer.close();
+        } catch (final ConnectException e) {
+            throw new ReviewBoardException(
+                "Review Board URL could not be reached. Cause: " +
+                e.getMessage());
+        }
+
+        final int responseCode = conn.getResponseCode();
+        switch (responseCode) {
+            case HttpStatus.SC_OK:
+                break;
+            case HttpStatus.SC_NOT_FOUND:
+                throw new ReviewBoardException(
+                    "Status Update or Review Request not found");
+            case HttpStatus.SC_FORBIDDEN:
+                throw new ReviewBoardException(
+                    "Review Board API token does not have permission to " +
+                    "update Status Update");
+            case HttpStatus.SC_UNAUTHORIZED:
+                throw new ReviewBoardException(
+                    "Review Board API token is invalid");
+            default:
+                throw new ReviewBoardException(
+                    String.format("Unhandled response code sent from Review " +
+                                  "Board: %d",
+                                  responseCode));
+        }
+    }
+
+    /**
+     * Declares the synchronization required for this step, for which we
+     * require none.
+     * @return BuildStepMonitor.NONE
+     */
+    @Override
+    public BuildStepMonitor getRequiredMonitorService() {
+        return BuildStepMonitor.NONE;
+    }
+
+    /**
+     * Provides the description of the notification build step and validation
+     * functions for fields in its configuration form.
+     */
+    @Symbol("notifyReviewBoard")
+    @Extension
+    public static final class DescriptorImpl
+        extends BuildStepDescriptor<Publisher> {
+        /**
+         * This validates the Review Board server configuration name. Mostly it
+         * checks if there has been a server configuration created.
+         * @param value Review Board server config name
+         * @return FormValidation
+         */
+        public FormValidation doCheckReviewBoardServer(
+            final @QueryParameter String value) {
+            final ReviewBoardGlobalConfiguration globalConfig =
+                GlobalConfiguration.all().get(
+                    ReviewBoardGlobalConfiguration.class);
+
+            if (globalConfig == null ||
+                globalConfig.getServerConfigurations().isEmpty()) {
+                return FormValidation.error(
+                    Messages.ReviewBoard_Error_NoServers());
+            }
+
+            return FormValidation.ok();
+        }
+
+        /**
+         * Informs Jenkins of whether or not this build step is applicable to
+         * the current job, which it always is.
+         * @param aClass
+         * @return true
+         */
+        @Override
+        public boolean isApplicable(
+            final Class<? extends AbstractProject> aClass) {
+            return true;
+        }
+
+        /**
+         * Returns the display name for this build step, as shown in the
+         * Jenkins GUI.
+         * @return Notification build step display name
+         */
+        @Override
+        public String getDisplayName() {
+            return Messages.ReviewBoardNotifier_DescriptorImpl_DisplayName();
+        }
+    }
+}
diff --git a/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetup.java b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetup.java
new file mode 100644
index 0000000000000000000000000000000000000000..12247d6847af66c95a68c20fc3eb928f69f5f22c
--- /dev/null
+++ b/plugins/jenkins/src/main/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetup.java
@@ -0,0 +1,189 @@
+package org.reviewboard.rbjenkins.steps;
+
+import hudson.Launcher;
+import hudson.Extension;
+import hudson.FilePath;
+import hudson.Proc;
+import hudson.model.*;
+import hudson.util.FormValidation;
+import hudson.tasks.Builder;
+import hudson.tasks.BuildStepDescriptor;
+import jenkins.model.GlobalConfiguration;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.QueryParameter;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+
+import jenkins.tasks.SimpleBuildStep;
+import org.jenkinsci.Symbol;
+import org.reviewboard.rbjenkins.Messages;
+import org.reviewboard.rbjenkins.common.ReviewBoardUtils;
+import org.reviewboard.rbjenkins.common.ReviewRequest;
+import org.reviewboard.rbjenkins.config.ReviewBoardGlobalConfiguration;
+import org.reviewboard.rbjenkins.config.ReviewBoardServerConfiguration;
+
+/**
+ * Creates a build step in Jenkins which will install and use rbtools to apply
+ * the patch from Review Board for the given review request.
+ */
+public class ReviewBoardSetup extends Builder implements SimpleBuildStep {
+    private static final String RBT_INSTALL = "pip install --user rbtools";
+    private static final String RBT_COMMAND =
+        "rbt patch --api-token %s --server %s --diff-revision %d %d";
+
+    /**
+     * Constructs the setup step.
+     */
+    @DataBoundConstructor
+    public ReviewBoardSetup() {
+    }
+
+    /**
+     * This function is called as part of a build when the setup step has been
+     * added. This will install rbtools and then use it to apply the patch
+     * for the given review request, as specified in the build parameters.
+     * @param run Current build
+     * @param workspace Active workspace
+     * @param launcher Process launcher
+     * @param listener Logger
+     * @throws InterruptedException
+     * @throws IOException
+     */
+    @Override
+    public void perform(final Run<?, ?> run, final FilePath workspace,
+                        final Launcher launcher, final TaskListener listener)
+        throws InterruptedException,IOException {
+        final ReviewRequest reviewRequest;
+
+        try {
+            reviewRequest = ReviewBoardUtils.parseReviewRequestFromParameters(
+                run.getActions(ParametersAction.class));
+        } catch (MalformedURLException e) {
+            listener.error("URL provided in REVIEWBOARD_SERVER is not a " +
+                           "valid URL.");
+            run.setResult(Result.FAILURE);
+            return;
+        }
+
+        // Check that we've successfully received all required parameters.
+        if (reviewRequest.getReviewId() == -1 ||
+            reviewRequest.getRevision() == -1 ||
+            reviewRequest.getStatusUpdateId() == -1 ||
+            reviewRequest.getServerURL() == null) {
+            listener.error("REVIEWBOARD_REVIEW_ID, " +
+                           "REVIEWBOARD_DIFF_REVISION or " +
+                           "REVIEWBOARD_STATUS_UPDATE_ID, or " +
+                           "REVIEWBOARD_SERVER not provided in " +
+                           "parameters");
+            run.setResult(Result.FAILURE);
+            return;
+        }
+
+        final ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+        if (globalConfig == null) {
+            listener.error("No Review Board server configurations found.");
+            run.setResult(Result.FAILURE);
+            return;
+        }
+
+        final ReviewBoardServerConfiguration serverConfig =
+            globalConfig.getServerConfiguration(reviewRequest.getServerURL());
+        if (serverConfig == null) {
+            listener.error(
+                String.format("No Review Board server configuration found " +
+                              "for server URL '%s'.",
+                              reviewRequest.getServerURL().toString()));
+            run.setResult(Result.FAILURE);
+            return;
+        }
+
+        // Construct commands to install and use rbtools.
+        final String rbtCommand = String.format(
+            RBT_COMMAND,
+            serverConfig.getReviewBoardAPIToken(),
+            serverConfig.getReviewBoardURL(),
+            reviewRequest.getRevision(),
+            reviewRequest.getReviewId());
+
+        // Generate a command mask to hide the API token from the console
+        // output.
+        final boolean[] rbtCommandMask =
+            new boolean[rbtCommand.split(" ").length];
+
+        // Set the 4th entry's mask to true - this is the API token.
+        rbtCommandMask[3] = true;
+
+        final Launcher.ProcStarter[] commands = {
+            launcher.launch().cmds(RBT_INSTALL.split(" ")),
+            launcher.launch().cmds(rbtCommand.split(" ")).masks(rbtCommandMask)
+        };
+
+        for (Launcher.ProcStarter args : commands) {
+            // Run the command in the workspace
+            args.stdout(listener).pwd(workspace)
+                .envs(run.getEnvironment(listener));
+            final Proc process = launcher.launch(args);
+
+            final int result = process.join();
+            if (result != 0) {
+                run.setResult(Result.FAILURE);
+                return;
+            }
+        }
+    }
+
+    /**
+     * Provides the description of the setup build step and validation
+     * functions for fields in its form.
+     */
+    @Symbol("publishReview")
+    @Extension
+    public static final class DescriptorImpl
+        extends BuildStepDescriptor<Builder> {
+        /**
+         * This validates the Review Board server configuration name. Mostly it
+         * checks if there has been a server configuration created.
+         * @param value Review Board server config name
+         * @return FormValidation
+         */
+        public FormValidation doCheckReviewBoardServer(
+            final @QueryParameter String value) {
+            final ReviewBoardGlobalConfiguration globalConfig =
+                GlobalConfiguration.all().get(
+                    ReviewBoardGlobalConfiguration.class);
+
+            if (globalConfig == null ||
+                globalConfig.getServerConfigurations().isEmpty()) {
+                return FormValidation.error(
+                    Messages.ReviewBoard_Error_NoServers());
+            }
+
+            return FormValidation.ok();
+        }
+
+        /**
+         * Informs Jenkins of whether or not this build step is applicable to
+         * the current job, which it always is.
+         * @param aClass
+         * @return true
+         */
+        @Override
+        public boolean isApplicable(
+            final Class<? extends AbstractProject> aClass) {
+            return true;
+        }
+
+        /**
+         * Returns the display name for this build step, as shown in the
+         * Jenkins GUI.
+         * @return Setup build step display name
+         */
+        @Override
+        public String getDisplayName() {
+            return Messages.ReviewBoardSetup_DescriptorImpl_DisplayName();
+        }
+    }
+}
diff --git a/plugins/jenkins/src/main/resources/index.jelly b/plugins/jenkins/src/main/resources/index.jelly
new file mode 100644
index 0000000000000000000000000000000000000000..fb34f30a9e6f6469f33eae3db1f7843bada77529
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/index.jelly
@@ -0,0 +1,4 @@
+<?jelly escape-by-default='true'?>
+<div>
+    This plugin enables integration with Review Board, allowing review requests to automatically start builds, as well as have status updates provided by Jenkins.
+</div>
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/Messages.properties b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/Messages.properties
new file mode 100644
index 0000000000000000000000000000000000000000..9d42c92e6575572b5c46b6f3fbe4ff6d17eb727c
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/Messages.properties
@@ -0,0 +1,12 @@
+ReviewBoardSetup.DescriptorImpl.DisplayName=Apply patch from Review Board
+ReviewBoardNotifier.DescriptorImpl.DisplayName=Publish build status to Review Board
+ReviewBoardServerConfiguration.DescriptorImpl.DisplayName=Review Board Server
+ReviewBoard.Error.InvalidAPIToken=The given Review Board API token is invalid
+ReviewBoard.Error.InvalidURL=The given Review Board server URL is invalid
+ReviewBoard.Error.InvalidName=You must specify a configuration name
+ReviewBoard.Error.NoServers=You must first create a Review Board server configuration
+ReviewBoard.Job.Success=build succeeded.
+ReviewBoard.Job.Failure=build failed.
+ReviewBoard.Job.Aborted=build aborted.
+ReviewBoard.Job.NotBuilt=build did not complete.
+ReviewBoard.Job.Unstable=build succeeded with test failures.
\ No newline at end of file
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.jelly b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.jelly
new file mode 100644
index 0000000000000000000000000000000000000000..63cd7663ea62de65639344c370a712d95722f25a
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.jelly
@@ -0,0 +1,11 @@
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define"
+         xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
+    <f:section title="${%ReviewBoard}">
+        <f:entry title="${%ReviewBoardServers}">
+            <f:repeatableHeteroProperty field="serverConfigurations"
+                                        hasHeader="true"
+                                        addCaption="${%AddReviewBoardServer}"/>
+        </f:entry>
+    </f:section>
+</j:jelly>
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.properties b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.properties
new file mode 100644
index 0000000000000000000000000000000000000000..861071e999956059ff9da7a6ec50c9b5b40caf63
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfiguration/config.properties
@@ -0,0 +1,3 @@
+ReviewBoard=Review Board
+ReviewBoardServers=Review Board Servers
+AddReviewBoardServer=Add Review Board Server
\ No newline at end of file
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.jelly b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.jelly
new file mode 100644
index 0000000000000000000000000000000000000000..9c4e7b1fd6d35bf807c922782194ed681eb724de
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.jelly
@@ -0,0 +1,11 @@
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define"
+         xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"
+         xmlns:c="/lib/credentials">
+    <f:entry title="${%ReviewBoardURL}" field="reviewBoardURL">
+        <f:textbox />
+    </f:entry>
+    <f:entry title="${%ReviewBoardAPIToken}" field="credentialsId">
+        <c:select />
+    </f:entry>
+</j:jelly>
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.properties b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.properties
new file mode 100644
index 0000000000000000000000000000000000000000..1c159eb85597618bc984cc081bc4168eb0cff2b3
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/config/ReviewBoardServerConfiguration/config.properties
@@ -0,0 +1,2 @@
+ReviewBoardURL=Review Board URL
+ReviewBoardAPIToken=Review Board API Token
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.jelly b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.jelly
new file mode 100644
index 0000000000000000000000000000000000000000..c65aea63549e2f8b16d710b6f29f8d9c95a8b7c7
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.jelly
@@ -0,0 +1,5 @@
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define"
+         xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
+    <f:description>${%Description}</f:description>
+</j:jelly>
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.properties b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.properties
new file mode 100644
index 0000000000000000000000000000000000000000..fdac67dc85167467efa494f23bb27751c53191f7
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardNotifier/config.properties
@@ -0,0 +1 @@
+Description=This step will notify Review Board of the result of the build. This step requires that the Review Board server details have been added in the "Configure System" admin page.
\ No newline at end of file
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.jelly b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.jelly
new file mode 100644
index 0000000000000000000000000000000000000000..c65aea63549e2f8b16d710b6f29f8d9c95a8b7c7
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.jelly
@@ -0,0 +1,5 @@
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define"
+         xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
+    <f:description>${%Description}</f:description>
+</j:jelly>
diff --git a/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.properties b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.properties
new file mode 100644
index 0000000000000000000000000000000000000000..9910305463dfff55cbf99d9bf45d48869c0e3f63
--- /dev/null
+++ b/plugins/jenkins/src/main/resources/org/reviewboard/rbjenkins/steps/ReviewBoardSetup/config.properties
@@ -0,0 +1 @@
+Description=This step will apply a patch from Review Board using RBTools. This step requires that the Review Board server details have been added in the "Configure System" admin page.
\ No newline at end of file
diff --git a/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfigurationTest.java b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfigurationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..902745be840c5745a86d3f343edb2dfb76379d76
--- /dev/null
+++ b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardGlobalConfigurationTest.java
@@ -0,0 +1,63 @@
+package org.reviewboard.rbjenkins.config;
+
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public class ReviewBoardGlobalConfigurationTest {
+    private static final String REVIEWBOARD_URL = "http://localhost";
+    private static final String REVIEWBOARD_CREDENTIALS = "api_token";
+
+    @Test
+    public void testConstructorAndSetter() throws Exception {
+        List<ReviewBoardServerConfiguration> serverConfigs = new ArrayList<>();
+
+        ReviewBoardGlobalConfiguration config =
+            new ReviewBoardGlobalConfiguration(serverConfigs);
+
+        config = Mockito.spy(config);
+        // save() will otherwise throw an exception due to not being properly
+        // setup.
+        Mockito.doNothing().when(config).save();
+
+        config.setServerConfigurations(serverConfigs);
+    }
+
+    @Test
+    public void testExistingServer() throws Exception {
+        List<ReviewBoardServerConfiguration> serverConfigs = new ArrayList<>();
+        serverConfigs.add(
+            new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                               REVIEWBOARD_CREDENTIALS));
+
+        ReviewBoardGlobalConfiguration config =
+            new ReviewBoardGlobalConfiguration(serverConfigs);
+
+        ReviewBoardServerConfiguration server =
+            config.getServerConfiguration(new URL(REVIEWBOARD_URL));
+
+        assertNotNull(server);
+    }
+
+    @Test
+    public void testNonExistentServer() throws Exception {
+        List<ReviewBoardServerConfiguration> serverConfigs = new ArrayList<>();
+        serverConfigs.add(
+            new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                               REVIEWBOARD_CREDENTIALS));
+
+        ReviewBoardGlobalConfiguration config =
+            new ReviewBoardGlobalConfiguration(serverConfigs);
+
+        ReviewBoardServerConfiguration server =
+            config.getServerConfiguration(new URL("http://nonexistent"));
+
+        assertNull(server);
+    }
+}
diff --git a/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfigurationTest.java b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfigurationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..beef60259433e2910a44b5547b4b899c29ef45cb
--- /dev/null
+++ b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/config/ReviewBoardServerConfigurationTest.java
@@ -0,0 +1,71 @@
+package org.reviewboard.rbjenkins.config;
+
+import com.cloudbees.plugins.credentials.CredentialsScope;
+import com.cloudbees.plugins.credentials.SystemCredentialsProvider;
+import hudson.util.Secret;
+import jenkins.model.GlobalConfiguration;
+import org.jenkinsci.plugins.plaincredentials.StringCredentials;
+import org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+public class ReviewBoardServerConfigurationTest {
+    private static final String REVIEWBOARD_URL = "http://localhost";
+    private static final String REVIEWBOARD_CREDENTIALS = "credentials_id";
+    private static final String REVIEWBOARD_API_TOKEN = "api_token";
+
+    @Rule
+    public JenkinsRule jenkins = new JenkinsRule();
+
+    @After
+    public void resetTest() {
+        // Ensure that each test has a clean global config
+        GlobalConfiguration.all().get(ReviewBoardGlobalConfiguration.class).
+            getServerConfigurations().clear();
+        SystemCredentialsProvider.getInstance().getCredentials().clear();
+    }
+
+    @Test
+    public void testMissingCredentials() throws Exception {
+        ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+
+        ReviewBoardServerConfiguration serverConfig =
+            new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                               REVIEWBOARD_CREDENTIALS);
+
+        globalConfig.getServerConfigurations().add(serverConfig);
+
+        assertNotEquals(serverConfig.getReviewBoardAPIToken(),
+                        REVIEWBOARD_API_TOKEN);
+    }
+
+    @Test
+    public void testCredentials() throws Exception {
+        StringCredentials credentials = new StringCredentialsImpl(
+            CredentialsScope.SYSTEM, REVIEWBOARD_CREDENTIALS, "Description",
+            Secret.fromString(REVIEWBOARD_API_TOKEN));
+
+        SystemCredentialsProvider.getInstance().getCredentials().
+            add(credentials);
+
+        ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+
+        ReviewBoardServerConfiguration serverConfig =
+            new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                               REVIEWBOARD_CREDENTIALS);
+
+        globalConfig.getServerConfigurations().add(serverConfig);
+
+        assertEquals(serverConfig.getReviewBoardAPIToken(),
+                     REVIEWBOARD_API_TOKEN);
+    }
+}
diff --git a/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifierTest.java b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifierTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..29dae024438f1ae88381f7382f25588d44799cd2
--- /dev/null
+++ b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardNotifierTest.java
@@ -0,0 +1,192 @@
+package org.reviewboard.rbjenkins.steps;
+
+import hudson.model.*;
+import jenkins.model.GlobalConfiguration;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.MockBuilder;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mockito;
+import org.reviewboard.rbjenkins.Messages;
+import org.reviewboard.rbjenkins.common.ReviewBoardException;
+import org.reviewboard.rbjenkins.common.ReviewRequest;
+import org.reviewboard.rbjenkins.config.ReviewBoardGlobalConfiguration;
+import org.reviewboard.rbjenkins.config.ReviewBoardServerConfiguration;
+
+import java.io.IOException;
+
+public class ReviewBoardNotifierTest {
+    private static final String REVIEWBOARD_URL = "http://localhost";
+    private static final String REVIEWBOARD_API_TOKEN = "api_token";
+    private static final int REVIEW_ID = 1;
+    private static final int STATUS_UPDATE_ID = 2;
+
+    @Rule
+    final public JenkinsRule jenkins = new JenkinsRule();
+
+    @After
+    public void resetGlobalConfig() {
+        // Ensure that each test has a clean global config
+        GlobalConfiguration.all().get(ReviewBoardGlobalConfiguration.class).
+            getServerConfigurations().clear();
+    }
+
+    public void setupGlobalConfig() {
+        resetGlobalConfig();
+        ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+
+        ReviewBoardServerConfiguration serverConfig =
+            new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                               REVIEWBOARD_API_TOKEN);
+
+        globalConfig.getServerConfigurations().add(serverConfig);
+    }
+
+    public void addBuildParameters(final FreeStyleProject project)
+        throws IOException {
+        final StringParameterDefinition serverURL =
+            new StringParameterDefinition("REVIEWBOARD_SERVER",
+                                          REVIEWBOARD_URL);
+        final StringParameterDefinition reviewId =
+            new StringParameterDefinition("REVIEWBOARD_REVIEW_ID",
+                                          String.valueOf(REVIEW_ID));
+        final StringParameterDefinition statusUpdateId =
+            new StringParameterDefinition("REVIEWBOARD_STATUS_UPDATE_ID",
+                                          String.valueOf(STATUS_UPDATE_ID));
+
+        project.addProperty(new ParametersDefinitionProperty(reviewId,
+                                                             statusUpdateId,
+                                                             serverURL));
+    }
+
+    @Test
+    public void testConfigRoundtrip() throws Exception {
+        setupGlobalConfig();
+        FreeStyleProject project = jenkins.createFreeStyleProject();
+        project.getPublishersList().add(new ReviewBoardNotifier());
+        project = jenkins.configRoundtrip(project);
+        jenkins.assertEqualDataBoundBeans(new ReviewBoardNotifier(),
+                                          project.getPublishersList().get(0));
+    }
+
+    @Test
+    public void testBuildNoParameters() throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        final ReviewBoardNotifier publisher = new ReviewBoardNotifier();
+        project.getPublishersList().add(publisher);
+
+        final StringParameterDefinition invalidURL =
+            new StringParameterDefinition("REVIEWBOARD_SERVER",
+                                          "htp?:/invalidurl?/.");
+
+        project.addProperty(new ParametersDefinitionProperty(invalidURL));
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.SUCCESS, build);
+        jenkins.assertLogContains(
+            "URL provided in REVIEWBOARD_SERVER is not a valid URL.", build);
+    }
+
+    @Test
+    public void testBuildInvalidURL() throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        final ReviewBoardNotifier publisher = new ReviewBoardNotifier();
+        project.getPublishersList().add(publisher);
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.SUCCESS, build);
+        jenkins.assertLogContains(
+            "REVIEWBOARD_REVIEW_ID, or REVIEWBOARD_STATUS_UPDATE_ID, or " +
+            "REVIEWBOARD_SERVER not provided in parameters", build);
+    }
+
+    @Test
+    public void testBuildWithStatuses() throws Exception {
+        testBuildStatus(Result.SUCCESS,
+                        ReviewRequest.StatusUpdateState.SUCCESS_STATE,
+                        org.reviewboard.rbjenkins.Messages.
+                            ReviewBoard_Job_Success());
+
+        testBuildStatus(Result.ABORTED,
+                        ReviewRequest.StatusUpdateState.FAILURE_STATE,
+                        org.reviewboard.rbjenkins.Messages.
+                            ReviewBoard_Job_Aborted());
+
+        testBuildStatus(Result.NOT_BUILT,
+                        ReviewRequest.StatusUpdateState.FAILURE_STATE,
+                        org.reviewboard.rbjenkins.Messages.
+                            ReviewBoard_Job_NotBuilt());
+
+        testBuildStatus(Result.UNSTABLE,
+                        ReviewRequest.StatusUpdateState.FAILURE_STATE,
+                        org.reviewboard.rbjenkins.Messages.
+                            ReviewBoard_Job_Unstable());
+
+        testBuildStatus(Result.FAILURE,
+                        ReviewRequest.StatusUpdateState.FAILURE_STATE,
+                        org.reviewboard.rbjenkins.Messages.
+                            ReviewBoard_Job_Failure());
+    }
+
+    private void testBuildStatus(Result result,
+                                 ReviewRequest.StatusUpdateState status,
+                                 String message) throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        ReviewBoardNotifier publisher = new ReviewBoardNotifier();
+        publisher = Mockito.spy(publisher);
+        Mockito.doNothing().when(publisher).updateStatusUpdate(
+            ArgumentMatchers.any(ReviewRequest.class),
+            ArgumentMatchers.eq(status),
+            ArgumentMatchers.eq(message));
+
+        // Force build result
+        project.getBuildersList().add(new MockBuilder(result));
+        project.getPublishersList().add(publisher);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(result, build);
+
+        Mockito.verify(publisher, Mockito.times(1)).updateStatusUpdate(
+            ArgumentMatchers.any(ReviewRequest.class),
+            ArgumentMatchers.eq(status),
+            ArgumentMatchers.eq(message));
+    }
+
+    @Test
+    public void testBuildUpdateError() throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        ReviewBoardNotifier publisher = new ReviewBoardNotifier();
+        publisher = Mockito.spy(publisher);
+        Mockito.doThrow(new ReviewBoardException("")).when(publisher).
+            updateStatusUpdate(
+                ArgumentMatchers.any(ReviewRequest.class),
+                ArgumentMatchers.eq(
+                    ReviewRequest.StatusUpdateState.SUCCESS_STATE),
+                ArgumentMatchers.eq(
+                    org.reviewboard.rbjenkins.Messages.
+                        ReviewBoard_Job_Success()));
+        project.getPublishersList().add(publisher);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.SUCCESS, build);
+
+        Mockito.verify(publisher, Mockito.times(1)).updateStatusUpdate(
+            ArgumentMatchers.any(ReviewRequest.class),
+            ArgumentMatchers.eq(ReviewRequest.StatusUpdateState.SUCCESS_STATE),
+            ArgumentMatchers.eq(Messages.ReviewBoard_Job_Success()));
+        jenkins.assertLogContains(
+            "Unable to notify Review Board of the result of the build.",
+            build);
+    }
+}
diff --git a/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetupTest.java b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetupTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..57433680f168bc50aad32a28341595358cc7b74f
--- /dev/null
+++ b/plugins/jenkins/src/test/java/org/reviewboard/rbjenkins/steps/ReviewBoardSetupTest.java
@@ -0,0 +1,202 @@
+package org.reviewboard.rbjenkins.steps;
+
+import static org.junit.Assert.assertTrue;
+
+import hudson.Launcher;
+import hudson.Proc;
+import hudson.model.*;
+import jenkins.model.GlobalConfiguration;
+import org.apache.commons.lang.ArrayUtils;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.FakeLauncher;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.PretendSlave;
+import org.reviewboard.rbjenkins.config.ReviewBoardGlobalConfiguration;
+import org.reviewboard.rbjenkins.config.ReviewBoardServerConfiguration;
+
+import java.io.IOException;
+
+public class ReviewBoardSetupTest {
+    private static final String REVIEWBOARD_URL = "http://localhost";
+    private static final String REVIEWBOARD_CREDENTIALS = "api_token";
+    private static final String REVIEW_ID = "1";
+    private static final String STATUS_UPDATE_ID = "2";
+    private static final String DIFF_REVISION = "3";
+
+    @Rule
+    final public JenkinsRule jenkins = new JenkinsRule();
+
+    @After
+    public void resetGlobalConfig() {
+        // Ensure that each test has a clean global config
+        GlobalConfiguration.all().get(ReviewBoardGlobalConfiguration.class).
+            getServerConfigurations().clear();
+    }
+
+    public void setupGlobalConfig() {
+        setupGlobalConfig(true);
+    }
+
+    public void setupGlobalConfig(boolean addServerConfig) {
+        ReviewBoardGlobalConfiguration globalConfig =
+            GlobalConfiguration.all().get(
+                ReviewBoardGlobalConfiguration.class);
+
+        if (addServerConfig) {
+            ReviewBoardServerConfiguration serverConfig =
+                new ReviewBoardServerConfiguration(REVIEWBOARD_URL,
+                                                   REVIEWBOARD_CREDENTIALS);
+
+            globalConfig.getServerConfigurations().add(serverConfig);
+        }
+    }
+
+    public void addBuildParameters(final FreeStyleProject project)
+        throws IOException {
+        final StringParameterDefinition serverURL =
+            new StringParameterDefinition("REVIEWBOARD_SERVER",
+                                          REVIEWBOARD_URL);
+        final StringParameterDefinition reviewId =
+            new StringParameterDefinition("REVIEWBOARD_REVIEW_ID", REVIEW_ID);
+        final StringParameterDefinition diffRevision =
+            new StringParameterDefinition("REVIEWBOARD_DIFF_REVISION",
+                                          DIFF_REVISION);
+        final StringParameterDefinition statusUpdateId =
+            new StringParameterDefinition("REVIEWBOARD_STATUS_UPDATE_ID",
+                                          STATUS_UPDATE_ID);
+
+        project.addProperty(new ParametersDefinitionProperty(reviewId,
+                                                             diffRevision,
+                                                             statusUpdateId,
+                                                             serverURL));
+    }
+
+    @Test
+    public void testConfigRoundtrip() throws Exception {
+        setupGlobalConfig();
+        FreeStyleProject project = jenkins.createFreeStyleProject();
+        project.getBuildersList().add(new ReviewBoardSetup());
+        project = jenkins.configRoundtrip(project);
+        jenkins.assertEqualDataBoundBeans(new ReviewBoardSetup(),
+                                          project.getBuildersList().get(0));
+    }
+
+    @Test
+    public void testBuildNoParameters() throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.FAILURE, build);
+        jenkins.assertLogContains(
+            "REVIEWBOARD_REVIEW_ID, REVIEWBOARD_DIFF_REVISION or " +
+            "REVIEWBOARD_STATUS_UPDATE_ID, or REVIEWBOARD_SERVER not " +
+            "provided in parameters", build);
+    }
+
+    @Test
+    public void testBuildInvalidURL() throws Exception {
+        setupGlobalConfig();
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+
+        final StringParameterDefinition invalidURL =
+            new StringParameterDefinition("REVIEWBOARD_SERVER",
+                                          "htp?:/invalidurl?/.");
+
+        project.addProperty(new ParametersDefinitionProperty(invalidURL));
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.FAILURE, build);
+        jenkins.assertLogContains(
+            "URL provided in REVIEWBOARD_SERVER is not a valid URL.", build);
+    }
+
+    @Test
+    public void testBuildWithParameters() throws Exception {
+        setupGlobalConfig();
+        final String[] commands = {
+            "pip install --user rbtools",
+            String.format("rbt patch --api-token %s --server %s " +
+                          "--diff-revision %s %s",
+                          "UNKNOWN",
+                          REVIEWBOARD_URL,
+                          DIFF_REVISION,
+                          REVIEW_ID)
+        };
+
+        final PretendSlave slave = jenkins.createPretendSlave(procStarter -> {
+            // Check that we run the correct commands.
+            String command = String.join(" ", procStarter.cmds());
+            assertTrue(ArrayUtils.contains(commands, command));
+
+            return new FakeLauncher.FinishedProc(0);
+        });
+
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+        project.setAssignedNode(slave);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.SUCCESS, build);
+    }
+
+    @Test
+    public void testBuildWithNoGlobalConfig() throws Exception {
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.FAILURE, build);
+        jenkins.assertLogContains(
+            "No Review Board server configuration found for server URL " +
+            "'http://localhost'.", build);
+    }
+
+    @Test
+    public void testBuildWithNoServerConfig() throws Exception {
+        setupGlobalConfig(false);
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.FAILURE, build);
+        jenkins.assertLogContains(
+            "No Review Board server configuration found for server URL " +
+            "'http://localhost'.", build);
+    }
+
+    @Test
+    public void testBuildCommandFails() throws Exception {
+        setupGlobalConfig();
+        final PretendSlave slave = jenkins.createPretendSlave(procStarter -> {
+            // Here we're testing that a failed command results in a failed
+            // build.
+            return new FakeLauncher.FinishedProc(1);
+        });
+
+        final FreeStyleProject project = jenkins.createFreeStyleProject();
+        addBuildParameters(project);
+
+        final ReviewBoardSetup builder = new ReviewBoardSetup();
+        project.getBuildersList().add(builder);
+        project.setAssignedNode(slave);
+
+        final FreeStyleBuild build = project.scheduleBuild2(0).get();
+        jenkins.assertBuildStatus(Result.FAILURE, build);
+    }
+}
\ No newline at end of file
diff --git a/rbintegrations/extension.py b/rbintegrations/extension.py
index 610bb62425bbdb06a6b5a79ae14afe65bd2d428f..52af72c7335464a1e614cf2c36b2c5d12a06e007 100644
--- a/rbintegrations/extension.py
+++ b/rbintegrations/extension.py
@@ -9,6 +9,7 @@ from reviewboard.urls import reviewable_url_names, review_request_url_names
 from rbintegrations.asana.integration import AsanaIntegration
 from rbintegrations.circleci.integration import CircleCIIntegration
 from rbintegrations.idonethis.integration import IDoneThisIntegration
+from rbintegrations.jenkinsci.integration import JenkinsCIIntegration
 from rbintegrations.mattermost.integration import MattermostIntegration
 from rbintegrations.slack.integration import SlackIntegration
 from rbintegrations.travisci.integration import TravisCIIntegration
@@ -28,6 +29,7 @@ class RBIntegrationsExtension(Extension):
         AsanaIntegration,
         CircleCIIntegration,
         IDoneThisIntegration,
+        JenkinsCIIntegration,
         MattermostIntegration,
         SlackIntegration,
         TravisCIIntegration,
diff --git a/rbintegrations/jenkinsci/__init__.py b/rbintegrations/jenkinsci/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rbintegrations/jenkinsci/api.py b/rbintegrations/jenkinsci/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..ceb42356bad289acd2572bb06cf2d35c30e1608c
--- /dev/null
+++ b/rbintegrations/jenkinsci/api.py
@@ -0,0 +1,201 @@
+"""Utilities for interacting with the Jenkins CI API."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+
+from django.utils.six.moves.urllib.error import HTTPError
+from django.utils.six.moves.urllib.parse import (quote, urlencode)
+from django.utils.six.moves.urllib.request import urlopen
+
+from rbintegrations.util.urlrequest import URLRequest
+
+
+logger = logging.getLogger(__name__)
+
+
+class JenkinsAPI(object):
+    """Object for interacting with the Jenkins CI API."""
+
+    csrf_protection_enabled = True
+    crumb = None
+    crumb_request_field = None
+
+    def __init__(self, endpoint, job_name, username, password):
+        """Initialize the object.
+
+        Args:
+            endpoint (unicode):
+                Jenkins server endpoint.
+
+            job_name (unicode):
+                Job name on Jenkins.
+
+            username (unicode):
+                Jenkins username.
+
+            password (unicode):
+                Jenkins password.
+        """
+        self.endpoint = endpoint
+        self.job_name = job_name
+        self.username = username
+        self.password = password
+
+    def test_connection(self):
+        """Test the connection to the Jenkins server.
+
+        This is used for verifying both the URL and user credentials are
+        correct."""
+        self._make_request('%s/api/json?pretty=true' % self.endpoint,
+                           method='GET')
+
+    def start_build(self, patch_info):
+        """Start a build.
+
+        Args:
+            patch_info (dict):
+                Contains the review ID, review diff revision and the status
+                update ID.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        data = {
+            'parameter': [
+                {
+                    'name': 'REVIEWBOARD_SERVER',
+                    'value': patch_info['reviewboard_server']
+                },
+                {
+                    'name': 'REVIEWBOARD_REVIEW_ID',
+                    'value': patch_info['review_id']
+                },
+                {
+                    'name': 'REVIEWBOARD_DIFF_REVISION',
+                    'value': patch_info['diff_revision']
+                },
+                {
+                    'name': 'REVIEWBOARD_STATUS_UPDATE_ID',
+                    'value': patch_info['status_update_id']
+                }
+            ]
+        }
+
+        # This is not part of the official REST API, but is however listed in
+        # the Jenkins wiki as the correct way to initiate a remote build.
+        #
+        # This method of passing in the build parameters may change in the
+        # future.
+        self._make_request(
+            '%s/job/%s/build' % (self.endpoint,
+                                 quote(self.job_name)),
+            body=urlencode({
+                'json': json.dumps(data)
+            }),
+            content_type='application/x-www-form-urlencoded',
+            method='POST'
+        )
+
+    def _fetch_csrf_token(self):
+        """Fetches a CSRF token from the Jenkins server.
+
+        This is required for making requests to API endpoints when using basic
+        authentication. A crumb is no longer required when using API token
+        authentication to access buildWithParameters."""
+        data = self._make_raw_request('%s/crumbIssuer/api/json'
+                                      % self.endpoint)
+
+        result = json.loads(data)
+
+        self.crumb = result['crumb']
+        self.crumb_request_field = result['crumbRequestField']
+
+    def _make_request(self, url, body=None, method='GET',
+                      content_type=''):
+        """Make an HTTP request.
+
+        This will first attempt to fetch a CSRF token if we do not currently
+        have one.
+
+        Args:
+            url (unicode):
+                The URL to make the request against.
+
+            body (unicode or bytes, optional):
+                The content of the request.
+
+            method (unicode, optional):
+                The request method. If not provided, it defaults to a ``GET``
+                request.
+
+            content_type (unicode, optional):
+                The type of the content being POSTed.
+
+        Returns:
+            bytes:
+            The contents of the HTTP response body.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        if self.csrf_protection_enabled and not self.crumb:
+            try:
+                self._fetch_csrf_token()
+            except HTTPError as e:
+                if e.code == 404:
+                    self.csrf_protection_enabled = False
+                else:
+                    raise e
+
+        return self._make_raw_request(url, body, method, content_type)
+
+    def _make_raw_request(self, url, body=None, method='GET',
+                          content_type=''):
+        """Make an HTTP request.
+
+        Args:
+            url (unicode):
+                The URL to make the request against.
+
+            body (unicode or bytes, optional):
+                The content of the request.
+
+            method (unicode, optional):
+                The request method. If not provided, it defaults to a ``GET``
+                request.
+
+            content_type (unicode, optional):
+                The type of the content being POSTed.
+
+        Returns:
+            bytes:
+            The contents of the HTTP response body.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        logger.debug('Making request to Jenkins CI %s', url)
+
+        headers = {}
+
+        if self.crumb:
+            headers[self.crumb_request_field] = self.crumb
+
+        if content_type:
+            headers['Content-Type'] = content_type
+
+        request = URLRequest(
+            url,
+            body=body,
+            method=method,
+            headers=headers)
+
+        request.add_basic_auth(self.username, self.password)
+
+        u = urlopen(request)
+        return u.read()
diff --git a/rbintegrations/jenkinsci/common.py b/rbintegrations/jenkinsci/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..228ec78855ec0b918b4726e6cfa28637be9e7d77
--- /dev/null
+++ b/rbintegrations/jenkinsci/common.py
@@ -0,0 +1,65 @@
+"""Common functions used with the various Jenkins classes."""
+
+from __future__ import unicode_literals
+
+import logging
+
+from django.contrib.auth.models import User
+from django.db import IntegrityError, transaction
+from djblets.avatars.services import URLAvatarService
+from djblets.siteconfig.models import SiteConfiguration
+from reviewboard.avatars import avatar_services
+
+
+logger = logging.getLogger(__name__)
+
+
+def icon_static_urls():
+    """Return the icons used for the integration."""
+    from rbintegrations.extension import RBIntegrationsExtension
+
+    extension = RBIntegrationsExtension.instance
+
+    return {
+        '1x': extension.get_static_url('images/jenkinsci/icon.png'),
+        '2x': extension.get_static_url('images/jenkinsci/icon@2x.png'),
+    }
+
+
+def get_or_create_jenkins_user():
+    """Return a user to use for Jenkins CI.
+
+    Returns:
+        django.contrib.auth.models.User:
+        A user instance.
+    """
+    try:
+        return User.objects.get(username='jenkins-ci')
+    except User.DoesNotExist:
+        logger.info('Creating new user for Jenkins CI')
+        siteconfig = SiteConfiguration.objects.get_current()
+        noreply_email = siteconfig.get('mail_default_from')
+
+        with transaction.atomic():
+            try:
+                user = User.objects.create(username='jenkins-ci',
+                                           email=noreply_email,
+                                           first_name='Jenkins',
+                                           last_name='CI')
+            except IntegrityError:
+                # Another process/thread beat us to it.
+                return User.objects.get(username='jenkins-ci')
+
+            profile = user.get_profile()
+            profile.should_send_email = False
+            profile.save()
+
+            if avatar_services.is_enabled(
+                URLAvatarService.avatar_service_id):
+                avatar_service = avatar_services.get_avatar_service(
+                    URLAvatarService.avatar_service_id)
+                # TODO: make somewhat higher-res versions for the main
+                # avatar.
+                avatar_service.setup(user, icon_static_urls)
+
+            return user
diff --git a/rbintegrations/jenkinsci/forms.py b/rbintegrations/jenkinsci/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..80b7a488d7d29b3e30e3ca178afce409b4720dd0
--- /dev/null
+++ b/rbintegrations/jenkinsci/forms.py
@@ -0,0 +1,120 @@
+"""The form for configuring the Jenkins CI integration."""
+
+from __future__ import unicode_literals
+
+import logging
+
+from django import forms
+from django.utils import six
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.translation import ugettext_lazy as _
+from djblets.forms.fields import ConditionsField
+from reviewboard.integrations.forms import IntegrationConfigForm
+from reviewboard.reviews.conditions import ReviewRequestConditionChoices
+from reviewboard.webapi.models import WebAPIToken
+
+from rbintegrations.jenkinsci.api import JenkinsAPI
+from rbintegrations.jenkinsci.common import get_or_create_jenkins_user
+
+
+logger = logging.getLogger(__name__)
+
+
+class JenkinsCIIntegrationConfigForm(IntegrationConfigForm):
+    """Form for configuring Jenkins CI"""
+
+    conditions = ConditionsField(ReviewRequestConditionChoices,
+                                 label=_('Conditions'))
+
+    jenkins_endpoint = forms.URLField(
+        label=_('Server'),
+        help_text=_('Server endpoint URL.'))
+
+    jenkins_job_name = forms.CharField(
+        label=_('Job Name'),
+        help_text=_('Job name. This can include the following variables: '
+                    '{repository}, {branch}.'))
+
+    jenkins_username = forms.CharField(
+        label=_('Username'),
+        help_text=_('User who has access to the above job.'))
+
+    jenkins_password = forms.CharField(
+        label=_('Password'),
+        help_text=_("User's password or its API token."),
+        widget=forms.PasswordInput)
+
+    jenkins_user_token = forms.CharField(
+        label=_('Review Board API Token'),
+        help_text=_('This API token is used by Jenkins to update build '
+                    'status. Please specify this in the Jenkins-side '
+                    'configuration. Note that if you switch the local site '
+                    'this API token will be updated upon saving.'),
+        required=False,
+        widget=forms.TextInput(attrs={'readonly': True}))
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the form.
+
+        Args:
+            *args (tuple):
+                Arguments for the form.
+
+            **kwargs (dict):
+                Keyword arguments for the form.
+        """
+        super(JenkinsCIIntegrationConfigForm, self).__init__(*args, **kwargs)
+
+        user = get_or_create_jenkins_user()
+        local_site = self.fields['local_site'].initial
+
+        # Fetch the user's API token using the current local site
+        try:
+            token = user.webapi_tokens.filter(local_site=local_site)[0]
+        except IndexError:
+            token = WebAPIToken.objects.generate_token(
+                user, local_site=local_site, auto_generated=True)
+
+        self.initial['jenkins_user_token'] = token.token
+
+    def clean(self):
+        """Clean the form.
+
+        This validates the user credentials that have been provided.
+
+        Returns:
+            dict:
+            The cleaned data.
+        """
+        cleaned_data = super(JenkinsCIIntegrationConfigForm, self).clean()
+
+        if self._errors:
+            # Form validation has already failed.
+            return cleaned_data
+
+        api = JenkinsAPI(cleaned_data.get('jenkins_endpoint'),
+                         cleaned_data.get('jenkins_job_name'),
+                         cleaned_data.get('jenkins_username'),
+                         cleaned_data.get('jenkins_password'))
+
+        try:
+            # Tests a simple endpoint to ensure the user credentials are
+            # correct.
+            api.test_connection()
+        except HTTPError as e:
+            if e.code == 403:
+                message = _('Unable to authenticate with the provided user.')
+            elif e.code == 401:
+                message = _('Provided user credentials are incorrect.')
+            else:
+                message = six.text_type(e)
+
+            self._errors['jenkins_username'] = self.error_class([message])
+            self._errors['jenkins_password'] = self.error_class([message])
+
+            return cleaned_data
+        except URLError as e:
+            self._errors['jenkins_endpoint'] = self.error_class([e])
+            return cleaned_data
+
+        return cleaned_data
diff --git a/rbintegrations/jenkinsci/integration.py b/rbintegrations/jenkinsci/integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..31dd19727968879530fe45d8aa964c60bfcc982f
--- /dev/null
+++ b/rbintegrations/jenkinsci/integration.py
@@ -0,0 +1,151 @@
+"""Integration for building changes on Jenkins CI."""
+
+from __future__ import unicode_literals
+
+import logging
+
+from django.utils.six.moves.urllib.error import HTTPError
+from djblets.util.decorators import cached_property
+from reviewboard.admin.server import get_server_url
+from reviewboard.extensions.hooks import SignalHook
+from reviewboard.integrations import Integration
+from reviewboard.reviews.models.status_update import StatusUpdate
+from reviewboard.reviews.signals import review_request_published
+
+from rbintegrations.jenkinsci.api import JenkinsAPI
+from rbintegrations.jenkinsci.common import get_or_create_jenkins_user
+from rbintegrations.jenkinsci.forms import JenkinsCIIntegrationConfigForm
+
+
+logger = logging.getLogger(__name__)
+
+
+class JenkinsCIIntegration(Integration):
+    """Integrates Review Board with Jenkins CI."""
+
+    name = 'Jenkins CI'
+    description = 'Builds diffs posted to Review Board using Jenkins CI.'
+    config_form_cls = JenkinsCIIntegrationConfigForm
+
+    def initialize(self):
+        """Initialize the integration hooks."""
+        SignalHook(self, review_request_published,
+                   self._on_review_request_published)
+
+    @cached_property
+    def icon_static_urls(self):
+        """The icons used for the integration."""
+        from rbintegrations.extension import RBIntegrationsExtension
+
+        extension = RBIntegrationsExtension.instance
+
+        return {
+            '1x': extension.get_static_url('images/jenkinsci/icon.png'),
+            '2x': extension.get_static_url('images/jenkinsci/icon@2x.png'),
+        }
+
+    def _on_review_request_published(self, sender, review_request,
+                                     changedesc=None, **kwargs):
+        """Handle when a review request is published.
+
+        Args:
+            sender (object):
+                The sender of the signal.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request which was published.
+
+            changedesc (reviewboard.changedescs.models.ChangeDescription,
+                        optional):
+                The change description associated with the publish.
+
+            **kwargs (dict):
+                Additional keyword arguments.
+        """
+        repository = review_request.repository
+
+        # This integration will work with all repository types that rbtools
+        # supports.
+        if not repository:
+            return
+
+        diffset = review_request.get_latest_diffset()
+
+        # TODO: the following code is common to all CI integrations.
+        # make a common class?
+
+        # Don't build any review requests that don't include diffs.
+        if not diffset:
+            return
+
+        # If this was an update to a review request, make sure that there was a
+        # diff update in it.
+        if changedesc is not None:
+            fields_changed = changedesc.fields_changed
+
+            if ('diff' not in fields_changed or
+                'added' not in fields_changed['diff']):
+                return
+
+        matching_configs = [
+            config
+            for config in self.get_configs(review_request.local_site)
+            if config.match_conditions(form_cls=self.config_form_cls,
+                                       review_request=review_request)
+        ]
+
+        if not matching_configs:
+            return
+
+        user = get_or_create_jenkins_user()
+
+        patch_info = {
+            'review_id': review_request.display_id,
+            'diff_revision': diffset.revision,
+        }
+
+        for config in matching_configs:
+            status_update = StatusUpdate.objects.create(
+                service_id='jenkins-ci',
+                user=user,
+                summary='Jenkins CI',
+                description='starting build...',
+                state=StatusUpdate.PENDING,
+                review_request=review_request,
+                change_description=changedesc)
+            patch_info['status_update_id'] = status_update.pk
+            patch_info['reviewboard_server'] = get_server_url(
+                local_site=config.local_site)
+
+            job_name = self._replace_job_variables(
+                config.get('jenkins_job_name'), repository, review_request)
+
+            # Time to kick off the build!
+            logger.info('Triggering Jenkins CI build for review request %s '
+                        '(diffset revision %d)',
+                        review_request.get_absolute_url(), diffset.revision)
+            api = JenkinsAPI(config.get('jenkins_endpoint'),
+                             job_name,
+                             config.get('jenkins_username'),
+                             config.get('jenkins_password'))
+
+            try:
+                api.start_build(patch_info)
+            except HTTPError as e:
+                status_update.description = ('failed to communicate with '
+                                             'Jenkins.')
+
+                if e.code == 404:
+                    status_update.description = 'failed, job does not exist.'
+
+                status_update.state = StatusUpdate.ERROR
+                status_update.save()
+
+    def _replace_job_variables(self, job_name, repository, review_request):
+        job_name = job_name.replace('{repository}', repository.name)
+
+        if repository.tool.name in ('Git',):
+            job_name = job_name.replace('{branch}', review_request.branch)
+
+        return job_name
diff --git a/rbintegrations/jenkinsci/tests.py b/rbintegrations/jenkinsci/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc89157893b78e8bc89a9243db7a5c2c17aa0eb3
--- /dev/null
+++ b/rbintegrations/jenkinsci/tests.py
@@ -0,0 +1,243 @@
+"""Unit tests for the Jenkins CI integration."""
+
+from __future__ import unicode_literals
+
+import json
+
+from django.utils.six.moves.urllib.error import HTTPError
+from django.utils.six.moves.urllib.parse import urlencode
+from djblets.conditions import ConditionSet, Condition
+from reviewboard.reviews.conditions import ReviewRequestRepositoriesChoice
+
+from rbintegrations.jenkinsci.api import JenkinsAPI
+from rbintegrations.jenkinsci.integration import JenkinsCIIntegration
+from rbintegrations.testing.testcases import IntegrationTestCase
+
+
+class JenkinsCIIntegrationTests(IntegrationTestCase):
+    """Tests for Jenkins CI."""
+
+    integration_cls = JenkinsCIIntegration
+    fixtures = ['test_scmtools', 'test_site', 'test_users']
+
+    def test_build_new_review_request(self):
+        """Testing JenkinsCIIntegration builds a new review request"""
+        repository = self.create_repository()
+        review_request = self.create_review_request(repository=repository)
+        diffset = self.create_diffset(review_request=review_request)
+        diffset.base_commit_id = '8fd69d70f07b57c21ad8733c1c04ae604d21493f'
+        diffset.save()
+
+        self._create_config()
+        self.integration.enable_integration()
+
+        data = {}
+
+        def _make_raw_request(api, url, body=None, method='GET', headers={},
+                              content_type=None):
+            # We can't actually do any assertions in here, because they'll get
+            # swallowed by SignalHook's sandboxing. We therefore record the
+            # data we need and assert later.
+            data['url'] = url
+            data['request'] = body
+            return '{}'
+
+        self.spy_on(JenkinsAPI._make_raw_request, call_fake=_make_raw_request)
+        self.spy_on(JenkinsAPI._fetch_csrf_token, call_original=False)
+
+        review_request.publish(review_request.submitter)
+
+        self.assertEqual(data['url'], 'http://localhost:8000/job/job_1/build')
+
+        self.assertEqual(data['request'], urlencode({
+            'json': json.dumps({
+                'parameter': [
+                    {
+                        'name': 'REVIEWBOARD_SERVER',
+                        'value': 'http://example.com/'
+                    },
+                    {
+                        'name': 'REVIEWBOARD_REVIEW_ID',
+                        'value': review_request.display_id
+                    },
+                    {
+                        'name': 'REVIEWBOARD_DIFF_REVISION',
+                        'value': 1
+                    },
+                    {
+                        'name': 'REVIEWBOARD_STATUS_UPDATE_ID',
+                        'value': 1
+                    }
+                ]
+            })
+        }))
+
+        self.assertTrue(JenkinsAPI._make_raw_request.called)
+        self.assertTrue(JenkinsAPI._fetch_csrf_token.called)
+
+    def test_build_new_review_request_with_local_site(self):
+        """Testing JenkinsCIIntegration builds a new review request with a
+        local site"""
+        repository = self.create_repository(with_local_site=True)
+        review_request = self.create_review_request(repository=repository,
+                                                    with_local_site=True)
+        diffset = self.create_diffset(review_request=review_request)
+        diffset.base_commit_id = '8fd69d70f07b57c21ad8733c1c04ae604d21493f'
+        diffset.save()
+
+        self._create_config(with_local_site=True)
+        self.integration.enable_integration()
+
+        data = {}
+
+        def _make_raw_request(api, url, body=None, method='GET', headers={},
+                              content_type=None):
+            # We can't actually do any assertions in here, because they'll get
+            # swallowed by SignalHook's sandboxing. We therefore record the
+            # data we need and assert later.
+            data['url'] = url
+            data['request'] = body
+            return '{}'
+
+        self.spy_on(JenkinsAPI._make_raw_request, call_fake=_make_raw_request)
+        self.spy_on(JenkinsAPI._fetch_csrf_token, call_original=False)
+
+        review_request.publish(review_request.submitter)
+
+        self.assertEqual(data['url'], 'http://localhost:8000/job/job_1/build')
+
+        self.assertEqual(data['request'], urlencode({
+            'json': json.dumps({
+                'parameter': [
+                    {
+                        'name': 'REVIEWBOARD_SERVER',
+                        'value': ('http://example.com/s/%s/' %
+                                  self.local_site_name)
+                    },
+                    {
+                        'name': 'REVIEWBOARD_REVIEW_ID',
+                        'value': review_request.display_id
+                    },
+                    {
+                        'name': 'REVIEWBOARD_DIFF_REVISION',
+                        'value': 1
+                    },
+                    {
+                        'name': 'REVIEWBOARD_STATUS_UPDATE_ID',
+                        'value': 1
+                    }
+                ]
+            })
+        }))
+
+        self.assertTrue(JenkinsAPI._make_raw_request.called)
+        self.assertTrue(JenkinsAPI._fetch_csrf_token.called)
+
+    def test_build_new_review_request_no_csrf_protection(self):
+        """Testing that JenkinsCIIntegration builds a new review request
+        without csrf protection"""
+        repository = self.create_repository()
+        review_request = self.create_review_request(repository=repository)
+        diffset = self.create_diffset(review_request=review_request)
+        diffset.base_commit_id = '8fd69d70f07b57c21ad8733c1c04ae604d21493f'
+        diffset.save()
+
+        self._create_config()
+        self.integration.enable_integration()
+
+        def _fetch_csrf_token(api):
+            raise HTTPError('', 404, 'Not found', None, None)
+
+        self.spy_on(JenkinsAPI._make_raw_request, call_original=False)
+        self.spy_on(JenkinsAPI._fetch_csrf_token, call_fake=_fetch_csrf_token)
+
+        review_request.publish(review_request.submitter)
+
+        self.assertTrue(JenkinsAPI._make_raw_request.called)
+
+    def test_build_new_review_request_crumb_fetch_error(self):
+        """Testing that JenkinsCIIntegration does not build when fetching the
+        csrf token (or crumb) results in a non-404 error code"""
+        repository = self.create_repository()
+        review_request = self.create_review_request(repository=repository)
+        diffset = self.create_diffset(review_request=review_request)
+        diffset.base_commit_id = '8fd69d70f07b57c21ad8733c1c04ae604d21493f'
+        diffset.save()
+
+        self._create_config()
+        self.integration.enable_integration()
+
+        def _fetch_csrf_token(api):
+            raise HTTPError('', 400, 'Bad request', None, None)
+
+        self.spy_on(JenkinsAPI._make_raw_request, call_original=False)
+        self.spy_on(JenkinsAPI._fetch_csrf_token, call_fake=_fetch_csrf_token)
+
+        review_request.publish(review_request.submitter)
+
+        self.assertFalse(JenkinsAPI._make_raw_request.called)
+
+    def test_job_name_variables_replaced(self):
+        """Testing that JenkinsCIIntegration correctly replaces variables in a
+        job name"""
+        repository = self.create_repository()
+        review_request = self.create_review_request(repository=repository)
+        diffset = self.create_diffset(review_request=review_request)
+        diffset.base_commit_id = '8fd69d70f07b57c21ad8733c1c04ae604d21493f'
+        diffset.save()
+
+        self._create_config(job_name='{repository}_{branch}_1')
+        self.integration.enable_integration()
+
+        data = {}
+
+        def _make_raw_request(api, url, body=None, method='GET', headers={},
+                              content_type=None):
+            # We can't actually do any assertions in here, because they'll get
+            # swallowed by SignalHook's sandboxing. We therefore record the
+            # data we need and assert later.
+            data['url'] = url
+            data['request'] = body
+            return '{}'
+
+        self.spy_on(JenkinsAPI._make_raw_request, call_fake=_make_raw_request)
+        self.spy_on(JenkinsAPI._fetch_csrf_token, call_original=False)
+
+        review_request.publish(review_request.submitter)
+
+        self.assertEqual(
+            data['url'],
+            'http://localhost:8000/job/Test%20Repo_my-branch_1/build')
+
+    def _create_config(self, job_name='job_1', with_local_site=False):
+        """Create an integration config.
+
+        Args:
+            job_name (string, optional):
+                The Jenkins job name, which is used in constructing the
+                URL to start a build on Jenkins.
+        """
+        choice = ReviewRequestRepositoriesChoice()
+
+        condition_set = ConditionSet(conditions=[
+            Condition(choice=choice,
+                      operator=choice.get_operator('any'))
+        ])
+
+        if with_local_site:
+            local_site = self.get_local_site(name=self.local_site_name)
+        else:
+            local_site = None
+
+        config = self.integration.create_config(name='Config 1',
+                                                enabled=True,
+                                                local_site=local_site)
+        config.set('conditions', condition_set.serialize())
+        config.set('jenkins_job_name', job_name)
+        config.set('jenkins_endpoint', 'http://localhost:8000')
+        config.set('jenkins_username', 'admin')
+        config.set('jenkins_password', 'admin')
+
+        config.save()
+
+        return config
diff --git a/rbintegrations/jenkinsci/views.py b/rbintegrations/jenkinsci/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rbintegrations/static/images/jenkinsci/icon.png b/rbintegrations/static/images/jenkinsci/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..bc8be3332386d1f20062427d60f1a4a0d427a314
Binary files /dev/null and b/rbintegrations/static/images/jenkinsci/icon.png differ
diff --git a/rbintegrations/static/images/jenkinsci/icon@2x.png b/rbintegrations/static/images/jenkinsci/icon@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..c370ee4578e922083610178859e5fb05d9b2dd16
Binary files /dev/null and b/rbintegrations/static/images/jenkinsci/icon@2x.png differ
diff --git a/rbintegrations/util/urlrequest.py b/rbintegrations/util/urlrequest.py
index 53db7782c7766ca9c4212cc7ce9f13f495c917d0..02bcaaf8c8dfdd7d74013620397a98773004c857 100644
--- a/rbintegrations/util/urlrequest.py
+++ b/rbintegrations/util/urlrequest.py
@@ -1,43 +1,15 @@
-"""URL Request utilities."""
+"""URL Request utilities.
 
-from __future__ import unicode_literals
-
-from django.utils.six.moves.urllib.request import Request as BaseURLRequest
-
-
-class URLRequest(BaseURLRequest):
-    """A request that can use any HTTP method.
-
-    By default, the :py:class:`urllib2.Request` class only supports HTTP GET
-    and HTTP POST methods. This subclass allows for any HTTP method to be
-    specified for the request.
-    """
+This file previously featured duplicate code from the reviewboard repository,
+reviewboard.hostingsvcs.service.URLRequest. That class is currently an internal
+class, so rather than importing it in all files that use it in this repository,
+for now we'll import it once in this file. Once URLRequest is refactored in the
+reviewboard repo, we can remove this urlrequest.py file and import the original
+class.
+"""
 
-    def __init__(self, url, body='', headers=None, method='GET'):
-        """Initialize the URLRequest.
-
-        Args:
-            url (unicode):
-                The URL to make the request against.
-
-            body (unicode or bytes):
-                The content of the request.
-
-            headers (dict, optional):
-                Additional headers to attach to the request.
-
-            method (unicode, optional):
-                The request method. If not provided, it defaults to a ``GET``
-                request.
-        """
-        BaseURLRequest.__init__(self, url, body, headers or {})
-        self.method = method
+from __future__ import unicode_literals
 
-    def get_method(self):
-        """Return the HTTP method of the request.
+from reviewboard.hostingsvcs.service import URLRequest
 
-        Returns:
-            unicode:
-            The HTTP method of the request.
-        """
-        return self.method
+__all__ = ['URLRequest']
