diff --git a/requirements.txt b/requirements.txt index 9769ddb3..b741b4f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ redis==2.10.3 Unidecode==0.04.16 raven==5.1.1 bleach==1.4 +django-ipware==0.1.0 # Comment it if you are using python >= 3.4 enum34==1.0 diff --git a/settings/common.py b/settings/common.py index d45a5de5..81a91568 100644 --- a/settings/common.py +++ b/settings/common.py @@ -194,7 +194,9 @@ INSTALLED_APPS = [ "taiga.mdrender", "taiga.export_import", "taiga.feedback", - "taiga.github_hook", + "taiga.hooks.github", + "taiga.hooks.gitlab", + "taiga.hooks.bitbucket", "rest_framework", "djmail", @@ -352,9 +354,13 @@ 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", + "bitbucket": "taiga.hooks.bitbucket.services.get_or_generate_config", } +BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166"] +GITLAB_VALID_ORIGIN_IPS = [] # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" 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..105d0189 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,16 @@ class GitHubViewSet(GenericViewSet): except Project.DoesNotExist: return None + def _get_payload(self, request): + try: + payload = json.loads(request.body.decode("utf-8")) + except ValueError: + raise exc.BadRequest(_("The payload is not a valid json")) + return payload + + def _get_event_name(self, request): + raise NotImplemented + def create(self, request, *args, **kwargs): project = self._get_project(request) if not project: @@ -77,12 +63,9 @@ 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")) - except ValueError: - raise exc.BadRequest(_("The payload is not a valid json")) + payload = self._get_payload(request) event_hook_class = self.event_hook_classes.get(event_name, None) if event_hook_class is not None: diff --git a/taiga/github_hook/migrations/__init__.py b/taiga/hooks/bitbucket/__init__.py similarity index 100% rename from taiga/github_hook/migrations/__init__.py rename to taiga/hooks/bitbucket/__init__.py diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py new file mode 100644 index 00000000..cb1c75e8 --- /dev/null +++ b/taiga/hooks/bitbucket/api.py @@ -0,0 +1,80 @@ +# 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 django.conf import settings + +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 +from ..exceptions import ActionSyntaxException + +from urllib.parse import parse_qs +from ipware.ip import get_real_ip + + +class BitBucketViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + } + + def _get_payload(self, request): + try: + body = parse_qs(request.body.decode("utf-8"), strict_parsing=True) + payload = body["payload"] + except (ValueError, KeyError): + raise exc.BadRequest(_("The payload is not a valid application/x-www-form-urlencoded")) + + return payload + + 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("bitbucket", {}).get("secret", "") + if not project_secret: + return False + + valid_origin_ips = project.modules_config.config.get("bitbucket", {}).get("valid_origin_ips", settings.BITBUCKET_VALID_ORIGIN_IPS) + origin_ip = get_real_ip(request) + if valid_origin_ips and (not origin_ip or not origin_ip in valid_origin_ips): + 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): + return "push" diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py new file mode 100644 index 00000000..a149923d --- /dev/null +++ b/taiga/hooks/bitbucket/event_hooks.py @@ -0,0 +1,102 @@ +# 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.base import exceptions as exc +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_bitbucket_user + +import json + +class PushEventHook(BaseEventHook): + def process_event(self): + if self.payload is None: + return + + # In bitbucket the payload is a list! :( + for payload_element_text in self.payload: + try: + payload_element = json.loads(payload_element_text) + except ValueError: + raise exc.BadRequest(_("The payload is not valid")) + + commits = payload_element.get("commits", []) + for commit in commits: + message = commit.get("message", None) + self._process_message(message, None) + + def _process_message(self, message, bitbucket_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, bitbucket_user) + + def _change_status(self, ref, status_slug, bitbucket_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 BitBucket commit", + user=get_bitbucket_user(bitbucket_user)) + send_notifications(element, history=snapshot) + + +def replace_bitbucket_references(project_url, wiki_text): + template = "\g<1>[BitBucket#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url) + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) diff --git a/taiga/hooks/bitbucket/migrations/0001_initial.py b/taiga/hooks/bitbucket/migrations/0001_initial.py new file mode 100644 index 00000000..372d93bb --- /dev/null +++ b/taiga/hooks/bitbucket/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="bitbucket-{}".format(random_hash), + email="bitbucket-{}@taiga.io".format(random_hash), + full_name="BitBucket", + is_active=False, + is_system=True, + bio="", + ) + f = open("taiga/hooks/bitbucket/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/bitbucket/migrations/__init__.py b/taiga/hooks/bitbucket/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/bitbucket/migrations/logo.png b/taiga/hooks/bitbucket/migrations/logo.png new file mode 100644 index 00000000..fbc456a7 Binary files /dev/null and b/taiga/hooks/bitbucket/migrations/logo.png differ diff --git a/taiga/github_hook/models.py b/taiga/hooks/bitbucket/models.py similarity index 100% rename from taiga/github_hook/models.py rename to taiga/hooks/bitbucket/models.py diff --git a/taiga/hooks/bitbucket/services.py b/taiga/hooks/bitbucket/services.py new file mode 100644 index 00000000..bcb74f56 --- /dev/null +++ b/taiga/hooks/bitbucket/services.py @@ -0,0 +1,55 @@ +# 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 django.conf import settings + +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 "bitbucket" in config: + g_config = project.modules_config.config["bitbucket"] + else: + g_config = { + "secret": uuid.uuid4().hex, + "valid_origin_ips": settings.BITBUCKET_VALID_ORIGIN_IPS, + } + + url = reverse("bitbucket-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s&key=%s"%(url, project.id, g_config["secret"]) + g_config["webhooks_url"] = url + return g_config + + +def get_bitbucket_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="bitbucket") + + return user 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/hooks/github/__init__.py b/taiga/hooks/github/__init__.py new file mode 100644 index 00000000..e69de29b 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/hooks/github/models.py b/taiga/hooks/github/models.py new file mode 100644 index 00000000..fca83d73 --- /dev/null +++ b/taiga/hooks/github/models.py @@ -0,0 +1 @@ +# This file is needed to load migrations 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..a7596910 --- /dev/null +++ b/taiga/hooks/gitlab/api.py @@ -0,0 +1,71 @@ +# 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 django.conf import settings + +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 + +from ipware.ip import get_real_ip + + +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 + + valid_origin_ips = project.modules_config.config.get("gitlab", {}).get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS) + origin_ip = get_real_ip(request) + if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips): + 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 00000000..bd90452a Binary files /dev/null and b/taiga/hooks/gitlab/migrations/logo.png differ 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..2d99969a --- /dev/null +++ b/taiga/hooks/gitlab/services.py @@ -0,0 +1,55 @@ +# 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 django.conf import settings + +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, + "valid_origin_ips": settings.GITLAB_VALID_ORIGIN_IPS, + } + + url = reverse("gitlab-hook-list") + url = get_absolute_url(url) + url = "{}?project={}&key={}".format(url, project.id, g_config["secret"]) + 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..0b6ffba2 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -132,8 +132,16 @@ 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") +# Gitlab webhooks +from taiga.hooks.gitlab.api import GitLabViewSet +router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook") + +# Bitbucket webhooks +from taiga.hooks.bitbucket.api import BitBucketViewSet +router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook") + # feedback # - see taiga.feedback.routers and taiga.feedback.apps diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index 7e6db573..df172a1a 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -103,7 +103,7 @@ def test_user_list(client, data): response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 4 + assert len(users_data) == 6 assert response.status_code == 200 diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py new file mode 100644 index 00000000..2fd53059 --- /dev/null +++ b/tests/integration/test_hooks_bitbucket.py @@ -0,0 +1,269 @@ +import pytest +import json +import urllib + +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core import mail +from django.conf import settings + +from taiga.hooks.bitbucket import event_hooks +from taiga.hooks.bitbucket.api import BitBucketViewSet +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={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = {} + response = client.post(url, urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded") + 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={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR=settings.BITBUCKET_VALID_ORIGIN_IPS[0]) + assert response.status_code == 200 + +def test_invalid_ip(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 400 + + +def test_not_ip_filter(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [] + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 200 + + +def test_push_event_detected(client): + project=f.ProjectFactory() + url = reverse("bitbucket-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {'payload': ['{"commits": [{"message": "test message"}]}']} + + BitBucketViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded") + + 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_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 "bitbucket" in content + assert content["bitbucket"]["secret"] != "" + assert content["bitbucket"]["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 = { + "bitbucket": { + "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 "bitbucket" in config + assert config["bitbucket"]["secret"] == "test_secret" + assert config["bitbucket"]["webhooks_url"] != "test_url" + +def test_replace_bitbucket_references(): + assert event_hooks.replace_bitbucket_references("project-url", "#2") == "[BitBucket#2](project-url/issues/2)" + assert event_hooks.replace_bitbucket_references("project-url", "#2 ") == "[BitBucket#2](project-url/issues/2) " + assert event_hooks.replace_bitbucket_references("project-url", " #2 ") == " [BitBucket#2](project-url/issues/2) " + assert event_hooks.replace_bitbucket_references("project-url", " #2") == " [BitBucket#2](project-url/issues/2)" + assert event_hooks.replace_bitbucket_references("project-url", "#test") == "#test" 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..adf3970c --- /dev/null +++ b/tests/integration/test_hooks_gitlab.py @@ -0,0 +1,385 @@ +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", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + 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", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 200 + + +def test_invalid_ip(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + 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", + REMOTE_ADDR="111.111.111.112") + + assert response.status_code == 400 + + +def test_not_ip_filter(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [], + } + }) + + 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", + REMOTE_ADDR="111.111.111.111") + + 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"