diff --git a/drupalorg/drupalorg.drush.inc b/drupalorg/drupalorg.drush.inc
index 672e74fce2375ecb69addec9d354df1b9320abfc..ebc9a6208763ff4fb4c2680f1e336d0908586765 100644
--- a/drupalorg/drupalorg.drush.inc
+++ b/drupalorg/drupalorg.drush.inc
@@ -80,14 +80,15 @@ function drupalorg_drush_command() {
     'drupalorg-tugboat-register-project' => [
       'description' => 'Registers a project at Tugboat',
       'arguments' => [
-        'project_name' => 'The project name, like drupal for Drupal core.',
+        'project_namespace' => 'The project namespace, like project.',
+        'project_name' => 'The project name, like drupal.',
       ],
     ],
     'drupalorg-tugboat-build-base-preview' => [
       'description' => 'Builds a base preview for a project and branch',
       'arguments' => [
         'project_id' => 'The project identifier at https://dashboard.tugboat.qa',
-        'project_name' => 'The project name, like drupal for Drupal core',
+        'project_name' => 'The project name, like drupal for Drupal core. This is used to pick a configuration from https://github.com/TugboatQA/drupalorg.',
         'branch' => 'The branch to build as a base preview',
       ],
     ],
@@ -1025,47 +1026,12 @@ function drush_drupalorg_repath_docs($nid) {
 /**
  * Registers a project at Tugboat.
  */
-function drush_drupalorg_tugboat_register_project($project_name) {
-  $gitlab_token = variable_get('tugboat_gitlab_api_token');
-
-  $body = [
-    'project' => variable_get('tugboat_project_id'),
-    'provider' => [
-      'name' => 'gitlab',
-      'address' => drupalorg_gitlab_url_with_htauth(),
-    ],
-    'repository' => [
-      'name' => $project_name,
-      'group' => 'project'
-    ],
-    'auth' => [
-      'token' => $gitlab_token,
-    ],
-    'autobuild' => FALSE,
-    'autodelete' => TRUE,
-    'autorebuild' => TRUE,
-    'build_timeout' => 3600,
-    'name' => 'project/' . $project_name,
-    'provider_comment' => TRUE,
-    'provider_deployment' => TRUE,
-    'provider_forks' => TRUE,
-    'provider_status' => TRUE,
-    'quota' => 0,
-    'refresh_anchors' => TRUE,
-    'refresh_day' => 7,
-    'refresh_hour' => 0,
-    'rebuild_orphaned' => FALSE,
-    'rebuild_stale' => FALSE,
-  ];
-
+function drush_drupalorg_tugboat_register_project($project_namespace, $project_name) {
   try {
-    $response = Tugboat::getClient()->request('POST', '/v3/repos', [
-      'headers' => ['Content-Type' => 'application/json'],
-      'body' => json_encode($body),
-    ]);
+    $response = TugboatApi::registerRepository($project_namespace, $project_name);
     drush_log(dt('Response is: !response', [
       '!response' => $response->getBody()->__toString(),
-      ]), LogLevel::OK);
+    ]), LogLevel::OK);
   }
   catch (\GuzzleHttp\Exception\BadResponseException $e) {
     drush_log(dt('Bad response: @message', [
@@ -1083,7 +1049,7 @@ function drush_drupalorg_tugboat_register_project($project_name) {
  * Builds a base preview for a Tugboat project.
  */
 function drush_drupalorg_tugboat_build_base_preview($project_id, $project_name, $branch) {
-  $tugboat_configuration = DrupalorgIssueFork::getTugboatConfiguration($project_name, $branch);
+  $tugboat_configuration = TugboatPreview::fetchConfiguration($project_name, $branch);
 
   if (empty($tugboat_configuration)) {
     drush_log(dt('Could not fetch Tugboat configuration for such project and branch.'), LogLevel::ERROR);
@@ -1101,7 +1067,7 @@ function drush_drupalorg_tugboat_build_base_preview($project_id, $project_name,
   ];
 
   try {
-    $response = Tugboat::getClient()->request('POST', '/v3/previews', [
+    $response = TugboatApi::getClient()->request('POST', '/v3/previews', [
       'headers' => ['Content-Type' => 'application/json'],
       'body' => json_encode($body),
     ]);
diff --git a/drupalorg/drupalorg.info b/drupalorg/drupalorg.info
index 36c50c21615504a0483d64af3181cbf64554fbf9..89f76efe918f153236fbd74fcc6357e208f271f8 100644
--- a/drupalorg/drupalorg.info
+++ b/drupalorg/drupalorg.info
@@ -15,7 +15,10 @@ scripts[] = js/general.js
 core = 7.x
 
 files[] = includes/DrupalorgIssueFork.php
-files[] = includes/Tugboat.php
+files[] = includes/ProjectDoesNotSupportTugboatException.php
+files[] = includes/UnsupportedMergeRequestEventException.php
+files[] = includes/TugboatApi.php
+files[] = includes/TugboatPreview.php
 files[] = includes/DrupalorgPackagingJob.php
 files[] = views/drupalorg_handler_not_spam.inc
 files[] = views/drupalorg_handler_security_coverage.inc
diff --git a/drupalorg/drupalorg.pages.inc b/drupalorg/drupalorg.pages.inc
index 06619ee25b6957ea8dc80836098308a57e0d646a..1cb4cded0550700a897d0a9dab12fd1030d002de 100644
--- a/drupalorg/drupalorg.pages.inc
+++ b/drupalorg/drupalorg.pages.inc
@@ -2224,7 +2224,7 @@ function drupalorg_issue_fork_tugboat_rebuild_preview_form($form, &$form_state,
 function drupalorg_issue_fork_tugboat_rebuild_preview_form_submit($form, &$form_state) {
   $preview_id = $form_state['values']['tugboat_preview_id'];
   try {
-    Tugboat::rebuildPreview($preview_id);
+    TugboatApi::rebuildPreview($preview_id);
     drupal_set_message('The Tugboat preview has been scheduled for rebuild.');
   }
   catch (Exception $e) {
@@ -2247,7 +2247,7 @@ function drupalorg_issue_fork_tugboat_rebuild_preview_form_submit($form, &$form_
 function drupalorg_issue_fork_tugboat_preview_log($preview_id) {
   try {
     $build_log = '';
-    foreach (Tugboat::getPreviewLog($preview_id) as $build_log_line) {
+    foreach (TugboatApi::getPreviewLog($preview_id) as $build_log_line) {
       $build_log .= $build_log_line->message;
     }
     return [
@@ -2258,7 +2258,7 @@ function drupalorg_issue_fork_tugboat_preview_log($preview_id) {
     ];
   }
   catch (Exception $e) {
-    watchdog('drupalorg_tugboat_rebuild', 'Failed to obtain the Tugboat build log for preview id !preview_id. Error was !error', [
+    watchdog('drupalorg_tugboat_log', 'Failed to obtain the Tugboat build log for preview id !preview_id. Error was !error', [
       '!preview_id' => $preview_id,
       '!error' => $e->getMessage(),
     ], WATCHDOG_ERROR);
diff --git a/drupalorg/includes/DrupalorgIssueFork.php b/drupalorg/includes/DrupalorgIssueFork.php
index ee585af25614d61dfbaaca3c4103f2e49361ccc3..03cd3163e412651aa2570ea58d375baf53aea9f6 100644
--- a/drupalorg/includes/DrupalorgIssueFork.php
+++ b/drupalorg/includes/DrupalorgIssueFork.php
@@ -1,8 +1,5 @@
 <?php
 
-use Symfony\Component\Yaml\Exception\ParseException;
-use Symfony\Component\Yaml\Yaml;
-
 class DrupalorgIssueFork extends Entity {
 
   /**
@@ -799,58 +796,37 @@ class DrupalorgIssueFork extends Entity {
   /**
    * Builds a Tugboat preview.
    *
-   * @param object $body
+   * @param object $payload
    *   The payload from the merge request event.
    */
-  public function buildTugboatPreview($body) {
-    $repository_path = $body->project->path_with_namespace;
-    $merge_request_id = $body->object_attributes->iid;
-    $branch = $body->object_attributes->source_branch;
-
-    // Only build on new or reopened merge requests.
-    if (!in_array($body->object_attributes->action, ['open', 'reopen'])) {
+  public function buildTugboatPreview($payload) {
+    try {
+      $tugboat_preview = TugboatPreview::fromGitLabPayload($payload);
+    }
+    catch (UnsupportedMergeRequestEventException $e) {
       return;
     }
-
-    $repository_id = $this->getTugboatRepositoryId($repository_path);
-    if (empty($repository_id)) {
-      watchdog('drupalorg_merge_request', 'Failed to find the repository !repository at Tugboat', [
-        '!repository' => $repository_path,
-      ], WATCHDOG_ERROR);
+    catch (ProjectDoesNotSupportTugboatException $e) {
       return;
     }
-
-    $configuration = $this->getTugboatConfiguration($body->project->name, $body->object_attributes->target_branch);
-    if (empty($configuration)) {
+    catch (Exception $e) {
+      watchdog('drupalorg_merge_request', 'Could not construct a TugboatPreview object. Error is !error', [
+        '!error' => $e->getMessage(),
+      ], WATCHDOG_ERROR);
       return;
     }
 
-    $body = [
-      'ref' => $merge_request_id,
-      'repo' => $repository_id,
-      'config' => $configuration,
-      'name' => $branch,
-      'expires' => date('c', strtotime('+5 days')),
-      'type' => 'pullrequest',
-    ];
-
     try {
-      $response = Tugboat::getClient()->post('/v3/previews', [
-        'json' => $body,
-      ]);
-
-      watchdog('drupalorg_merge_request', 'Triggered a preview at Tugboat for repository !repository, merge request !merge_request, and branch !branch. Response was !response', [
-        '!repository' => $repository_id,
-        '!merge_request' => $merge_request_id,
-        '!branch' => $branch,
+      /** @var \Psr\Http\Message\ResponseInterface */
+      $response = $tugboat_preview->build();
+      watchdog('drupalorg_merge_request', 'Triggered a preview at Tugboat with payload !payload. Response was !response', [
+        '!payload' => json_encode($tugboat_preview->toPayload()),
         '!response' => $response->getBody()->__toString(),
       ], WATCHDOG_INFO);
     }
     catch (Exception $e) {
-      watchdog('drupalorg_merge_request', 'Failed to build a Tugboat preview for repository !repository, merge request !merge_request, and branch !branch. Error was !error', [
-        '!repository' => $this->label(),
-        '!merge_request' => $merge_request_id,
-        '!branch' => $branch,
+      watchdog('drupalorg_merge_request', 'Tugboat failed to build a preview with payload !payload. Error was !error', [
+        '!payload' => json_encode($tugboat_preview->toPayload()),
         '!error' => $e->getMessage(),
       ], WATCHDOG_ERROR);
     }
@@ -873,25 +849,18 @@ class DrupalorgIssueFork extends Entity {
   public function getTugboatPreviews() {
     $previews = [];
 
-    $project_repository = versioncontrol_project_repository_load($this->project_nid);
-    $parent_repository = $project_repository->namespace . '/' . $project_repository->name;
+    $repository_data = versioncontrol_project_repository_load($this->project_nid);
+    $repository_name = $repository_data->namespace . '/' . $repository_data->name;
 
     try {
-      foreach ($this->getTugboatRepositories() as $repository) {
-        if ($repository->name == $parent_repository) {
-          $response = Tugboat::getClient()->get('/v3/repos/' . $repository->id . '/previews');
-          foreach (json_decode($response->getBody()) as $preview) {
-            // Filter successful previews for the current issue fork.
-            if (!empty($preview->provider_ref->source) && ($preview->provider_ref->source->path == $this->label())) {
-              $previews[] = [
-                'id' => $preview->id,
-                'branch' => $preview->provider_ref->source_branch,
-                'state' => $preview->state,
-                'url' => !empty($preview->url) ? $preview->url : '',
-              ];
-            }
-          }
-          break;
+      foreach (TugboatApi::getPreviewsFor($repository_name) as $preview) {
+        if (!empty($preview->provider_ref->source) && ($preview->provider_ref->source->path == $this->label())) {
+          $previews[] = [
+            'id' => $preview->id,
+            'branch' => $preview->provider_ref->source_branch,
+            'state' => $preview->state,
+            'url' => !empty($preview->url) ? $preview->url : '',
+          ];
         }
       }
     }
@@ -905,105 +874,6 @@ class DrupalorgIssueFork extends Entity {
     return $previews;
   }
 
-  /**
-   * Returns Tugboat repositories.
-   *
-   * @return array
-   *   An associative array whose keys are the repository forks withing the
-   *   Tugboat project.
-   */
-  private static function getTugboatRepositories() {
-    try {
-      $repositories = [];
-      $gitlab_url = drupalorg_gitlab_url_with_htauth();
-      foreach (json_decode(Tugboat::getClient()->get('/v3/projects/' . variable_get('tugboat_project_id') . '/repos')->getBody()) as $repository) {
-        if ($repository->provider_config->server === $gitlab_url) {
-          $repositories[] = $repository;
-        }
-      }
-      return $repositories;
-    }
-    catch (Exception $e) {
-      watchdog('drupalorg_issue_fork', 'Failed to fetch repositories from Tugboat. Error was !error', [
-        '!error' => $e->getMessage(),
-      ], WATCHDOG_ERROR);
-      return [];
-    }
-  }
-
-  /**
-   * Returns the repository id at Tugboat for an issue fork.
-   *
-   * @param string $repository_name
-   *   The project repository, such as project/drupal.
-   *
-   * @return string
-   *   The repository id or an empty string.
-   */
-  private function getTugboatRepositoryId($repository_name) {
-    foreach ($this->getTugboatRepositories() as $repository) {
-      if (($repository->provider_config->namespace . '/' . $repository->provider_config->project) === $repository_name) {
-        return $repository->id;
-      }
-    }
-
-    return '';
-  }
-
-  /**
-   * Returns Tugboat configuration for a project and branch.
-   *
-   * Fetches configuration files from https://github.com/TugboatQA/drupalorg.
-   *
-   * @param string $project_name
-   *   The project name, like drupal for Drupal core.
-   * @param string $branch
-   *   The branch name.
-   *
-   * @return array
-   *   The Tugboat configuration, or an empty array if there was an issue.
-   */
-  public static function getTugboatConfiguration($project_name, $branch) {
-    $tugboat_configuration = [];
-
-    $tugboat_config_repository = variable_get('tugboat_config_repository', 'TugboatQA/drupalorg');
-    $tugboat_config_branch = variable_get('tugboat_config_branch', 'HEAD');
-    $tugboat_configs_path = 'https://raw.githubusercontent.com/' . $tugboat_config_repository . '/' . $tugboat_config_branch . '/' . $project_name;
-    $tugboat_config_file_url = $tugboat_configs_path . '/' . $branch . '/config.yml';
-    $response_contents = @file_get_contents($tugboat_config_file_url);
-    if (empty($response_contents)) {
-      $tugboat_config_file_url = $tugboat_configs_path . '/default/config.yml';
-      $response_contents = @file_get_contents($tugboat_config_file_url);
-      if (empty($response_contents)) {
-        watchdog('drupalorg_issue_fork', 'Failed to fetch default Tugboat configuration at !url', [
-          '!tugboat_configs_url' => $tugboat_config_file_url,
-        ], WATCHDOG_ERROR);
-      }
-      else {
-        watchdog('drupalorg_issue_fork', 'Using default Tugboat configuration for project !project and branch !branch: !url', [
-          '!project' => $project_name,
-          '!branch' => $branch,
-          '!url' => $tugboat_config_file_url,
-        ], WATCHDOG_NOTICE);
-      }
-    }
-
-    if (!empty($response_contents)) {
-      try {
-        $tugboat_configuration = Yaml::parse($response_contents);
-      }
-      catch (ParseException $e) {
-        watchdog('drupalorg_issue_fork', 'Failed to parse Tugboat configuration at !tugboat_configs_url. Error is !error and response contents are !response_contents.', [
-          '!tugboat_configs_url' => $tugboat_config_file_url,
-          '!error' => $e->getMessage(),
-          '!response_contents' => $response_contents,
-        ], WATCHDOG_ERROR);
-      }
-    }
-
-    return $tugboat_configuration;
-  }
-
 }
 
 /**
@@ -1131,4 +1001,5 @@ function drupalorg_issue_fork_open_merge_request_form_ajax(array $form, array $f
     // Let reattaching the form re-trigger behaviors, any errors will be shown.
     return $form;
   }
+
 }
diff --git a/drupalorg/includes/ProjectDoesNotSupportTugboatException.php b/drupalorg/includes/ProjectDoesNotSupportTugboatException.php
new file mode 100644
index 0000000000000000000000000000000000000000..f0d2e0825106139d406c221e43c1ed2d5b3ee2a3
--- /dev/null
+++ b/drupalorg/includes/ProjectDoesNotSupportTugboatException.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * Class ProjectDoesNotSupportTugboatException
+ *
+ * Represents the exception when a project does not support Tugboat.
+ */
+class ProjectDoesNotSupportTugboatException extends Exception {}
diff --git a/drupalorg/includes/Tugboat.php b/drupalorg/includes/Tugboat.php
deleted file mode 100644
index 2c098a886e8dc293bd64a03e71309966a25003b4..0000000000000000000000000000000000000000
--- a/drupalorg/includes/Tugboat.php
+++ /dev/null
@@ -1,79 +0,0 @@
-<?php
-
-use GuzzleHttp\Client;
-
-/**
- * Class Tugboat
- *
- * Provides an interface for the Tugboat API.
- */
-class Tugboat {
-
-  /**
-   * Get a Tugboat API client.
-   *
-   * @return \GuzzleHttp\Client
-   *   A Guzzle client object.
-   */
-  public static function getClient() {
-    static $client;
-
-    if (is_null($client)) {
-      $client = new Client([
-        'base_uri' => 'https://api.tugboat.qa',
-        'timeout' => variable_get('drupalorg_tugboat_timeout', 20.0),
-        'headers' => [
-          'Authorization' => 'Bearer ' . variable_get('tugboat_api_token'),
-          'Accept' => 'application/json',
-        ],
-      ]);
-    }
-
-    return $client;
-  }
-
-  /**
-   * Rebuilds a Tugboat preview.
-   *
-   * @param string $preview_id
-   *   The preview identifier.
-   */
-  public static function rebuildPreview($preview_id) {
-    $response = self::getClient()->post('/v3/previews/' . $preview_id . '/rebuild');
-    $response_body = $response->getBody()->__toString();
-
-    if ($response->getStatusCode() !== 202) {
-      throw new Exception($response_body);
-    }
-  }
-
-  /**
-   * Returns the build log for a Tugboat preview.
-   *
-   * @param string $preview_id
-   *   The preview identifier.
-   *
-   * @return array
-   *   An array of objects with the following structure:
-   * <code>
-   * stdClass::__set_state(array(
-   *   'timestamp' => '2020-10-12T20:31:46.235Z',
-   *   'level' => 'info',
-   *    'message' => 'Building preview: foo (mr72)',
-   *    'job' => 'bar',
-   *    'preview' => 'foo',
-   *    'id' => 'baz',
-   * ))
-   * </code>
-   */
-  public static function getPreviewLog($preview_id) {
-    $response = self::getClient()->get('/v3/previews/' . $preview_id . '/log');
-    $response_body = $response->getBody();
-    if ($response->getStatusCode() !== 200) {
-      throw new Exception($response_body);
-    }
-
-    return json_decode($response_body);
-  }
-
-}
diff --git a/drupalorg/includes/TugboatApi.php b/drupalorg/includes/TugboatApi.php
new file mode 100644
index 0000000000000000000000000000000000000000..d9b074f4d94a9e5b58b856c6b73ee95e9f560c38
--- /dev/null
+++ b/drupalorg/includes/TugboatApi.php
@@ -0,0 +1,216 @@
+<?php
+
+use GuzzleHttp\Client;
+
+/**
+ * Class Tugboat
+ *
+ * Provides an interface for the Tugboat API.
+ */
+class TugboatApi {
+
+  /**
+   * Get a Tugboat API client.
+   *
+   * @return \GuzzleHttp\Client
+   *   A Guzzle client object.
+   */
+  public static function getClient() {
+    static $client;
+
+    if (is_null($client)) {
+      $client = new Client([
+        'base_uri' => variable_get('drupalorg_tugboat_api', 'https://api.tugboat.qa'),
+        'timeout' => variable_get('drupalorg_tugboat_timeout', 20.0),
+        'headers' => [
+          'Authorization' => 'Bearer ' . variable_get('tugboat_api_token'),
+          'Accept' => 'application/json',
+        ],
+      ]);
+    }
+
+    return $client;
+  }
+
+  /**
+   * Rebuilds a Tugboat preview.
+   *
+   * @param string $preview_id
+   *   The preview identifier.
+   *
+   * @throws \Exception
+   *   If there was an unsuccessful response from the Tugboat API.
+   */
+  public static function rebuildPreview($preview_id) {
+    $response = self::getClient()->post('/v3/previews/' . $preview_id . '/rebuild');
+    $response_body = $response->getBody()->__toString();
+
+    if ($response->getStatusCode() !== 202) {
+      throw new Exception($response_body);
+    }
+  }
+
+  /**
+   * Returns the build log for a Tugboat preview.
+   *
+   * @param string $preview_id
+   *   The preview identifier.
+   *
+   * @return array
+   *   An array of objects with the following structure:
+   * <code>
+   * stdClass::__set_state(array(
+   *   'timestamp' => '2020-10-12T20:31:46.235Z',
+   *   'level' => 'info',
+   *   'message' => 'Building preview: foo (mr72)',
+   *   'job' => 'bar',
+   *   'preview' => 'foo',
+   *   'id' => 'baz',
+   * ))
+   * </code>
+   *
+   * @throws \Exception
+   *   If there was an unsuccessful response from the Tugboat API.
+   */
+  public static function getPreviewLog($preview_id) {
+    $response = self::getClient()->get('/v3/previews/' . $preview_id . '/log');
+    $response_body = $response->getBody();
+    if ($response->getStatusCode() !== 200) {
+      throw new Exception($response_body);
+    }
+
+    return json_decode($response_body);
+  }
+
+  /**
+   * Returns an array of Tugboat repositories.
+   *
+   * @return array
+   *   An associative array whose keys are the repository forks withing the
+   *   Tugboat project.
+   *
+   * @throws \Exception
+   *   If there is an error fetching repository data.
+   */
+  private static function getRepositories() {
+    try {
+      $repositories = [];
+      $gitlab_url = drupalorg_gitlab_url_with_htauth();
+      foreach (json_decode(self::getClient()->get('/v3/projects/' . variable_get('tugboat_project_id') . '/repos')->getBody()) as $repository) {
+        if (!empty($repository->provider_config->server) && ($repository->provider_config->server === $gitlab_url)) {
+          $repositories[] = $repository;
+        }
+      }
+      return $repositories;
+    }
+    catch (Exception $e) {
+      throw new Exception('Failed to fetch repositories from Tugboat. Error was ' . $e->getMessage());
+    }
+  }
+
+  /**
+   * Returns a tugboat repository data by its name.
+   *
+   * @param string $repository_name
+   *   The project repository, such as project/drupal.
+   *
+   * @return object
+   *   The repository data or FALSE if not found.
+   */
+  public static function getRepositoryBy($repository_name) {
+    foreach (self::getRepositories() as $repository) {
+      if (($repository->provider_config->namespace . '/' . $repository->provider_config->project) === $repository_name) {
+        return $repository;
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * Registers a repository.
+   *
+   * @param string $namespace
+   *   The repository namespace, like project.
+   * @param string $name
+   *   The repository name, like drupal.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   *   The response from Tugboat.
+   *
+   * @throws \Exception
+   *   If there was an error.
+   */
+  public static function registerRepository($namespace, $name) {
+    $gitlab_token = variable_get('tugboat_gitlab_api_token');
+
+    $body = [
+      'project' => variable_get('tugboat_project_id'),
+      'provider' => [
+        'name' => 'gitlab',
+        'address' => drupalorg_gitlab_url_with_htauth(),
+      ],
+      'repository' => [
+        'group' => $namespace,
+        'name' => $name,
+      ],
+      'auth' => [
+        'token' => $gitlab_token,
+      ],
+      'autobuild' => FALSE,
+      'autodelete' => TRUE,
+      'autorebuild' => TRUE,
+      'build_timeout' => 3600,
+      'name' => $namespace . '/' . $name,
+      'provider_comment' => TRUE,
+      'provider_deployment' => TRUE,
+      'provider_forks' => TRUE,
+      'provider_status' => TRUE,
+      'quota' => 0,
+      'refresh_anchors' => TRUE,
+      'refresh_day' => 7,
+      'refresh_hour' => 0,
+      'rebuild_orphaned' => FALSE,
+      'rebuild_stale' => FALSE,
+    ];
+
+    return self::getClient()->request('POST', '/v3/repos', [
+      'headers' => ['Content-Type' => 'application/json'],
+      'body' => json_encode($body),
+    ]);
+  }
+
+  /**
+   * Returns Tugboat previews for a given repository.
+   *
+   * @param string $repository_name
+   *   The repository name, such as project/drupal
+   *
+   * @return array
+   *   The array of repositories.
+   *
+   * @throws \Exception
+   *   If there was an error with the Tugboat API.
+   */
+  public static function getPreviewsFor($repository_name) {
+    $previews = [];
+
+    try {
+      foreach (self::getRepositories() as $repository) {
+        if ($repository->name == $repository_name) {
+          $response = self::getClient()->get('/v3/repos/' . $repository->id . '/previews');
+          foreach (json_decode($response->getBody()) as $preview) {
+            $previews[] = $preview;
+          }
+          break;
+        }
+      }
+    }
+    catch (Exception $e) {
+      throw new Exception('Failed to fetch Tugboat previews for repository ' . $repository_name . '. Error was ' . $e->getMessage());
+    }
+
+    return $previews;
+  }
+
+}
diff --git a/drupalorg/includes/TugboatPreview.php b/drupalorg/includes/TugboatPreview.php
new file mode 100644
index 0000000000000000000000000000000000000000..e17e89350fb52fd11368f0cb7a488a2f2b193ca3
--- /dev/null
+++ b/drupalorg/includes/TugboatPreview.php
@@ -0,0 +1,303 @@
+<?php
+
+use Gitlab\Exception\RuntimeException;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\ClientException;
+use Symfony\Component\Yaml\Yaml;
+use Symfony\Component\Yaml\Exception\ParseException;
+
+
+/**
+ * Class TugboatPreview
+ *
+ * Provides an interface to construct and build Tugboat previews.
+ */
+class TugboatPreview {
+
+  /**
+   * The source branch name.
+   *
+   * @var string
+   */
+  private $source_branch;
+
+  /**
+   * The target branch name.
+   *
+   * @var string
+   */
+  private $target_branch;
+
+  /**
+   * The preview configuration.
+   *
+   * @var array
+   */
+  private $configuration;
+
+  /**
+   * The merge request identifier.
+   *
+   * @var string
+   */
+  private $merge_request_id;
+
+  /**
+   * The Tugboat repository id.
+   *
+   * @var string
+   */
+  private $repository_id;
+
+  /**
+   * The repository namespace.
+   *
+   * @var string
+   */
+  private $repository_namespace;
+
+  /**
+   * The repository name.
+   *
+   * @var string
+   */
+  private $repository_name;
+
+  /**
+   * TugboatPreview constructor.
+   *
+   * @param array $configuration
+   *   The Tugboat preview configuration.
+   * @param string $merge_request_id
+   *   The merge request id.
+   * @param string $repository_namespace
+   *   The repository namespace.
+   * @param string $repository_name
+   *   The repository name.
+   * @param string $repository_id
+   *   The repository identifier.
+   * @param string $source_branch
+   *   The merge request's source branch.
+   * @param $target_branch
+   *   The merge request's target branch.
+   */
+  public function __construct($configuration, $merge_request_id, $repository_namespace, $repository_name, $repository_id, $source_branch, $target_branch) {
+    $this->configuration = $configuration;
+    $this->merge_request_id = $merge_request_id;
+    $this->repository_namespace = $repository_namespace;
+    $this->repository_name = $repository_name;
+    $this->repository_id = $repository_id;
+    $this->source_branch = $source_branch;
+    $this->target_branch = $target_branch;
+  }
+
+  /**
+   * Builds a Tugboat preview out of a GitLab payload.
+   *
+   * Tugboat config files for core are at https://github.com/TugboatQA/drupalorg/tree/master/drupal.
+   *
+   * Contrib modules can provide a .tugboat/config.yml file.
+   *
+   * @param object $payload
+   *   A GitLab payload.
+   *
+   * @return \TugboatPreview
+   *   A TugboatPreview object.
+   *
+   * @throws \UnsupportedMergeRequestEventException
+   *   If the merge request event is unsupported.
+   * @throws \ProjectDoesNotSupportTugboatException
+   *   If the project does not support Tugboat.
+   * @throws \Exception
+   *   If there is an error.
+   */
+  public static function fromGitLabPayload($payload) {
+    // Only build on new or reopened merge requests.
+    if (!in_array($payload->object_attributes->action, ['open', 'reopen'])) {
+      throw new UnsupportedMergeRequestEventException();
+    }
+
+    $repository_namespace = $payload->project->namespace;
+    $repository_name = $payload->project->name;
+    $merge_request_id = $payload->object_attributes->iid;
+    $source_branch = $payload->object_attributes->source_branch;
+    $target_branch = $payload->object_attributes->target_branch;
+
+    if ($repository_name == 'drupal') {
+      $configuration = self::fetchConfiguration($repository_name, $target_branch);
+    }
+    elseif (self::projectSupportsTugboat($payload)) {
+      $configuration = [];
+    }
+
+    if (!isset($configuration)) {
+      throw new ProjectDoesNotSupportTugboatException();
+    }
+
+    // Check and register repository.
+    $repository = TugboatApi::getRepositoryBy($repository_namespace . '/' . $repository_name);
+    if (empty($repository)) {
+      try {
+        self::addTugboatTo($payload->object_attributes->target_project_id);
+        $repository = json_decode(TugboatApi::registerRepository($repository_namespace, $repository_name)->getBody()->__toString());
+      }
+      catch (Exception $e) {
+        throw $e;
+      }
+      finally {
+        self::removeTugboatFrom($payload->object_attributes->target_project_id);
+      }
+    }
+
+    return new self($configuration, $merge_request_id, $repository_namespace, $repository_name, $repository->id, $source_branch, $target_branch);
+  }
+
+  /**
+   * Adds the Tugboat GitLab user to a repository.
+   *
+   * @param int $project_id
+   *   The GitLab project identifier.
+   *
+   * @throws \Exception
+   *   If there was an error adding the member.
+   */
+  private static function addTugboatTo($project_id) {
+    $maintainer_access_level = 40;
+    $tugboat_gitlab_user_id = variable_get('tugboat_gitlab_user_id');
+    try {
+      $user = versioncontrol_gitlab_get_client()->api('projects')->addMember($project_id, $tugboat_gitlab_user_id, $maintainer_access_level);
+    }
+    catch (RuntimeException $e) {
+    }
+
+    if (empty($user)) {
+      throw new Exception('Could not make Tugboat a member of the repository with id ' . $project_id);
+    }
+  }
+
+  /**
+   * Removes the Tugboat GitLab user from a repository.
+   *
+   * @param int $project_id
+   *   The GitLab project identifier.
+   */
+  private static function removeTugboatFrom($project_id) {
+    $tugboat_gitlab_user_id = variable_get('tugboat_gitlab_user_id');
+    versioncontrol_gitlab_get_client()->api('projects')->removeMember($project_id, $tugboat_gitlab_user_id);
+  }
+
+
+  /**
+   * Sends a request to the Tugboat API to build a preview.
+   *
+   * @return string
+   *   The Response from the Tugboat API.
+   *
+   * @throws \Exception
+   *   If there is an error.
+   */
+  public function build() {
+    return TugboatApi::getClient()->post('/v3/previews', [
+      'json' => $this->toPayload(),
+    ]);
+  }
+
+  /**
+   * Transforms the preview into a body payload.
+   *
+   * @return array
+   *   The payload to build a preview.
+   *
+   * @throws \Exception
+   *   If there was an issue with the Tugboat API.
+   */
+  public function toPayload() {
+    $payload = [
+      'ref' => $this->merge_request_id,
+      'repo' => $this->repository_id,
+      'name' => $this->source_branch,
+      'expires' => date('c', strtotime('+5 days')),
+      'type' => 'pullrequest',
+    ];
+
+    // Contrib projects have a Tugboat config file so we don't need to send it in the request.
+    if (!empty($this->configuration)) {
+      $payload['config'] = $this->configuration;
+    }
+
+    return $payload;
+  }
+
+  /**
+   * Returns Tugboat configuration for a project and branch.
+   *
+   * Fetches configuration files from https://github.com/TugboatQA/drupalorg.
+   *
+   * @param string $project_name
+   *   The project name, like drupal for Drupal core.
+   * @param string $branch
+   *   The branch name.
+   *
+   * @return array
+   *   The Tugboat configuration.
+   *
+   * @throws \Exception
+   *   When no configuration could be found or parsing failed.
+   */
+  public static function fetchConfiguration($project_name, $branch) {
+    $tugboat_configuration = [];
+    $client = new Client();
+
+    $tugboat_config_repository = variable_get('tugboat_config_repository', 'TugboatQA/drupalorg');
+    $tugboat_config_branch = variable_get('tugboat_config_branch', 'HEAD');
+    $tugboat_configs_path = 'https://raw.githubusercontent.com/' . $tugboat_config_repository . '/' . $tugboat_config_branch . '/' . $project_name;
+    $tugboat_config_file_url = $tugboat_configs_path . '/' . $branch . '/config.yml';
+    try {
+      $response_contents = $client->get($tugboat_config_file_url)->getBody()->__toString();
+    }
+    catch (ClientException $e) {}
+    if (empty($response_contents)) {
+      $tugboat_config_file_url = $tugboat_configs_path . '/default/config.yml';
+      try {
+        $response_contents = $client->get($tugboat_config_file_url)->getBody()->__toString();
+      }
+      catch (ClientException $e) {}
+      if (empty($response_contents)) {
+        throw new Exception('Failed to fetch default Tugboat configuration at ' . $tugboat_config_file_url);
+      }
+    }
+
+    if (!empty($response_contents)) {
+      try {
+        $tugboat_configuration = Yaml::parse($response_contents);
+      }
+      catch (ParseException $e) {
+        throw new Exception('Failed to parse the Tugboat configuration. Error is ' . $e->getMessage() . ' and configuration is ' . print_r($response_contents, TRUE));
+      }
+    }
+
+    return $tugboat_configuration;
+  }
+
+  /**
+   * Checks if a contrib project supports Tugboat.
+   *
+   * @param object $payload
+   *   The request payload.
+   *
+   * @return bool
+   *   TRUE if the project supports Tugboat. FALSE otherwise.
+   */
+  private static function projectSupportsTugboat($payload) {
+    $project_tugboat_config_url = drupalorg_gitlab_url_with_htauth() . '/' . $payload->object_attributes->source->path_with_namespace . '/-/raw/' . $payload->object_attributes->source_branch . '/.tugboat/config.yml';
+    $client = new Client();
+    try {
+      $configuration = $client->get($project_tugboat_config_url)->getBody()->__toString();
+      return !empty($configuration);
+    }
+    catch (ClientException $e) {
+      return FALSE;
+    }
+  }
+
+}
diff --git a/drupalorg/includes/UnsupportedMergeRequestEventException.php b/drupalorg/includes/UnsupportedMergeRequestEventException.php
new file mode 100644
index 0000000000000000000000000000000000000000..104820949dec750149fc21a21894375d7a1d5bda
--- /dev/null
+++ b/drupalorg/includes/UnsupportedMergeRequestEventException.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * Class ProjectDoesNotSupportTugboatException
+ *
+ * Represents the exception when a merge request event is unsupported to build a Tugboat preview.
+ */
+class UnsupportedMergeRequestEventException extends Exception {}
diff --git a/drupalorg/tests/tugboat/README.md b/drupalorg/tests/tugboat/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f62e0a910da51cff3a749512cf099aae4ac07603
--- /dev/null
+++ b/drupalorg/tests/tugboat/README.md
@@ -0,0 +1,32 @@
+# Tugboat test suite
+
+This is a rudimentary test harness for the Tugboat integration.
+
+It's meant to be executed locally in order to catch bugs quickly.
+
+__CAUTION__ This suite overrides a few environment variables in order to
+run locally.
+
+## Requirements
+Install Drupal locally:
+```
+drush si -y
+drush en -y drupalorg
+```
+
+## Running the suite
+Start the proxy in a separate terminal. This proxy mocks Tugboat API responses.
+
+```
+php -S localhost:8001 tugboatProxy.php
+```
+
+Run the test suite:
+
+```
+cd htdocs
+drush php-script sites/all/modules/drupalorg/drupalorg/tests/tugboat/tugboat.php
+```
+
+The above command will throw a lot of warnings due to lack of configuration. Ignore it,
+just look for PHP Errors and ensure that all checks start with an [OK] instead of a [KO].
diff --git a/drupalorg/tests/tugboat/tugboat.php b/drupalorg/tests/tugboat/tugboat.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1509cc951d9982a33fa94c23cd66f7a6e7aff01
--- /dev/null
+++ b/drupalorg/tests/tugboat/tugboat.php
@@ -0,0 +1,178 @@
+<?php
+
+/**
+ * @file
+ *
+ * Tugboat test suite.
+ */
+
+// Set variables.
+variable_set('drupalorg_tugboat_api', 'http://localhost:8001');
+variable_set('tugboat_project_id', '1234');
+variable_set('versioncontrol_gitlab_url', 'http://localhost:8001');
+variable_set('tugboat_gitlab_user_id', '72');
+
+// TugboatApi tests.
+// =================
+// TugboatApi::getClient();
+/** @var \TugboatApi $client */
+$client = TugboatApi::getClient();
+if (!empty($client)) {
+  drush_print('[OK] TugboatApi::getClient() returns a valid client.');
+}
+
+// TugboatApi::rebuildPreview('foo');
+TugboatApi::rebuildPreview('foo');
+drush_print('[OK] TugboatApi::rebuildPreview() did not throw an Exception.');
+
+// TugboatApi::getPreviewLog('foo');
+$preview_log = TugboatApi::getPreviewLog('foo');
+if (!empty($preview_log)) {
+  drush_print('[OK] TugboatApi::getPreviewLog() returned a preview log.');
+}
+else {
+  drush_print('[KO] TugboatApi::getPreviewLog() failed.');
+}
+
+// TugboatApi::getRepositoryBy('project/drupal');
+$repository = TugboatApi::getRepositoryBy('project/drupal');
+if (!empty($repository)) {
+  drush_print('[OK] TugboatApi::getRepositoryBy() returned a repository.');
+}
+else {
+  drush_print('[KO] TugboatApi::getRepositoryBy() failed.');
+}
+
+$response = TugboatApi::registerRepository('project', 'webform');
+if ($response->getStatusCode() == 202) {
+  drush_print('[OK] TugboatApi::registerRepository() returned a successful response.');
+}
+else {
+  drush_print('[KO] TugboatApi::registerRepository() failed.');
+}
+
+// TugboatApi::getPreviewsFor('project/drupal');
+$previews = TugboatApi::getPreviewsFor('project/drupal');
+if (!empty($previews[0])) {
+  drush_print('[OK] TugboatApi::getPreviewsFor() returned previews.');
+}
+else {
+  drush_print('[KO] TugboatApi::getPreviewsFor() failed.');
+}
+
+// TugboatPreview tests
+// ====================
+// TugboatPreview::fromGitLabPayload()
+
+// Unsupported payload.
+// Payload for core.
+$unsupported_payload = new stdClass();
+$unsupported_payload->project = new stdClass();
+$unsupported_payload->project->path_with_namespace = 'project/drupal';
+$unsupported_payload->project->name = 'drupal';
+$unsupported_payload->project->namespace = 'project';
+$unsupported_payload->object_attributes = new stdClass();
+$unsupported_payload->object_attributes->iid = '72';
+$unsupported_payload->object_attributes->source_branch = '1234_drupal';
+$unsupported_payload->object_attributes->target_branch = '8.8.x';
+$unsupported_payload->object_attributes->action = 'update';
+try {
+  $tugboat_preview = TugboatPreview::fromGitLabPayload($unsupported_payload);
+  drush_print('[KO] TugboatPreview::fromGitLabPayload() when a merge request event is not supported, an exception should be thrown.');
+}
+catch (UnsupportedMergeRequestEventException $e) {
+  drush_print('[OK] TugboatPreview::fromGitLabPayload() throws an exception when the merge request event is unsupported.');
+}
+
+// Payload for core.
+$payload_for_core = new stdClass();
+$payload_for_core->project = new stdClass();
+$payload_for_core->project->path_with_namespace = 'project/drupal';
+$payload_for_core->project->name = 'drupal';
+$payload_for_core->project->namespace = 'project';
+$payload_for_core->object_attributes = new stdClass();
+$payload_for_core->object_attributes->iid = '72';
+$payload_for_core->object_attributes->source_branch = '1234_drupal';
+$payload_for_core->object_attributes->target_branch = '8.8.x';
+$payload_for_core->object_attributes->action = 'open';
+
+$tugboat_preview = TugboatPreview::fromGitLabPayload($payload_for_core);
+if (!empty($tugboat_preview)) {
+  drush_print('[OK] TugboatPreview::fromGitLabPayload() returned a payload.');
+}
+else {
+  drush_print('[KO] TugboatPreview::fromGitLabPayload() failed.');
+}
+
+// Same, but for contrib.
+$payload_for_contrib = new stdClass();
+$payload_for_contrib->project = new stdClass();
+$payload_for_contrib->project->path_with_namespace = 'project/webform';
+$payload_for_contrib->project->name = 'webform';
+$payload_for_contrib->project->namespace = 'project/webform';
+$payload_for_contrib->object_attributes = new stdClass();
+$payload_for_contrib->object_attributes->iid = '67';
+$payload_for_contrib->object_attributes->source_branch = '1234_webform-test';
+$payload_for_contrib->object_attributes->target_branch = '8.x-5.x';
+$payload_for_contrib->object_attributes->action = 'open';
+$payload_for_contrib->object_attributes->source = new stdClass();
+$payload_for_contrib->object_attributes->source->path_with_namespace = 'project/webform';
+$payload_for_contrib->object_attributes->target_project_id = '400';
+$tugboat_preview = TugboatPreview::fromGitLabPayload($payload_for_contrib);
+if (!empty($tugboat_preview)) {
+  drush_print('[OK] TugboatPreview::fromGitLabPayload() returned a payload for contrib.');
+}
+else {
+  drush_print('[KO] TugboatPreview::fromGitLabPayload() failed for contrib.');
+}
+
+// Now test a contrib that does not support tugboat.
+$payload_for_unsupported_contrib = new stdClass();
+$payload_for_unsupported_contrib->project = new stdClass();
+$payload_for_unsupported_contrib->project->path_with_namespace = 'project/foo';
+$payload_for_unsupported_contrib->project->name = 'webform';
+$payload_for_unsupported_contrib->project->namespace = 'project/foo';
+$payload_for_unsupported_contrib->object_attributes = new stdClass();
+$payload_for_unsupported_contrib->object_attributes->iid = '67';
+$payload_for_unsupported_contrib->object_attributes->source_branch = '1234_foo-test';
+$payload_for_unsupported_contrib->object_attributes->target_branch = '8.x-5.x';
+$payload_for_unsupported_contrib->object_attributes->action = 'open';
+$payload_for_unsupported_contrib->object_attributes->source = new stdClass();
+$payload_for_unsupported_contrib->object_attributes->source->path_with_namespace = 'project/foo';
+$payload_for_unsupported_contrib->object_attributes->target_project_id = '400';
+try {
+  $tugboat_preview = TugboatPreview::fromGitLabPayload($payload_for_unsupported_contrib);
+  drush_print('[KO] TugboatPreview::fromGitLabPayload() when a project does not support Tugboat, an exception should be thrown.');
+}
+catch (ProjectDoesNotSupportTugboatException $e) {
+  drush_print('[OK] TugboatPreview::fromGitLabPayload() throws an exception when the project does not support Tugboat.');
+}
+
+// $tugboat_preview->build()
+/** @var \Psr\Http\Message\ResponseInterface $response */
+$response = $tugboat_preview->build();
+if ($response->getStatusCode() == 202) {
+  drush_print('[OK] $tugboat_preview->build() returned a successful response.');
+}
+else {
+  drush_print('[KO] $tugboat_preview->build() failed.');
+}
+
+// TugboatPreview::fetchConfiguration()
+$configuration = TugboatPreview::fetchConfiguration('drupal', 'default');
+if (!empty($configuration)) {
+  drush_print('[OK] TugboatPreview::fetchConfiguration() returned valid configuration.');
+}
+else {
+  drush_print('[KO] TugboatPreview::fetchConfiguration() failed.');
+}
+
+// drush drupalorg-tugboat-register-project
+// ========================================
+drush_print('Testing drush drupalorg-tugboat-register-project');
+drush_drupalorg_tugboat_register_project('project', 'drupal');
+
+// drush drupalorg-tugboat-build-base-preview
+// ==========================================
+drush_print('Testing drupalorg-tugboat-build-base-preview');
+drush_drupalorg_tugboat_build_base_preview('123projectid', 'drupal', '9.5.x');
diff --git a/drupalorg/tests/tugboat/tugboatProxy.php b/drupalorg/tests/tugboat/tugboatProxy.php
new file mode 100644
index 0000000000000000000000000000000000000000..840b357093f2dd85f243ac215ac72f52ce49a87c
--- /dev/null
+++ b/drupalorg/tests/tugboat/tugboatProxy.php
@@ -0,0 +1,94 @@
+<?php
+
+/**
+ * @file
+ *
+ * Test proxy to mock requests to the Tugboat API.
+ */
+
+if ($_SERVER['REQUEST_URI'] == '/v3/previews/foo/rebuild') {
+  http_response_code(202);
+  echo 'ok';
+}
+
+if ($_SERVER['REQUEST_URI'] == '/v3/previews/foo/log') {
+  http_response_code(200);
+  echo json_encode(['foo' => 'bar']);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/v3/projects/1234/repos') {
+  http_response_code(200);
+  echo json_encode([
+    [
+      'name' => 'project/drupal',
+      'id' => '123repo',
+      'provider_config' => [
+        'server' => 'http://localhost:8001',
+        'namespace' => 'project',
+        'project' => 'drupal',
+      ],
+    ],
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/v3/repos/123repo/previews') {
+  http_response_code(200);
+  echo json_encode([
+    [
+      'id' => '123preview',
+    ],
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/v3/previews') {
+  $payload = json_decode(file_get_contents('php://input'));
+  http_response_code(202);
+  echo json_encode([
+    'project'=> '5d810c19f6f8203d5b65ef01',
+    'repo'=> '5d810c19f6f82083ed65ef03',
+    'preview'=> '5d9b842252163ca8a1508c11',
+    'action'=> 'build',
+    'target'=> 'previews',
+    'args'=> [],
+    'createdAt'=> '2019-08-24T14:15:22Z',
+    'endedAt'=> '2019-08-24T14:15:22Z',
+    'id'=> '5d9e0a3f7f02ad896974a975',
+    'job'=> '5d9e0a3f7f02ad896974a975',
+    'key'=> '5d9b5bfb52163ce4e1508c07',
+    'message'=> 'string',
+    'object'=> '5d55823f30af7a1be3899ca4',
+    'result'=> 'success',
+    'startedAt'=> '2019-08-24T14:15:22Z',
+    'type'=> 'job',
+    'updatedAt'=> '2019-08-24T14:15:22Z'
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/v3/repos') {
+  $payload = json_decode(file_get_contents('php://input'));
+  http_response_code(202);
+  echo json_encode([
+    'foo' => 'bar',
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/api/v4/projects/400/members') {
+  http_response_code(200);
+  header('Content-Type: application/json');
+  echo json_encode([
+    'foo' => 'bar',
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/api/v4/projects/400/members/72') {
+  http_response_code(200);
+  header('Content-Type: application/json');
+  echo json_encode([
+    'foo' => 'bar',
+  ]);
+}
+
+if ($_SERVER['REQUEST_URI'] == '/project/webform/-/raw/1234_webform-test/.tugboat/config.yml') {
+  echo '- drush en webform -y';
+}
+