diff --git a/settings/common.py b/settings/common.py index 66fbb67f..333d310a 100644 --- a/settings/common.py +++ b/settings/common.py @@ -313,6 +313,7 @@ INSTALLED_APPS = [ "taiga.hooks.github", "taiga.hooks.gitlab", "taiga.hooks.bitbucket", + "taiga.hooks.gogs", "taiga.webhooks", "djmail", @@ -506,6 +507,7 @@ 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", + "gogs": "taiga.hooks.gogs.services.get_or_generate_config", } BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166", "104.192.143.192/28", "104.192.143.208/28"] diff --git a/taiga/hooks/gogs/__init__.py b/taiga/hooks/gogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gogs/api.py b/taiga/hooks/gogs/api.py new file mode 100644 index 00000000..ced551de --- /dev/null +++ b/taiga/hooks/gogs/api.py @@ -0,0 +1,44 @@ +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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 taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + + +class GogsViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook + } + + def _validate_signature(self, project, request): + payload = self._get_payload(request) + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + secret = project.modules_config.config.get("gogs", {}).get("secret", None) + if secret is None: + return False + + return payload.get('secret', None) == secret + + def _get_event_name(self, request): + return "push" diff --git a/taiga/hooks/gogs/event_hooks.py b/taiga/hooks/gogs/event_hooks.py new file mode 100644 index 00000000..8e68b8db --- /dev/null +++ b/taiga/hooks/gogs/event_hooks.py @@ -0,0 +1,52 @@ +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.path + +from taiga.hooks.event_hooks import BasePushEventHook + + +class BaseGogsEventHook(): + platform = "Gogs" + platform_slug = "gogs" + + def replace_gogs_references(self, project_url, wiki_text): + if wiki_text is None: + wiki_text = "" + + template = "\g<1>[Gogs#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url) + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class PushEventHook(BaseGogsEventHook, BasePushEventHook): + def get_data(self): + result = [] + commits = self.payload.get("commits", []) + project_url = self.payload.get("repository", {}).get("url", None) + + for commit in filter(None, commits): + user_name = commit.get('author', {}).get('username', None) + result.append({ + "user_id": user_name, + "user_name": user_name, + "user_url": os.path.join(os.path.dirname(os.path.dirname(project_url)), user_name), + "commit_id": commit.get("id", None), + "commit_url": commit.get("url", None), + "commit_message": commit.get("message", None), + }) + return result diff --git a/taiga/hooks/gogs/migrations/0001_initial.py b/taiga/hooks/gogs/migrations/0001_initial.py new file mode 100644 index 00000000..09ba6709 --- /dev/null +++ b/taiga/hooks/gogs/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.files import File + +import uuid +import os + +CUR_DIR = os.path.dirname(__file__) + + +def create_gogs_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="gogs-{}".format(random_hash), + email="gogs-{}@taiga.io".format(random_hash), + full_name="Gogs", + is_active=False, + is_system=True, + bio="", + ) + f = open("{}/logo.png".format(CUR_DIR), "rb") + user.photo.save("logo.png", File(f)) + user.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0010_auto_20150414_0936') + ] + + operations = [ + migrations.RunPython(create_gogs_system_user), + ] diff --git a/taiga/hooks/gogs/migrations/__init__.py b/taiga/hooks/gogs/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gogs/migrations/logo.png b/taiga/hooks/gogs/migrations/logo.png new file mode 100644 index 00000000..384a58d2 Binary files /dev/null and b/taiga/hooks/gogs/migrations/logo.png differ diff --git a/taiga/hooks/gogs/models.py b/taiga/hooks/gogs/models.py new file mode 100644 index 00000000..fca83d73 --- /dev/null +++ b/taiga/hooks/gogs/models.py @@ -0,0 +1 @@ +# This file is needed to load migrations diff --git a/taiga/hooks/gogs/services.py b/taiga/hooks/gogs/services.py new file mode 100644 index 00000000..40d06fab --- /dev/null +++ b/taiga/hooks/gogs/services.py @@ -0,0 +1,37 @@ +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# 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.base.utils.urls import get_absolute_url + + +# Set this in settings.PROJECT_MODULES_CONFIGURATORS["gogs"] +def get_or_generate_config(project): + config = project.modules_config.config + if config and "gogs" in config: + g_config = project.modules_config.config["gogs"] + else: + g_config = {"secret": uuid.uuid4().hex} + + url = reverse("gogs-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s" % (url, project.id) + g_config["webhooks_url"] = url + return g_config diff --git a/taiga/routers.py b/taiga/routers.py index 66e1b9f7..24974b74 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -208,6 +208,12 @@ from taiga.hooks.bitbucket.api import BitBucketViewSet router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook") +# Gogs webhooks +from taiga.hooks.gogs.api import GogsViewSet + +router.register(r"gogs-hook", GogsViewSet, base_name="gogs-hook") + + # Importer from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet diff --git a/tests/integration/test_hooks_gogs.py b/tests/integration/test_hooks_gogs.py new file mode 100644 index 00000000..290bc3f8 --- /dev/null +++ b/tests/integration/test_hooks_gogs.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# 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 pytest + +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core import mail + +from taiga.base.utils import json +from taiga.hooks.gogs import event_hooks +from taiga.hooks.gogs.api import GogsViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects import choices as project_choices +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() + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = { + "secret": "badbadbad" + } + response = client.post(url, json.dumps(data), + content_type="application/json") + response_content = response.data + 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={ + "gogs": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data", "secret": "tpnIwJDz4e"} + response = client.post(url, json.dumps(data), + content_type="application/json") + + assert response.status_code == 204 + + +def test_blocked_project(client): + project = f.ProjectFactory(blocked_code=project_choices.BLOCKED_BY_STAFF) + f.ProjectModulesConfigFactory(project=project, config={ + "gogs": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data", "secret": "tpnIwJDz4e"} + response = client.post(url, json.dumps(data), + content_type="application/json") + + assert response.status_code == 451 + + +def test_push_event_detected(client): + project = f.ProjectFactory() + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = { + "commits": [ + { + "message": "test message", + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + + GogsViewSet._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 == 204 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + 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"]) + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + 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"]) + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + + 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_issue_mention(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(issue, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (issue.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert issue_history[0].comment.startswith("This issue has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_task_mention(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(task, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (task.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment.startswith("This task has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_mention(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(user_story, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (user_story.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + us_history = get_history_queryset_by_model_instance(user_story) + assert us_history.count() == 1 + assert us_history[0].comment.startswith("This user story has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_multiple_actions(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + issue2 = 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 + test TG-%s #%s ok + bye! + """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook1 = event_hooks.PushEventHook(issue1.project, payload) + ev_hook1.process_event() + issue1 = Issue.objects.get(id=issue1.id) + issue2 = Issue.objects.get(id=issue2.id) + assert issue1.status.id == new_status.id + assert issue2.status.id == new_status.id + assert len(mail.outbox) == 2 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + 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()), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + + 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), + "author": { + "username": "test", + }, + } + ], + "repository": { + "url": "http://test-url/test/project" + } + } + + 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() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = response.data + assert "gogs" in content + assert content["gogs"]["secret"] != "" + assert content["gogs"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "gogs": { + "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 "gogs" in config + assert config["gogs"]["secret"] == "test_secret" + assert config["gogs"]["webhooks_url"] != "test_url" + + +def test_replace_gogs_references(): + ev_hook = event_hooks.BaseGogsEventHook + assert ev_hook.replace_gogs_references(None, "project-url", "#2") == "[Gogs#2](project-url/issues/2)" + assert ev_hook.replace_gogs_references(None, "project-url", "#2 ") == "[Gogs#2](project-url/issues/2) " + assert ev_hook.replace_gogs_references(None, "project-url", " #2 ") == " [Gogs#2](project-url/issues/2) " + assert ev_hook.replace_gogs_references(None, "project-url", " #2") == " [Gogs#2](project-url/issues/2)" + assert ev_hook.replace_gogs_references(None, "project-url", "#test") == "#test" + assert ev_hook.replace_gogs_references(None, "project-url", None) == ""