# HG changeset patch
# User Georges Racinet <georges.racinet@octobus.net>
# Date 1587332315 -7200
#      Sun Apr 19 23:38:35 2020 +0200
# Node ID 844f9ef3e33e71a69a30f2f19328902ee8ecc715
# Parent  d810ba97c1c8693a9dba3631b86b99df9c08f47f
GitLab hooks: direct REST API call for pre-receive hook

diff --git a/heptapod/gitlab/__init__.py b/heptapod/gitlab/__init__.py
--- a/heptapod/gitlab/__init__.py
+++ b/heptapod/gitlab/__init__.py
@@ -6,11 +6,43 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 import logging
 import os
+import re
 import requests
 import requests_unixsocket
 
 logger = logging.getLogger(__name__)
 
+ACTOR_RX_SYMBS = (
+    (re.compile(r'key-(\d+)$'), 'key_id'),
+    (re.compile(r'user-(\d+)$'), 'user_id'),
+    (re.compile(r'username-(.*)$'), 'username'),
+)
+
+ALLOWED_API_KNOWN_STATUS_CODES = (
+    requests.codes.ok,
+    requests.codes.multiple_choices,
+    requests.codes.unauthorized,
+    requests.codes.not_found,
+    requests.codes.service_unavailable,
+)
+
+
+class InvalidActor(ValueError):
+    pass
+
+
+def parse_actor(actor):
+    for regexp, symbol in ACTOR_RX_SYMBS:
+        m = regexp.match(actor)
+        if m is not None:
+            return (symbol, m.group(1))
+    raise InvalidActor(actor)
+
+
+def sanitize_path(path):
+    # translated from gitlab-shell/lib/gitlab_net.rb
+    return path.replace("'", "")
+
 
 def git_hook_format_changes(changes):
     """Format Git changes in the way Git hooks expect them in `stdin`.
@@ -108,6 +140,7 @@
 
     _hook_methods = {
         'post-receive': 'post_receive',
+        'pre-receive': 'pre_receive',
     }
 
     def __init__(self, name, repo):
@@ -211,6 +244,43 @@
             out_lines.extend(format_warnings(warnings))
         return '\n'.join(out_lines)
 
+    def pre_receive_params(self, changes, env=None):
+        if env is None:
+            env = self.environ()
+        params = dict(action='git-receive-pack',
+                      changes=git_hook_format_changes(changes),
+                      gl_repository=env['GL_REPOSITORY'],
+                      project=sanitize_path(self.repo.root),
+                      # TODO use a dedicated 'hg-git' protocol, and also
+                      # internally
+                      protocol=env['GL_PROTOCOL'],
+                      )
+        actor_symb, actor = parse_actor(env['GL_ID'])
+        params[actor_symb] = actor
+        return params
+
+    def pre_receive(self, changes, env=None):
+        """Call GitLab internal API 'allowed' endpoint used for pre-receive
+        """
+        params = self.pre_receive_params(changes, env=env)
+        resp = self.gitlab_internal_api_request('POST',
+                                                subpath='allowed',
+                                                params=params)
+        if resp.status_code not in ALLOWED_API_KNOWN_STATUS_CODES:
+            return False, '', "API is not accessible"
+
+        as_json = resp.json()
+        logger.debug("pre_receive for changes %r, response: %r",
+                     changes, as_json)
+        ok, message = as_json['status'], as_json.get('message')
+        out = err = ''
+        if ok:
+            if message:
+                out = message
+        else:
+            err = message
+        return ok, out, err
+
     def __call__(self, changes):
         """Call GitLab Hook for the given changes.
 
diff --git a/heptapod/gitlab/tests/test_hooks.py b/heptapod/gitlab/tests/test_hooks.py
--- a/heptapod/gitlab/tests/test_hooks.py
+++ b/heptapod/gitlab/tests/test_hooks.py
@@ -18,12 +18,31 @@
     format_mr_links,
     git_hook_format_changes,
     parse_broadcast_msg,
+    parse_actor,
     Hook,
+    InvalidActor,
     )
 
 ZERO_SHA = "0" * 20
 
 
