From d974befd6c74620c5ebe00570be0e696558c8c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 26 Nov 2014 12:55:20 +0100 Subject: [PATCH] Gitlab integration --- settings/common.py | 6 +- taiga/{github_hook => hooks}/__init__.py | 0 taiga/{github_hook => hooks}/api.py | 35 +- taiga/hooks/event_hooks.py | 24 ++ taiga/{github_hook => hooks}/exceptions.py | 0 .../migrations => hooks/github}/__init__.py | 0 taiga/hooks/github/api.py | 59 +++ .../github}/event_hooks.py | 12 +- .../github}/migrations/0001_initial.py | 2 +- taiga/hooks/github/migrations/__init__.py | 0 .../github}/migrations/logo.png | Bin taiga/{github_hook => hooks/github}/models.py | 0 .../{github_hook => hooks/github}/services.py | 0 taiga/hooks/gitlab/__init__.py | 0 taiga/hooks/gitlab/api.py | 63 ++++ taiga/hooks/gitlab/event_hooks.py | 127 +++++++ taiga/hooks/gitlab/migrations/0001_initial.py | 36 ++ taiga/hooks/gitlab/migrations/__init__.py | 0 taiga/hooks/gitlab/migrations/logo.png | Bin 0 -> 51201 bytes taiga/hooks/gitlab/models.py | 1 + taiga/hooks/gitlab/services.py | 51 +++ taiga/routers.py | 5 +- ...st_github_hook.py => test_hooks_github.py} | 6 +- tests/integration/test_hooks_gitlab.py | 341 ++++++++++++++++++ 24 files changed, 723 insertions(+), 45 deletions(-) rename taiga/{github_hook => hooks}/__init__.py (100%) rename taiga/{github_hook => hooks}/api.py (72%) create mode 100644 taiga/hooks/event_hooks.py rename taiga/{github_hook => hooks}/exceptions.py (100%) rename taiga/{github_hook/migrations => hooks/github}/__init__.py (100%) create mode 100644 taiga/hooks/github/api.py rename taiga/{github_hook => hooks/github}/event_hooks.py (95%) rename taiga/{github_hook => hooks/github}/migrations/0001_initial.py (93%) create mode 100644 taiga/hooks/github/migrations/__init__.py rename taiga/{github_hook => hooks/github}/migrations/logo.png (100%) rename taiga/{github_hook => hooks/github}/models.py (100%) rename taiga/{github_hook => hooks/github}/services.py (100%) create mode 100644 taiga/hooks/gitlab/__init__.py create mode 100644 taiga/hooks/gitlab/api.py create mode 100644 taiga/hooks/gitlab/event_hooks.py create mode 100644 taiga/hooks/gitlab/migrations/0001_initial.py create mode 100644 taiga/hooks/gitlab/migrations/__init__.py create mode 100644 taiga/hooks/gitlab/migrations/logo.png create mode 100644 taiga/hooks/gitlab/models.py create mode 100644 taiga/hooks/gitlab/services.py rename tests/integration/{test_github_hook.py => test_hooks_github.py} (99%) create mode 100644 tests/integration/test_hooks_gitlab.py diff --git a/settings/common.py b/settings/common.py index d45a5de5..0fcacf82 100644 --- a/settings/common.py +++ b/settings/common.py @@ -194,7 +194,8 @@ INSTALLED_APPS = [ "taiga.mdrender", "taiga.export_import", "taiga.feedback", - "taiga.github_hook", + "taiga.hooks.github", + "taiga.hooks.gitlab", "rest_framework", "djmail", @@ -352,7 +353,8 @@ CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds # List of functions called for filling correctly the ProjectModulesConfig associated to a project # This functions should receive a Project parameter and return a dict with the desired configuration PROJECT_MODULES_CONFIGURATORS = { - "github": "taiga.github_hook.services.get_or_generate_config", + "github": "taiga.hooks.github.services.get_or_generate_config", + "gitlab": "taiga.hooks.gitlab.services.get_or_generate_config", } diff --git a/taiga/github_hook/__init__.py b/taiga/hooks/__init__.py similarity index 100% rename from taiga/github_hook/__init__.py rename to taiga/hooks/__init__.py diff --git a/taiga/github_hook/api.py b/taiga/hooks/api.py similarity index 72% rename from taiga/github_hook/api.py rename to taiga/hooks/api.py index 23c4a65a..f99b61de 100644 --- a/taiga/github_hook/api.py +++ b/taiga/hooks/api.py @@ -22,44 +22,20 @@ from taiga.base import exceptions as exc from taiga.base.utils import json from taiga.projects.models import Project -from . import event_hooks from .exceptions import ActionSyntaxException -import hmac -import hashlib - -class GitHubViewSet(GenericViewSet): +class BaseWebhookApiViewSet(GenericViewSet): # We don't want rest framework to parse the request body and transform it in # a dict in request.DATA, we need it raw parser_classes = () # This dict associates the event names we are listening for # with their reponsible classes (extending event_hooks.BaseEventHook) - event_hook_classes = { - "push": event_hooks.PushEventHook, - "issues": event_hooks.IssuesEventHook, - "issue_comment": event_hooks.IssueCommentEventHook, - } + event_hook_classes = {} def _validate_signature(self, project, request): - x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None) - if not x_hub_signature: - return False - - sha_name, signature = x_hub_signature.split('=') - if sha_name != 'sha1': - return False - - if not hasattr(project, "modules_config"): - return False - - if project.modules_config.config is None: - return False - - secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8")) - mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1) - return hmac.compare_digest(mac.hexdigest(), signature) + raise NotImplemented def _get_project(self, request): project_id = request.GET.get("project", None) @@ -69,6 +45,9 @@ class GitHubViewSet(GenericViewSet): except Project.DoesNotExist: return None + def _get_event_name(self, request): + raise NotImplemented + def create(self, request, *args, **kwargs): project = self._get_project(request) if not project: @@ -77,7 +56,7 @@ class GitHubViewSet(GenericViewSet): if not self._validate_signature(project, request): raise exc.BadRequest(_("Bad signature")) - event_name = request.META.get("HTTP_X_GITHUB_EVENT", None) + event_name = self._get_event_name(request) try: payload = json.loads(request.body.decode("utf-8")) diff --git a/taiga/hooks/event_hooks.py b/taiga/hooks/event_hooks.py new file mode 100644 index 00000000..0d26be38 --- /dev/null +++ b/taiga/hooks/event_hooks.py @@ -0,0 +1,24 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +class BaseEventHook: + def __init__(self, project, payload): + self.project = project + self.payload = payload + + def process_event(self): + raise NotImplementedError("process_event must be overwritten") diff --git a/taiga/github_hook/exceptions.py b/taiga/hooks/exceptions.py similarity index 100% rename from taiga/github_hook/exceptions.py rename to taiga/hooks/exceptions.py diff --git a/taiga/github_hook/migrations/__init__.py b/taiga/hooks/github/__init__.py similarity index 100% rename from taiga/github_hook/migrations/__init__.py rename to taiga/hooks/github/__init__.py diff --git a/taiga/hooks/github/api.py b/taiga/hooks/github/api.py new file mode 100644 index 00000000..c0f32c16 --- /dev/null +++ b/taiga/hooks/github/api.py @@ -0,0 +1,59 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework.response import Response +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.api.viewsets import GenericViewSet +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +import hmac +import hashlib + + +class GitHubViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issues": event_hooks.IssuesEventHook, + "issue_comment": event_hooks.IssueCommentEventHook, + } + + def _validate_signature(self, project, request): + x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None) + if not x_hub_signature: + return False + + sha_name, signature = x_hub_signature.split('=') + if sha_name != 'sha1': + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8")) + mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1) + return hmac.compare_digest(mac.hexdigest(), signature) + + def _get_event_name(self, request): + return request.META.get("HTTP_X_GITHUB_EVENT", None) diff --git a/taiga/github_hook/event_hooks.py b/taiga/hooks/github/event_hooks.py similarity index 95% rename from taiga/github_hook/event_hooks.py rename to taiga/hooks/github/event_hooks.py index bf7745f0..d7231d72 100644 --- a/taiga/github_hook/event_hooks.py +++ b/taiga/hooks/github/event_hooks.py @@ -23,22 +23,14 @@ from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory from taiga.projects.history.services import take_snapshot from taiga.projects.notifications.services import send_notifications +from taiga.hooks.event_hooks import BaseEventHook +from taiga.hooks.exceptions import ActionSyntaxException -from .exceptions import ActionSyntaxException from .services import get_github_user import re -class BaseEventHook: - def __init__(self, project, payload): - self.project = project - self.payload = payload - - def process_event(self): - raise NotImplementedError("process_event must be overwritten") - - class PushEventHook(BaseEventHook): def process_event(self): if self.payload is None: diff --git a/taiga/github_hook/migrations/0001_initial.py b/taiga/hooks/github/migrations/0001_initial.py similarity index 93% rename from taiga/github_hook/migrations/0001_initial.py rename to taiga/hooks/github/migrations/0001_initial.py index fc98953d..75e43abf 100644 --- a/taiga/github_hook/migrations/0001_initial.py +++ b/taiga/hooks/github/migrations/0001_initial.py @@ -20,7 +20,7 @@ def create_github_system_user(apps, schema_editor): is_system=True, bio="", ) - f = open("taiga/github_hook/migrations/logo.png", "rb") + f = open("taiga/hooks/github/migrations/logo.png", "rb") user.photo.save("logo.png", File(f)) user.save() diff --git a/taiga/hooks/github/migrations/__init__.py b/taiga/hooks/github/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/github_hook/migrations/logo.png b/taiga/hooks/github/migrations/logo.png similarity index 100% rename from taiga/github_hook/migrations/logo.png rename to taiga/hooks/github/migrations/logo.png diff --git a/taiga/github_hook/models.py b/taiga/hooks/github/models.py similarity index 100% rename from taiga/github_hook/models.py rename to taiga/hooks/github/models.py diff --git a/taiga/github_hook/services.py b/taiga/hooks/github/services.py similarity index 100% rename from taiga/github_hook/services.py rename to taiga/hooks/github/services.py diff --git a/taiga/hooks/gitlab/__init__.py b/taiga/hooks/gitlab/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py new file mode 100644 index 00000000..59256b1b --- /dev/null +++ b/taiga/hooks/gitlab/api.py @@ -0,0 +1,63 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework.response import Response +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.api.viewsets import GenericViewSet +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + + +class GitLabViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issue": event_hooks.IssuesEventHook, + } + + def _validate_signature(self, project, request): + secret_key = request.GET.get("key", None) + + if secret_key is None: + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + project_secret = project.modules_config.config.get("gitlab", {}).get("secret", "") + if not project_secret: + return False + + return project_secret == secret_key + + def _get_project(self, request): + project_id = request.GET.get("project", None) + try: + project = Project.objects.get(id=project_id) + return project + except Project.DoesNotExist: + return None + + def _get_event_name(self, request): + payload = json.loads(request.body.decode("utf-8")) + return payload.get('object_kind', 'push') diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py new file mode 100644 index 00000000..3a84a20d --- /dev/null +++ b/taiga/hooks/gitlab/event_hooks.py @@ -0,0 +1,127 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import re +import os + +from django.utils.translation import ugettext_lazy as _ + +from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus + +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.history.services import take_snapshot +from taiga.projects.notifications.services import send_notifications +from taiga.hooks.event_hooks import BaseEventHook +from taiga.hooks.exceptions import ActionSyntaxException + +from .services import get_gitlab_user + + +class PushEventHook(BaseEventHook): + def process_event(self): + if self.payload is None: + return + + commits = self.payload.get("commits", []) + for commit in commits: + message = commit.get("message", None) + self._process_message(message, None) + + def _process_message(self, message, gitlab_user): + """ + The message we will be looking for seems like + TG-XX #yyyyyy + Where: + XX: is the ref for us, issue or task + yyyyyy: is the status slug we are setting + """ + if message is None: + return + + p = re.compile("tg-(\d+) +#([-\w]+)") + m = p.search(message.lower()) + if m: + ref = m.group(1) + status_slug = m.group(2) + self._change_status(ref, status_slug, gitlab_user) + + def _change_status(self, ref, status_slug, gitlab_user): + if Issue.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Issue + statusClass = IssueStatus + elif Task.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Task + statusClass = TaskStatus + elif UserStory.objects.filter(project=self.project, ref=ref).exists(): + modelClass = UserStory + statusClass = UserStoryStatus + else: + raise ActionSyntaxException(_("The referenced element doesn't exist")) + + element = modelClass.objects.get(project=self.project, ref=ref) + + try: + status = statusClass.objects.get(project=self.project, slug=status_slug) + except statusClass.DoesNotExist: + raise ActionSyntaxException(_("The status doesn't exist")) + + element.status = status + element.save() + + snapshot = take_snapshot(element, + comment="Status changed from GitLab commit", + user=get_gitlab_user(gitlab_user)) + send_notifications(element, history=snapshot) + + +def replace_gitlab_references(project_url, wiki_text): + template = "\g<1>[GitLab#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url) + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class IssuesEventHook(BaseEventHook): + def process_event(self): + if self.payload.get('object_attributes', {}).get("action", "") != "open": + return + + subject = self.payload.get('object_attributes', {}).get('title', None) + description = self.payload.get('object_attributes', {}).get('description', None) + gitlab_reference = self.payload.get('object_attributes', {}).get('url', None) + + project_url = None + if gitlab_reference: + project_url = os.path.basename(os.path.basename(gitlab_reference)) + + if not all([subject, gitlab_reference, project_url]): + raise ActionSyntaxException(_("Invalid issue information")) + + issue = Issue.objects.create( + project=self.project, + subject=subject, + description=replace_gitlab_references(project_url, description), + status=self.project.default_issue_status, + type=self.project.default_issue_type, + severity=self.project.default_severity, + priority=self.project.default_priority, + external_reference=['gitlab', gitlab_reference], + owner=get_gitlab_user(None) + ) + take_snapshot(issue, user=get_gitlab_user(None)) + + snapshot = take_snapshot(issue, comment="Created from GitLab", user=get_gitlab_user(None)) + send_notifications(issue, history=snapshot) diff --git a/taiga/hooks/gitlab/migrations/0001_initial.py b/taiga/hooks/gitlab/migrations/0001_initial.py new file mode 100644 index 00000000..683d3956 --- /dev/null +++ b/taiga/hooks/gitlab/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.files import File + +import uuid + +def create_github_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="gitlab-{}".format(random_hash), + email="gitlab-{}@taiga.io".format(random_hash), + full_name="GitLab", + is_active=False, + is_system=True, + bio="", + ) + f = open("taiga/hooks/gitlab/migrations/logo.png", "rb") + user.photo.save("logo.png", File(f)) + user.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132') + ] + + operations = [ + migrations.RunPython(create_github_system_user), + ] diff --git a/taiga/hooks/gitlab/migrations/__init__.py b/taiga/hooks/gitlab/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gitlab/migrations/logo.png b/taiga/hooks/gitlab/migrations/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bd90452af227795c4db7c52da9c12ddf4d64e347 GIT binary patch literal 51201 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 P0KibsM7LVo>EZtYgv}eH literal 0 HcmV?d00001 diff --git a/taiga/hooks/gitlab/models.py b/taiga/hooks/gitlab/models.py new file mode 100644 index 00000000..fca83d73 --- /dev/null +++ b/taiga/hooks/gitlab/models.py @@ -0,0 +1 @@ +# This file is needed to load migrations diff --git a/taiga/hooks/gitlab/services.py b/taiga/hooks/gitlab/services.py new file mode 100644 index 00000000..340420a1 --- /dev/null +++ b/taiga/hooks/gitlab/services.py @@ -0,0 +1,51 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import uuid + +from django.core.urlresolvers import reverse + +from taiga.users.models import User +from taiga.base.utils.urls import get_absolute_url + + +def get_or_generate_config(project): + config = project.modules_config.config + if config and "gitlab" in config: + g_config = project.modules_config.config["gitlab"] + else: + g_config = {"secret": uuid.uuid4().hex } + + url = reverse("gitlab-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s"%(url, project.id) + g_config["webhooks_url"] = url + return g_config + + +def get_gitlab_user(user_email): + user = None + + if user_email: + try: + user = User.objects.get(email=user_email) + except User.DoesNotExist: + pass + + if user is None: + user = User.objects.get(is_system=True, username__startswith="gitlab") + + return user diff --git a/taiga/routers.py b/taiga/routers.py index ac8c6da8..f5959d14 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -132,8 +132,11 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") # GitHub webhooks -from taiga.github_hook.api import GitHubViewSet +from taiga.hooks.github.api import GitHubViewSet router.register(r"github-hook", GitHubViewSet, base_name="github-hook") +from taiga.hooks.gitlab.api import GitLabViewSet +router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook") + # feedback # - see taiga.feedback.routers and taiga.feedback.apps diff --git a/tests/integration/test_github_hook.py b/tests/integration/test_hooks_github.py similarity index 99% rename from tests/integration/test_github_hook.py rename to tests/integration/test_hooks_github.py index da3d6ca6..bd5103ae 100644 --- a/tests/integration/test_github_hook.py +++ b/tests/integration/test_hooks_github.py @@ -6,9 +6,9 @@ from unittest import mock from django.core.urlresolvers import reverse from django.core import mail -from taiga.github_hook.api import GitHubViewSet -from taiga.github_hook import event_hooks -from taiga.github_hook.exceptions import ActionSyntaxException +from taiga.hooks.github import event_hooks +from taiga.hooks.github.api import GitHubViewSet +from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py new file mode 100644 index 00000000..f8083676 --- /dev/null +++ b/tests/integration/test_hooks_gitlab.py @@ -0,0 +1,341 @@ +import pytest +import json + +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core import mail + +from taiga.hooks.gitlab import event_hooks +from taiga.hooks.gitlab.api import GitLabViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + +def test_bad_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = {} + response = client.post(url, json.dumps(data), content_type="application/json") + response_content = json.loads(response.content.decode("utf-8")) + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 200 + + +def test_push_event_detected(client): + project=f.ProjectFactory() + url = reverse("gitlab-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {"commits": [ + {"message": "test message"}, + ]} + + GitLabViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, json.dumps(data), + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 200 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(issue.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(task.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(user_story.ref, new_status.slug)}, + ]} + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test tg-%s #%s ok + bye! + """%(task.ref, new_status.slug.upper())}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = {"commits": [ + {"message": """test message + test TG-6666666 #%s ok + bye! + """%(issue_status.slug)}, + ]} + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """%(user_story.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """%(issue.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_issues_event_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_owner=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.watch + notify_policy.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "title": "test-title", + "description": "test-body", + "url": "http://gitlab.com/test/project/issues/11", + "action": "open", + }, + } + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + +def test_issues_event_other_than_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "title": "test-title", + "description": "test-body", + "url": "http://gitlab.com/test/project/issues/11", + "action": "update", + }, + } + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "action": "open", + }, + } + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + + +def test_api_get_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = json.loads(response.content.decode("utf-8")) + assert "gitlab" in content + assert content["gitlab"]["secret"] != "" + assert content["gitlab"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "gitlab": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "gitlab" in config + assert config["gitlab"]["secret"] == "test_secret" + assert config["gitlab"]["webhooks_url"] != "test_url" + +def test_replace_gitlab_references(): + assert event_hooks.replace_gitlab_references("project-url", "#2") == "[GitLab#2](project-url/issues/2)" + assert event_hooks.replace_gitlab_references("project-url", "#2 ") == "[GitLab#2](project-url/issues/2) " + assert event_hooks.replace_gitlab_references("project-url", " #2 ") == " [GitLab#2](project-url/issues/2) " + assert event_hooks.replace_gitlab_references("project-url", " #2") == " [GitLab#2](project-url/issues/2)" + assert event_hooks.replace_gitlab_references("project-url", "#test") == "#test"