diff --git a/rbintegrations/extension.py b/rbintegrations/extension.py
index b9c860f2df76fb276083d1ea35586a6867a21a98..316e23bce05ebe7127bd83d14ed30aee3d993b98 100644
--- a/rbintegrations/extension.py
+++ b/rbintegrations/extension.py
@@ -7,6 +7,7 @@ from reviewboard.urls import reviewable_url_names, review_request_url_names
 from rbintegrations.asana.integration import AsanaIntegration
 from rbintegrations.circleci.integration import CircleCIIntegration
 from rbintegrations.discord.integration import DiscordIntegration
+from rbintegrations.gitlabci.integration import GitlabCIIntegration
 from rbintegrations.idonethis.integration import IDoneThisIntegration
 from rbintegrations.jenkinsci.integration import JenkinsCIIntegration
 from rbintegrations.matrix.integration import MatrixIntegration
@@ -30,6 +31,7 @@ class RBIntegrationsExtension(Extension):
         AsanaIntegration,
         CircleCIIntegration,
         DiscordIntegration,
+        GitlabCIIntegration,
         IDoneThisIntegration,
         JenkinsCIIntegration,
         MatrixIntegration,
diff --git a/rbintegrations/gitlabci/__init__.py b/rbintegrations/gitlabci/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rbintegrations/gitlabci/forms.py b/rbintegrations/gitlabci/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..4594da0211241f5f82fabe301484b0252ac287bf
--- /dev/null
+++ b/rbintegrations/gitlabci/forms.py
@@ -0,0 +1,146 @@
+"""The form for configuring the gitlab CI integration."""
+
+import gitlab
+import logging
+import re
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from rbintegrations.baseci.forms import BaseCIIntegrationConfigForm
+
+
+logger = logging.getLogger(__name__)
+
+
+class GitlabCIIntegrationConfigForm(BaseCIIntegrationConfigForm):
+    """Form for configuring gitlab CI."""
+
+    gitlab_endpoint = forms.URLField(
+        label=_('Server'),
+        help_text=_('Server endpoint URL.'))
+
+    gitlab_token = forms.CharField(
+        label=_('Token'),
+        help_text=_('The API token used for authentication. '
+                    '"private_token" if project is a name and '
+                    '"trigger_token" if proejct is an ID'),
+        widget=forms.PasswordInput)
+
+    gitlab_name = forms.CharField(
+        label=_('Project Name or ID'),
+        help_text=_(
+            'Name (uses private_token) or ID (uses trigger token).'
+            'This can include the following variables: '
+            '{branch_name} (the branch name), '
+            '{repository_name} (the repository name).'
+        ))
+
+    gitlab_user_token = forms.CharField(
+        label=_('Review Board API Token'),
+        help_text=_('This API token is used by gitlab to update build '
+                    'status. Please specify this in the gitlab-side '
+                    'configuration. Note that if you switch the local site '
+                    'this API token will be updated upon saving.'),
+        required=False,
+        widget=forms.TextInput(attrs={'readonly': True}))
+
+    def __init__(self, *args, **kwargs) -> None:
+        """Initialize the form.
+
+        Args:
+            *args (tuple):
+                Arguments for the form.
+
+            **kwargs (dict):
+                Keyword arguments for the form.
+        """
+        super().__init__(*args, **kwargs)
+
+        integration = self.integration
+        local_site = self.fields['local_site'].initial
+        user = integration.get_or_create_user()
+
+        token = integration.get_or_create_api_token(
+            user=user,
+            local_site=local_site)
+
+        self.initial['gitlab_user_token'] = token.token
+
+    def clean(self):
+        """Clean the form.
+
+        This validates the user credentials that have been provided.
+
+        Returns:
+            dict:
+            The data.
+        """
+        data = super(GitlabCIIntegrationConfigForm, self).clean()
+
+        if self._errors:
+            # Form validation has already failed.
+            return data
+
+        try:
+            if not re.search(r'^\d+$', data.get('gitlab_name')):
+                gl = gitlab.Gitlab(url=data.get('gitlab_endpoint'),
+                                   private_token=data.get('gitlab_token'))
+                gl.auth()
+        except gitlab.exceptions.GitlabAuthenticationError as e:
+            message = str(e)
+            self._errors['gitlab_endpoint'] = self.error_class([message])
+            self._errors['gitlab_endpoint'] = self.error_class([message])
+
+        return data
+
+    class Meta(BaseCIIntegrationConfigForm.Meta):
+        fieldsets = (
+            (_('What To Build'), {
+                'description': _(
+                    'You can choose which review requests to build using this '
+                    'configuration by setting conditions here. At a minimum, '
+                    'this should include the specific repository to use '
+                    'this configuration for.'
+                ),
+                'fields': (
+                    'conditions',
+                ),
+            }),
+            (_('Where To Build'), {
+                'description': _(
+                    'Configure the address and authentication credentials '
+                    'for the gitlab server handling your builds.'
+                ),
+                'fields': (
+                    'gitlab_endpoint',
+                    'gitlab_token',
+                ),
+            }),
+            (_('How To Build'), {
+                'description': _(
+                    "Builds performed on the code in review requests will use "
+                    "a completely separate configuration from commits which "
+                    "are pushed to the repository. The configuration listed "
+                    "here will be used regardless of and in addition to any "
+                    "external configurations."
+                    "\n"
+                    "It's also recommended to create a special branch head "
+                    "in the repository to use for these builds, so they "
+                    "don't appear to be happening on your main development "
+                    "branch. This branch can contain anything (or even be "
+                    "empty), since the code will come from the review "
+                    "request."
+                ),
+                'fields': (
+                    'gitlab_name',
+                    'gitlab_user_token',
+                ),
+            }),
+            (_('When To Build'), {
+                'fields': (
+                    'run_manually',
+                    'timeout',
+                ),
+            }),
+        )
diff --git a/rbintegrations/gitlabci/integration.py b/rbintegrations/gitlabci/integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..8175308ccb592e1eeaf2501ffa24e2206de405ec
--- /dev/null
+++ b/rbintegrations/gitlabci/integration.py
@@ -0,0 +1,201 @@
+"""Integration for building changes on gitlab CI."""
+
+from __future__ import annotations
+
+import gitlab
+import logging
+import re
+from typing import Dict, TYPE_CHECKING
+
+from djblets.util.decorators import cached_property
+
+# from rbintegrations.baseci.errors import CIBuildError
+from rbintegrations.baseci.integration import BaseCIIntegration, BuildPrepData
+from rbintegrations.gitlabci.forms import GitlabCIIntegrationConfigForm
+
+if TYPE_CHECKING:
+    from reviewboard.integrations.models import IntegrationConfig
+    from reviewboard.reviews.models import ReviewRequest, StatusUpdate
+    from reviewboard.scmtools.models import Repository
+
+
+logger = logging.getLogger(__name__)
+
+
+class GitlabCIIntegration(BaseCIIntegration):
+    """Integrates Review Board with gitlab CI."""
+
+    name = 'gitlab CI'
+    description = 'Builds diffs posted to Review Board using gitlab CI.'
+    config_form_cls = GitlabCIIntegrationConfigForm
+
+    status_update_service_id = 'gitlab-ci'
+
+    bot_username = 'gitlab-ci'
+    bot_user_first_name = 'gitlab'
+    bot_user_last_name = 'CI'
+
+    @cached_property
+    def icon_static_urls(self) -> Dict:
+        """The icons used for the integration.
+
+        Type:
+            dict
+        """
+        from rbintegrations.extension import RBIntegrationsExtension
+
+        extension = RBIntegrationsExtension.instance
+
+        return {
+            '1x': extension.get_static_url('images/gitlabci/icon.png'),
+            '2x': extension.get_static_url('images/gitlabci/icon@2x.png'),
+        }
+
+    def prepare_builds(
+        self,
+        prep_data: BuildPrepData,
+    ) -> bool:
+        """Prepare for builds.
+
+        This will set some initial information common to all builds.
+
+        Version Added:
+            4.0.2
+
+        Args:
+            prep_data (BuildPrepData):
+                The builds preparation data to modify.
+
+        Returns:
+            bool:
+            ``True``, always.
+        """
+        review_request = prep_data.review_request
+
+        prep_data.extra_state['patch_info'] = {
+            'diff_revision': prep_data.diffset.revision,
+            'review_branch': review_request.branch,
+            'review_id': review_request.display_id,
+            'reviewboard_server': prep_data.server_url,
+        }
+
+        return True
+
+    def start_build(
+        self,
+        *,
+        prep_data: BuildPrepData,
+        config: IntegrationConfig,
+        status_update: StatusUpdate,
+    ) -> None:
+        """Start a new build.
+
+        This will trigger a Jenkins build for the given review request and
+        configuration.
+
+        Version Added:
+            4.0.2
+
+        Args:
+            prep_data (BuildPrepData):
+                The builds preparation data containing information for the
+                build.
+
+            config (reviewboard.integrations.models.IntegrationConfig):
+                The configuration for the integration triggering this build.
+
+            status_update (reviewboard.reviews.models.status_update.
+                           StatusUpdate):
+                The status update indicating the status of this build.
+
+        Raises:
+            rbintegrations.baseci.errors.CIBuildError:
+                There was an error invoking the build.
+        """
+        patch_info = prep_data.extra_state['patch_info'].copy()
+        patch_info['status_update_id'] = status_update.pk
+
+        diffset = prep_data.diffset
+        repository = prep_data.repository
+        review_request = prep_data.review_request
+        name = self._replace_variables(config.get('gitlab_name'),
+                                       repository, review_request)
+
+        logger.debug('Triggering gitlab CI build for review request '
+                     '%s (diffset revision %d) on %s',
+                     review_request.get_absolute_url(),
+                     diffset.revision,
+                     name)
+
+        variables = [
+            {"key": "REVIEWBOARD_SERVER",
+             "value": patch_info['reviewboard_server']},
+            {"key": "REVIEWBOARD_REVIEW_ID",
+             "value": patch_info['review_id']},
+            {"key": "REVIEWBOARD_REVIEW_BRANCH",
+             "value": patch_info['review_branch']},
+            {"key": "REVIEWBOARD_DIFF_REVISION",
+             "value": patch_info['diff_revision']},
+            {"key": "REVIEWBOARD_STATUS_UPDATE_ID",
+             "value": patch_info['status_update_id']},
+        ]
+
+        if re.search(r'^\d+$', config.get('gitlab_name')):
+            gl = gitlab.Gitlab(url=config.get('gitlab_endpoint'))
+            gl.enable_debug()
+            project = gl.projects.get(config.get('gitlab_name'), lazy=True)
+            project.trigger_pipeline(review_request.branch,
+                                     config.get('gitlab_token'),
+                                     variables=variables)
+        else:
+            gl = gitlab.Gitlab(url=config.get('gitlab_endpoint'),
+                               private_token=config.get('gitlab_token'))
+            gl.enable_debug()
+            gl.auth()
+            project = gl.projects.get(name)
+            project.pipelines.create({'ref': review_request.branch,
+                                      'variables': variables})
+
+    def _replace_variables(
+        self,
+        name: str,
+        repository: Repository,
+        review_request: ReviewRequest,
+    ) -> str:
+        """Replace variables in the name.
+
+        This will replace the following variables:
+
+        ``{branch_name}``:
+            The branch name.
+
+        ``{repository_name}``:
+            The configured repository name.
+
+        Version Added:
+            4.0.2:
+
+        Args:
+            job_name (str):
+                The template string for the name.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository for the change being built.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request for the change being built.
+
+        Returns:
+            str:
+            The resulting name.
+        """
+        var_map: dict[str, str] = {
+            'branch_name': review_request.branch,
+            'repository_name': repository.name,
+        }
+
+        return re.sub(
+            r'\{(%s)\}' % '|'.join(var_map.keys()),
+            lambda m: var_map[m.group(1)],
+            name)
diff --git a/rbintegrations/static/images/gitlabci/icon.png b/rbintegrations/static/images/gitlabci/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec8ff2bb2da7840e44d03e2ac2c5dec99383378f
Binary files /dev/null and b/rbintegrations/static/images/gitlabci/icon.png differ
diff --git a/rbintegrations/static/images/gitlabci/icon@2x.png b/rbintegrations/static/images/gitlabci/icon@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..bfe25e6891eff66b72f2cf4d4a6c0ba04651b836
Binary files /dev/null and b/rbintegrations/static/images/gitlabci/icon@2x.png differ
diff --git a/setup.py b/setup.py
index f9bb8dd2545aa064740a4ff2f949ee067f4ad1b4..ccd7272ab244040d22005f7862f817f302d289d8 100755
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@ setup(
     packages=find_packages(),
     install_requires=[
         'asana',
+        'python-gitlab',
         'housekeeping~=1.1',
         'PyYAML>=3.12',
     ],
