From c0448bfae33a638c083c13bb3d3b07124d2fc35d Mon Sep 17 00:00:00 2001 From: Aaron Bauman <aaron@messageagency.com> Date: Fri, 30 Dec 2016 13:56:18 -0500 Subject: [PATCH] - Refactor salesforce push and push queue to accommodate async delete - Fix RestClient behavior for objectDelete: do not throw an exception when delete returns 404 - Prevent missing field mapping plugins from causing fatal errors - Normalize data for EntityNotFoundException --- .../salesforce_mapping.module | 15 +- .../src/Entity/SalesforceMapping.php | 16 +- .../Form/SalesforceMappingFormCrudBase.php | 4 +- .../salesforce_push/salesforce_push.module | 156 +++++++++--------- .../SalesforcePushQueueProcessor/Rest.php | 63 +++++-- modules/salesforce_push/src/PushQueue.php | 57 +++++-- src/EntityNotFoundException.php | 20 ++- src/Rest/RestClient.php | 89 ++++++---- 8 files changed, 271 insertions(+), 149 deletions(-) diff --git a/modules/salesforce_mapping/salesforce_mapping.module b/modules/salesforce_mapping/salesforce_mapping.module index fb8d1312..e5702eaa 100644 --- a/modules/salesforce_mapping/salesforce_mapping.module +++ b/modules/salesforce_mapping/salesforce_mapping.module @@ -103,7 +103,7 @@ function salesforce_mapping_load($name) { ->getStorage('salesforce_mapping') ->load($name); if (empty($mapping)) { - throw new EntityNotFoundException("No mapping found for $name."); + throw new EntityNotFoundException($name, 'salesforce_mapping'); } return $mapping; } @@ -127,7 +127,11 @@ function salesforce_mapping_load_multiple($properties = []) { ->getStorage('salesforce_mapping') ->loadByProperties($properties); if (empty($mappings)) { - throw new EntityNotFoundException('No mappings found.'); + $bt = debug_backtrace(FALSE); + foreach ($bt as &$e) { + unset($e['args']); + } + throw new EntityNotFoundException($properties, 'salesforce_mapping'); } return $mappings; } @@ -162,7 +166,7 @@ function salesforce_mapped_object_load_multiple($properties = []) { ->getStorage('salesforce_mapped_object') ->loadByProperties($properties); if (empty($mappings)) { - throw new EntityNotFoundException('No mapped objects found'); + throw new EntityNotFoundException($properties, 'salesforce_mapped_object'); } return $mappings; } @@ -351,7 +355,7 @@ function salesforce_mapping_entity_update(EntityInterface $entity) { try { salesforce_mapping_load_by_drupal($entity->getEntityTypeId()); } - catch (Exception $e) { + catch (\Exception $e) { return; } @@ -359,9 +363,10 @@ function salesforce_mapping_entity_update(EntityInterface $entity) { try { $mapped_objects = salesforce_mapped_object_load_by_drupal($entity->getEntityTypeId(), $entity->id()); } - catch (Exception $e) { + catch (\Exception $e) { return; } + foreach ($mapped_objects as $mapped_object) { // Resaving the object should update the timestamp. // NB: we are purposefully not creating a new revision here. diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php index 08f947f3..1464d02c 100644 --- a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php +++ b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php @@ -5,6 +5,7 @@ namespace Drupal\salesforce_mapping\Entity; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityInterface; use Drupal\salesforce\Exception; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; /** * Defines a Salesforce Mapping configuration entity class. @@ -273,10 +274,17 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt // @TODO #fieldMappingField $mappings = []; foreach ($this->field_mappings as $field) { - $mappings[] = $this->fieldManager->createInstance( - $field['drupal_field_type'], - $field - ); + try { + $mappings[] = $this->fieldManager->createInstance( + $field['drupal_field_type'], + $field + ); + } + catch (PluginNotFoundException $e) { + // Don't let a missing plugin kill our mapping. + watchdog_exception(__CLASS__, $e); + salesforce_set_message(t('Field plugin not found: %message The field will be removed from this mapping.', ['%message' => $e->getMessage()]), 'error'); + } } return $mappings; } diff --git a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php index 2af14f83..b7cdc971 100644 --- a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php +++ b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php @@ -263,7 +263,7 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { if (!empty($entity_type) && empty($form_state->getValue('drupal_bundle')[$entity_type])) { $element = &$form['drupal_entity']['drupal_bundle'][$entity_type]; // @TODO replace with Dependency Injection - \Drupal::formBuilder()->setError($element, $this->t('!name field is required.', ['!name' => $element['#title']])); + $form_state->setError($element, $this->t('%name field is required.', ['%name' => $element['#title']])); } // In case the form was submitted without javascript, we must validate the @@ -272,7 +272,7 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { $record_types = $this->get_salesforce_record_type_options($form_state->getValue('salesforce_object_type'), $form_state); if (count($record_types) > 1) { $element = &$form['salesforce_object']['salesforce_record_type']; - drupal_set_message($this->t('!name field is required for this Salesforce Object type.', ['!name' => $element['#title']])); + $form_state->setError($element, $this->t('%name field is required for this Salesforce Object type.', ['%name' => $element['#title']])); $form_state->setValue('rebuild', TRUE); } } diff --git a/modules/salesforce_push/salesforce_push.module b/modules/salesforce_push/salesforce_push.module index 69fd575b..0a56af7c 100644 --- a/modules/salesforce_push/salesforce_push.module +++ b/modules/salesforce_push/salesforce_push.module @@ -7,6 +7,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\salesforce\EntityNotFoundException; use Drupal\salesforce_mapping\Entity\MappedObject; use Drupal\salesforce_mapping\Entity\MappedObjectInterface; use Drupal\salesforce_mapping\Entity\SalesforceMapping; @@ -58,7 +59,13 @@ function salesforce_push_salesforce_push_entity_allowed(EntityInterface $entity, * to be a very long ways away. https://www.drupal.org/node/2551893 */ function salesforce_push_entity_crud(EntityInterface $entity, $op) { - $mappings = salesforce_push_load_push_mappings($entity->getEntityTypeId()); + try { + $mappings = salesforce_push_load_push_mappings($entity->getEntityTypeId()); + } + catch (EntityNotFoundException $e) { + return; + } + foreach ($mappings as $mapping) { $mapped_objects = []; $mapped_object = FALSE; @@ -69,17 +76,58 @@ function salesforce_push_entity_crud(EntityInterface $entity, $op) { } } + // Look for existing mapped object. + $props = [ + 'entity_id' => $entity->id(), + 'entity_type_id' => $entity->getEntityTypeId(), + 'salesforce_mapping' => $mapping->id(), + ]; + $mapped_object = FALSE; + try { + $mapped_objects = salesforce_mapped_object_load_multiple($props); + // There should really only be one, but the return value is always an array + $mapped_object = current($mapped_objects); + } + catch (EntityNotFoundException $e) { + // No mappings found. + if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { + // If no existing mapping, and this is a delete, purge any entries from push queue and we're done. + \Drupal::service('queue.salesforce_push') + ->setName($mapping->id()) + ->deleteItemByEntity($entity); + return; + } + $mapped_object = new MappedObject($props); + } + if ($mapping->async) { - // Enqueue - salesforce_push_enqueue_async($entity, $mapping, $op); + // Enqueue async push if the mapping is configured to do so, and quit. + try { + salesforce_push_enqueue_async($entity, $mapping, $mapped_object, $op); + } + catch (\Exception $e) { + // @TODO log: failed to enqueue changes. + } return; } try { - $mapped_object = salesforce_push_sync_rest($entity, $mapping, $op); + // If this is a delete, destroy the SF object and we're done. + if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { + $mapped_object->pushDelete(); + } + else { + // Push to SF. This also saves the mapped object. + $mapped_object->push(); + } } - catch (Exception $e) { - // what do do here? + catch (RequestException $e) { + $mapped_object + ->set('last_sync_action', $op) + ->set('last_sync_status', FALSE) + ->set('revision_log_message', $e->getMessage()) + ->save(); + throw $e; } } } @@ -92,84 +140,27 @@ function salesforce_push_entity_crud(EntityInterface $entity, $op) { * (optional) filter by mapping drupal entity type. * * @return array + * + * @throws EntityNotFoundException if no push mappings found. */ function salesforce_push_load_push_mappings($entity_type_id = NULL) { $push_mappings = []; - try { - $properties = empty($entity_type_id) - ? [] - : ["drupal_entity_type" => $entity_type_id]; - $mappings = salesforce_mapping_load_multiple($properties); - foreach ($mappings as $key => $mapping) { - if (!$mapping->doesPush()) { - continue; - } - $push_mappings[$key] = $mapping; + $properties = empty($entity_type_id) + ? [] + : ["drupal_entity_type" => $entity_type_id]; + $mappings = salesforce_mapping_load_multiple($properties); + foreach ($mappings as $key => $mapping) { + if (!$mapping->doesPush()) { + continue; } + $push_mappings[$key] = $mapping; } - catch (Exception $e) { - // No mappings found. + if (empty($push_mappings)) { + throw new EntityNotFoundException($properties, 'salesforce_mapping'); } return $push_mappings; } -/** - * Worker function to do actual push to Salesforce. - * - * @param EntityInterface $entity - * @param SalesforceMappingInterface $mapping - * @param string $op - * one of push_create, push_update, push_delete - * @return SF result - * - * @throws Exception if mapping object cannot be loaded or created - * @throws RequestException if push operations fail - */ -function salesforce_push_sync_rest(EntityInterface $entity, SalesforceMappingInterface $mapping, $op) { - - // First, look for existing mapped object. - $props = [ - 'entity_id' => $entity->id(), - 'entity_type_id' => $entity->getEntityTypeId(), - 'salesforce_mapping' => $mapping->id(), - ]; - try { - $mapped_objects = salesforce_mapped_object_load_multiple($props); - // There should really only be one, but the return value is always an array - $mapped_object = current($mapped_objects); - } - catch (Exception $e) { - // No mappings found, need to create a new one and push... - if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { - // Unless it's a delete. Then we're done. - throw $e; - } - $mapped_object = new MappedObject($props); - } - - try { - // If this is a delete, destroy the SF object and we're done. - if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { - return $mapped_object->pushDelete(); - } - else { - // Push to SF. This also saves the mapped object. - return $mapped_object->push(); - } - } - catch (RequestException $e) { - $mapped_object - ->set('last_sync_action', $op) - ->set('last_sync_status', FALSE) - ->set('revision_log_message', $e->getMessage()) - ->save(); - throw $e; - - // @TODO Can we pawn this off to an entity_save hook? implementations can look at last_sync_status to determine success vs failure. - // \Drupal::moduleHandler()->invokeAll('salesforce_push_failure', array($e->getMessage(), $vars)); - } -} - /** * Worker function to insert a new queue item into the async push queue for the * given mapping. @@ -177,16 +168,21 @@ function salesforce_push_sync_rest(EntityInterface $entity, SalesforceMappingInt * @param SalesforceMappingInterface $mapping * @param string $op */ -function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappingInterface $mapping, $op) { +function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappingInterface $mapping, MappedObjectInterface $mapped_object = NULL, $op) { // Each mapping has its own queue, so that like entries can be easily grouped // for batching. Each queue item is a unique array of entity ids to be // pushed. The async queue worker loads the queue item and works through as // many entities as possible, up to the async limit for this mapping. - \Drupal::service('queue.salesforce_push')->createItem([ + $props = [ 'name' => $mapping->id(), 'entity_id' => $entity->id(), 'op' => $op, - ]); + ]; + if ($mapped_object) { + $props['mapped_object_id'] = $mapped_object->id(); + } + + \Drupal::service('queue.salesforce_push')->createItem($props); } /** @@ -194,11 +190,11 @@ function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappin */ function salesforce_push_cron() { $queue = \Drupal::service('queue.salesforce_push'); + $queue->garbageCollection(); try { $queue->processQueues(); } - catch (Exception $e) { + catch (\Exception $e) { watchdog_exception('Salesforce Push', $e); } - $queue->garbageCollection(); } diff --git a/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php b/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php index d240d1a8..93b2007c 100644 --- a/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php +++ b/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php @@ -8,6 +8,7 @@ use Drupal\salesforce\EntityNotFoundException; use Drupal\salesforce\Rest\RestClient; use Drupal\salesforce_push\PushQueue; use Drupal\salesforce_push\PushQueueProcessorInterface; +use Drupal\salesforce_mapping\Entity\MappedObject; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -52,23 +53,57 @@ class Rest extends PluginBase implements PushQueueProcessorInterface { } protected function processItem(\stdClass $item) { + $mapped_object = \Drupal::entityTypeManager() + ->getStorage('salesforce_mapped_object') + ->load($item->mapped_object_id); + + // Allow exceptions to bubble up for PushQueue to sort things out. $mapping = salesforce_mapping_load($item->name); - $entity = \Drupal::entityTypeManager() - ->getStorage($mapping->get('drupal_entity_type')) - ->load($item->entity_id); - if (!$entity) { - throw new EntityNotFoundException(); - } + if (!$mapped_object) { + if ($item->op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { + // If mapped object doesn't exist or fails to load for this delete, this item can be considered successfully processed. + return; + } + $mapped_object = new MappedObject([ + 'entity_id' => $item->entity_id, + 'entity_type_id' => $mapping->drupal_entity_type, + 'salesforce_mapping' => $mapping->id(), + ]); + } + + // @TODO: the following is nearly identical to the end of salesforce_push_entity_crud(). Can we DRY it? Do we care? + try { + // If this is a delete, destroy the SF object and we're done. + if ($item->op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) { + $mapped_object->pushDelete(); + } + else { + $entity = \Drupal::entityTypeManager() + ->getStorage($mapping->drupal_entity_type) + ->load($item->entity_id); + if (!$entity) { + // Bubble this up also + throw new EntityNotFoundException($item->entity_id, $mapping->drupal_entity_type); + } - salesforce_push_sync_rest($entity, $mapping, $item->op); - \Drupal::logger('Salesforce Push')->notice('Entity %type %id for salesforce mapping %mapping pushed successfully.', - [ - '%type' => $mapping->get('drupal_entity_type'), - '%id' => $item->entity_id, - '%mapping' => $mapping->id(), - ] - ); + // Push to SF. This also saves the mapped object. + $mapped_object + ->setDrupalEntity($entity) + ->push(); + } + } + catch (\Exception $e) { + if (!$mapped_object->isNew()) { + // Only update existing mapped objects. + $mapped_object + ->set('last_sync_action', $item->op) + ->set('last_sync_status', FALSE) + ->set('revision_log_message', $e->getMessage()) + ->save(); + } + throw $e; + } } } diff --git a/modules/salesforce_push/src/PushQueue.php b/modules/salesforce_push/src/PushQueue.php index 2178045f..f5633823 100644 --- a/modules/salesforce_push/src/PushQueue.php +++ b/modules/salesforce_push/src/PushQueue.php @@ -10,6 +10,7 @@ use Drupal\Core\Queue\DatabaseQueue; use Drupal\Core\State\State; use Drupal\Core\Queue\SuspendQueueException; use Drupal\Core\Queue\RequeueException; +use Drupal\salesforce\EntityNotFoundException; /** * Salesforce push queue. @@ -88,15 +89,23 @@ class PushQueue extends DatabaseQueue { } $this->name = $data['name']; $time = time(); + $fields = [ + 'name' => $this->name, + 'entity_id' => $data['entity_id'], + 'op' => $data['op'], + 'updated' => $time, + 'failures' => empty($data['failures']) + ? 0 + : $data['failures'], + 'mapped_object_id' => empty($data['mapped_object_id']) + ? 0 + : $data['mapped_object_id'], + ]; + $query = $this->connection->merge(static::TABLE_NAME) ->key(array('name' => $this->name, 'entity_id' => $data['entity_id'])) - ->fields(array( - 'name' => $this->name, - 'entity_id' => $data['entity_id'], - 'op' => $data['op'], - 'updated' => $time, - )); - + ->fields($fields); + // Return Merge::STATUS_INSERT or Merge::STATUS_UPDATE $ret = $query->execute(); @@ -190,6 +199,12 @@ class PushQueue extends DatabaseQueue { 'default' => 0, 'description' => 'The entity id', ], + 'mapped_object_id' => [ + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Foreign key for salesforce_mapped_object table.' + ], 'op' => [ 'type' => 'varchar_ascii', 'length' => 16, @@ -238,7 +253,12 @@ class PushQueue extends DatabaseQueue { * Process Salesforce queues */ public function processQueues() { - $mappings = salesforce_push_load_push_mappings(); + try { + $mappings = salesforce_push_load_push_mappings(); + } + catch (EntityNotFoundException $e) { + return $this; + } $i = 0; // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this: @@ -270,7 +290,8 @@ class PushQueue extends DatabaseQueue { } catch (SuspendQueueException $e) { // Getting a SuspendQueue is more likely, e.g. because of a network - // or authorization error. Move on to the next mapping in this case. + // or authorization error. Release items and move on to the next + // mapping in this case. $this->releaseItems($items); watchdog_exception('Salesforce Push', $e); @@ -307,7 +328,7 @@ class PushQueue extends DatabaseQueue { $mapping = salesforce_mapping_load($item->name); if ($e instanceof EntityNotFoundException) { - // If there was an exception loading the entity, we assume that this queue item is no longer relevant. + // If there was an exception loading any entities, we assume that this queue item is no longer relevant. \Drupal::logger('Salesforce Push')->error($e->getMessage() . ' Exception while loading entity %type %id for salesforce mapping %mapping. Queue item deleted.', [ @@ -340,10 +361,10 @@ class PushQueue extends DatabaseQueue { ] ); + // Failed items will remain in queue, but not be released. They'll be + // retried only when the current lease expires. // doCreateItem() doubles as "save" function. - // failed items will remain in queue in case fail params change or they need to be manually retried. $this->doCreateItem(get_object_vars($item)); - $this->releaseItem($item); } /** @@ -369,4 +390,16 @@ class PushQueue extends DatabaseQueue { } } + public function deleteItemByEntity(EntityInterface $entity) { + try { + $this->connection->delete(static::TABLE_NAME) + ->condition('entity_id', $entity->id()) + ->condition('name', $this->name) + ->execute(); + } + catch (\Exception $e) { + $this->catchException($e); + } + } + } diff --git a/src/EntityNotFoundException.php b/src/EntityNotFoundException.php index aba4d9ee..6383ffc2 100644 --- a/src/EntityNotFoundException.php +++ b/src/EntityNotFoundException.php @@ -6,6 +6,24 @@ namespace Drupal\salesforce; * EntityNotFoundException extends Drupal\salesforce\Exception * Thrown when a load operation returns no results. */ -class EntityNotFoundException extends Exception { +class EntityNotFoundException extends \RuntimeException { + protected $entity_properties; + + protected $entity_type_id; + + public function __construct($entity_properties, $entity_type_id, Throwable $previous = NULL) { + parent::__construct(t('Entity not found. type: %type properties: %props', ['%type' => $entity_type_id, '%props' => var_export($entity_properties, TRUE)]), 0, $previous); + $this->entity_properties = $entity_properties; + $this->entity_type_id = $entity_type_id; + } + + public function getEntityProperties() { + return $this->entity_properties; + } + + public function getEntityTypeId() { + return $this->entity_type_id; + } + } diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php index 01bf15cd..1ed44865 100644 --- a/src/Rest/RestClient.php +++ b/src/Rest/RestClient.php @@ -90,38 +90,33 @@ class RestClient { catch (RequestException $e) { // RequestException gets thrown for any response status but 2XX. $this->response = $e->getResponse(); + + switch ($this->response->getStatusCode()) { + case 401: + // The session ID or OAuth token used has expired or is invalid: refresh + // token. If refreshToken() throws an exception, or if apiHttpRequest() + // throws anything but a RequestException, let it bubble up. + $this->refreshToken(); + try { + $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method)); + } + catch (RequestException $e) { + $this->response = $e->getResponse(); + throw $e; + } + break; + + default: + // Any exceptions besides 401 we bubble up to the caller. + throw $e; + } } - if (!is_object($this->response)) { + + if (empty($this->response) + || !in_array($this->response->getStatusCode(), [200, 201, 204])) { throw new Exception('Unknown error occurred during API call'); } - switch ($this->response->getStatusCode()) { - case 401: - // The session ID or OAuth token used has expired or is invalid: refresh - // token. If refreshToken() throws an exception, or if apiHttpRequest() - // throws anything but a RequestException, let it bubble up. - $this->refreshToken(); - try { - $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method)); - } - catch (RequestException $e) { - $this->response = $e->getResponse(); - throw $e; - } - break; - - case 200: - case 201: - case 204: - // All clear. - break; - - default: - // We have problem and no specific Salesforce error provided. - if (empty($this->response)) { - throw new Exception('Unknown error occurred during API call'); - } - } if ($returnObject) { return $this->response; } @@ -182,6 +177,27 @@ class RestClient { return $response; } + /** + * Extract normalized error information from a RequestException + * + * @param RequestException $e + * @return array + * Error array with keys: + * * message + * * errorCode + * * fields + */ + protected function getErrorData(RequestException $e) { + $response = $e->getResponse(); + $response_body = $response->getBody()->getContents(); + $data = Json::decode($response_body); + if (!empty($data[0])) { + $data = $data[0]; + } + return $data; + } + + /** * Get the API end point for a given type of the API. * @@ -641,20 +657,31 @@ class RestClient { } /** - * Delete a Salesforce object. + * Delete a Salesforce object. Note: if Object with given $id doesn't exist, + * objectDelete() will assume success unless $throw_exception is given. * * @param string $name * Object type name, E.g., Contact, Account. * @param string $id * Salesforce id of the object. + * @pararm bool $throw_exception + * (optional) If TRUE, 404 response code will cause RequestException to be + * thrown. Otherwise, hide those errors. Default is FALSE. * * @addtogroup salesforce_apicalls * * @return null * Delete() doesn't return any data. Examine HTTP response or Exception. */ - public function objectDelete($name, $id) { - $this->apiCall("sobjects/{$name}/{$id}", [], 'DELETE'); + public function objectDelete($name, $id, $throw_exception = FALSE) { + try { + $this->apiCall("sobjects/{$name}/{$id}", [], 'DELETE'); + } + catch (RequestException $e) { + if ($throw_exception || $e->getResponse()->getStatusCode() != 404) { + throw $e; + } + } } /** -- GitLab