From 3e46b439bf48fa4c248585b14d03b172de20099a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 27 Nov 2014 18:56:54 +0100 Subject: [PATCH] Bitbucket webhooks for commits --- requirements.txt | 1 + settings/common.py | 3 + taiga/hooks/bitbucket/__init__.py | 0 taiga/hooks/bitbucket/api.py | 96 ++++++++ taiga/hooks/bitbucket/event_hooks.py | 102 ++++++++ .../bitbucket/migrations/0001_initial.py | 36 +++ taiga/hooks/bitbucket/migrations/__init__.py | 0 taiga/hooks/bitbucket/migrations/logo.png | Bin 0 -> 7847 bytes taiga/hooks/bitbucket/models.py | 1 + taiga/hooks/bitbucket/services.py | 55 +++++ taiga/routers.py | 5 + .../test_users_resources.py | 2 +- tests/integration/test_hooks_bitbucket.py | 233 ++++++++++++++++++ 13 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 taiga/hooks/bitbucket/__init__.py create mode 100644 taiga/hooks/bitbucket/api.py create mode 100644 taiga/hooks/bitbucket/event_hooks.py create mode 100644 taiga/hooks/bitbucket/migrations/0001_initial.py create mode 100644 taiga/hooks/bitbucket/migrations/__init__.py create mode 100644 taiga/hooks/bitbucket/migrations/logo.png create mode 100644 taiga/hooks/bitbucket/models.py create mode 100644 taiga/hooks/bitbucket/services.py create mode 100644 tests/integration/test_hooks_bitbucket.py 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 0fcacf82..d57f14c3 100644 --- a/settings/common.py +++ b/settings/common.py @@ -196,6 +196,7 @@ INSTALLED_APPS = [ "taiga.feedback", "taiga.hooks.github", "taiga.hooks.gitlab", + "taiga.hooks.bitbucket", "rest_framework", "djmail", @@ -355,8 +356,10 @@ CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds PROJECT_MODULES_CONFIGURATORS = { "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"] # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/taiga/hooks/bitbucket/__init__.py b/taiga/hooks/bitbucket/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py new file mode 100644 index 00000000..cd992036 --- /dev/null +++ b/taiga/hooks/bitbucket/api.py @@ -0,0 +1,96 @@ +# 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 create(self, request, *args, **kwargs): + project = self._get_project(request) + if not project: + raise exc.BadRequest(_("The project doesn't exist")) + + if not self._validate_signature(project, request): + raise exc.BadRequest(_("Bad signature")) + + event_name = self._get_event_name(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")) + + event_hook_class = self.event_hook_classes.get(event_name, None) + if event_hook_class is not None: + event_hook = event_hook_class(project, payload) + try: + event_hook.process_event() + except ActionSyntaxException as e: + raise exc.BadRequest(e) + + return Response({}) + + 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 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 0000000000000000000000000000000000000000..fbc456a766ccddb815a6371d1933cddad875d359 GIT binary patch literal 7847 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 +# 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/routers.py b/taiga/routers.py index f5959d14..0b6ffba2 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -135,8 +135,13 @@ router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notification 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..5ac6c88b --- /dev/null +++ b/tests/integration/test_hooks_bitbucket.py @@ -0,0 +1,233 @@ +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_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"