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'; +} +