+def test_parse_actor():
+    assert parse_actor("key-1") == ('key_id', '1')
+    assert parse_actor("key-17") == ('key_id', '17')
+    assert parse_actor("user-3") == ('user_id', '3')
+    assert parse_actor("user-35") == ('user_id', '35')
+    assert parse_actor("username-testgr") == ('username', 'testgr')
+    with pytest.raises(InvalidActor) as exc_info:
+        parse_actor('key-1x')
+    assert exc_info.value.args[0] == 'key-1x'
+    with pytest.raises(InvalidActor) as exc_info:
+        parse_actor('user-1a')
+    assert exc_info.value.args[0] == 'user-1a'
+    with pytest.raises(InvalidActor) as exc_info:
+        parse_actor('imaginary-12')
+    assert exc_info.value.args[0] == 'imaginary-12'
+
+
 def test_git_hook_format_changes():
     changes = {'branch/newbr': (ZERO_SHA, "beef1234dead" + "a" * 8),
                'topic/default/zz': ("23cafe45" + "0" * 12, "1234" + "b" * 16),
@@ -158,6 +177,29 @@
     assert params['gl_repository'] == 'project-1024'
 
 
+def test_pre_receive_params(tmpdir):
+    wrapper = make_repo(tmpdir, 'proj.hg')
+    repo = wrapper.repo
+    hook = Hook('post-receive', repo)
+
+    repo.ui.environ.update(
+        HEPTAPOD_USERINFO_ID='2',
+        HEPTAPOD_PROJECT_ID='3145926',
+    )
+    changes = {
+        'topic/default/with-a-dash': (ZERO_SHA, "beef1234dead" + "a" * 8),
+        'branch/default': ("23cafe45" + "0" * 12, "1234" + "b" * 16),
+    }
+    params = hook.pre_receive_params(changes)
+
+    assert params['user_id'] == '2'
+    assert params['changes'].splitlines() == [
+        '23cafe45000000000000 1234bbbbbbbbbbbbbbbb branch/default',
+        '00000000000000000000 beef1234deadaaaaaaaa topic/default/with-a-dash']
+    assert params['gl_repository'] == 'project-3145926'
+    assert params['project'] == repo.root
+
+
 def test_post_receive_handle_response(tmpdir):
     wrapper = make_repo(tmpdir, 'proj.hg')
     repo = wrapper.repo
@@ -256,6 +298,54 @@
         'merge_requests/1']
 
 
+def test_integration_pre_receive(tmpdir, monkeypatch):
+    records = []
+    responses = [make_json_response({u'message': "yes, granted!",
+                                     u'status': True,
+                                     },
+                                    status_code=200),
+                 make_json_response({u'status': True},
+                                    status_code=200),
+                 make_json_response({u'message': "go away!",
+                                     u'status': False,
+                                     },
+                                    status_code=401),
+                 make_response(504, b"Bad gateway :-("),
+                 ]
+    monkeypatch.setattr(Hook, 'gitlab_internal_api_request',
+                        request_recorder(records, responses))
+
+    repo_wrapper = make_repo(tmpdir, 'repo.hg')
+    repo = repo_wrapper.repo
+
+    repo.ui.environ.update(
+        HEPTAPOD_USERINFO_ID='23',
+        HEPTAPOD_PROJECT_ID='1024',
+    )
+
+    hook = Hook('pre-receive', repo_wrapper.repo)
+    changes = {
+        'branch/newbr': (ZERO_SHA, "beef1234dead" + "a" * 8),
+        'topic/default/zz': ("23cafe45" + "0" * 12, "1234" + "b" * 16),
+    }
+
+    return_code, out, err = hook(changes)
+    assert return_code == 0
+    assert out == 'yes, granted!'
+
+    return_code, out, err = hook(changes)
+    assert return_code == 0
+    assert out == ''
+
+    return_code, out, err = hook(changes)
+    assert return_code == 1
+    assert err == 'go away!'
+
+    return_code, out, err = hook(changes)
+    assert return_code == 1
+    assert err == 'API is not accessible'
+
+
 def test_empty_changes(tmpdir):
     hook = Hook('post-receive', make_repo(tmpdir, 'repo.hg').repo)
     code, out, err = hook(())