Gitlab integration
parent
50ad5046e6
commit
d974befd6c
|
@ -194,7 +194,8 @@ INSTALLED_APPS = [
|
||||||
"taiga.mdrender",
|
"taiga.mdrender",
|
||||||
"taiga.export_import",
|
"taiga.export_import",
|
||||||
"taiga.feedback",
|
"taiga.feedback",
|
||||||
"taiga.github_hook",
|
"taiga.hooks.github",
|
||||||
|
"taiga.hooks.gitlab",
|
||||||
|
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"djmail",
|
"djmail",
|
||||||
|
@ -352,7 +353,8 @@ CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds
|
||||||
# List of functions called for filling correctly the ProjectModulesConfig associated to a project
|
# 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
|
# This functions should receive a Project parameter and return a dict with the desired configuration
|
||||||
PROJECT_MODULES_CONFIGURATORS = {
|
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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -22,44 +22,20 @@ from taiga.base import exceptions as exc
|
||||||
from taiga.base.utils import json
|
from taiga.base.utils import json
|
||||||
from taiga.projects.models import Project
|
from taiga.projects.models import Project
|
||||||
|
|
||||||
from . import event_hooks
|
|
||||||
from .exceptions import ActionSyntaxException
|
from .exceptions import ActionSyntaxException
|
||||||
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
class BaseWebhookApiViewSet(GenericViewSet):
|
||||||
class GitHubViewSet(GenericViewSet):
|
|
||||||
# We don't want rest framework to parse the request body and transform it in
|
# We don't want rest framework to parse the request body and transform it in
|
||||||
# a dict in request.DATA, we need it raw
|
# a dict in request.DATA, we need it raw
|
||||||
parser_classes = ()
|
parser_classes = ()
|
||||||
|
|
||||||
# This dict associates the event names we are listening for
|
# This dict associates the event names we are listening for
|
||||||
# with their reponsible classes (extending event_hooks.BaseEventHook)
|
# with their reponsible classes (extending event_hooks.BaseEventHook)
|
||||||
event_hook_classes = {
|
event_hook_classes = {}
|
||||||
"push": event_hooks.PushEventHook,
|
|
||||||
"issues": event_hooks.IssuesEventHook,
|
|
||||||
"issue_comment": event_hooks.IssueCommentEventHook,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _validate_signature(self, project, request):
|
def _validate_signature(self, project, request):
|
||||||
x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None)
|
raise NotImplemented
|
||||||
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_project(self, request):
|
def _get_project(self, request):
|
||||||
project_id = request.GET.get("project", None)
|
project_id = request.GET.get("project", None)
|
||||||
|
@ -69,6 +45,9 @@ class GitHubViewSet(GenericViewSet):
|
||||||
except Project.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_event_name(self, request):
|
||||||
|
raise NotImplemented
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
project = self._get_project(request)
|
project = self._get_project(request)
|
||||||
if not project:
|
if not project:
|
||||||
|
@ -77,7 +56,7 @@ class GitHubViewSet(GenericViewSet):
|
||||||
if not self._validate_signature(project, request):
|
if not self._validate_signature(project, request):
|
||||||
raise exc.BadRequest(_("Bad signature"))
|
raise exc.BadRequest(_("Bad signature"))
|
||||||
|
|
||||||
event_name = request.META.get("HTTP_X_GITHUB_EVENT", None)
|
event_name = self._get_event_name(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(request.body.decode("utf-8"))
|
payload = json.loads(request.body.decode("utf-8"))
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEventHook:
|
||||||
|
def __init__(self, project, payload):
|
||||||
|
self.project = project
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def process_event(self):
|
||||||
|
raise NotImplementedError("process_event must be overwritten")
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
|
@ -23,22 +23,14 @@ from taiga.projects.tasks.models import Task
|
||||||
from taiga.projects.userstories.models import UserStory
|
from taiga.projects.userstories.models import UserStory
|
||||||
from taiga.projects.history.services import take_snapshot
|
from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.notifications.services import send_notifications
|
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
|
from .services import get_github_user
|
||||||
|
|
||||||
import re
|
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):
|
class PushEventHook(BaseEventHook):
|
||||||
def process_event(self):
|
def process_event(self):
|
||||||
if self.payload is None:
|
if self.payload is None:
|
|
@ -20,7 +20,7 @@ def create_github_system_user(apps, schema_editor):
|
||||||
is_system=True,
|
is_system=True,
|
||||||
bio="",
|
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.photo.save("logo.png", File(f))
|
||||||
user.save()
|
user.save()
|
||||||
|
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
@ -0,0 +1,63 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from taiga.base.api.viewsets import GenericViewSet
|
||||||
|
from taiga.base import exceptions as exc
|
||||||
|
from taiga.base.utils import json
|
||||||
|
from taiga.projects.models import Project
|
||||||
|
from taiga.hooks.api import BaseWebhookApiViewSet
|
||||||
|
|
||||||
|
from . import event_hooks
|
||||||
|
|
||||||
|
|
||||||
|
class GitLabViewSet(BaseWebhookApiViewSet):
|
||||||
|
event_hook_classes = {
|
||||||
|
"push": event_hooks.PushEventHook,
|
||||||
|
"issue": event_hooks.IssuesEventHook,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_signature(self, project, request):
|
||||||
|
secret_key = request.GET.get("key", None)
|
||||||
|
|
||||||
|
if secret_key is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not hasattr(project, "modules_config"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if project.modules_config.config is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
project_secret = project.modules_config.config.get("gitlab", {}).get("secret", "")
|
||||||
|
if not project_secret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return project_secret == secret_key
|
||||||
|
|
||||||
|
def _get_project(self, request):
|
||||||
|
project_id = request.GET.get("project", None)
|
||||||
|
try:
|
||||||
|
project = Project.objects.get(id=project_id)
|
||||||
|
return project
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_event_name(self, request):
|
||||||
|
payload = json.loads(request.body.decode("utf-8"))
|
||||||
|
return payload.get('object_kind', 'push')
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
|
@ -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),
|
||||||
|
]
|
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
|
@ -0,0 +1 @@
|
||||||
|
# This file is needed to load migrations
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
|
||||||
|
from taiga.users.models import User
|
||||||
|
from taiga.base.utils.urls import get_absolute_url
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_generate_config(project):
|
||||||
|
config = project.modules_config.config
|
||||||
|
if config and "gitlab" in config:
|
||||||
|
g_config = project.modules_config.config["gitlab"]
|
||||||
|
else:
|
||||||
|
g_config = {"secret": uuid.uuid4().hex }
|
||||||
|
|
||||||
|
url = reverse("gitlab-hook-list")
|
||||||
|
url = get_absolute_url(url)
|
||||||
|
url = "%s?project=%s"%(url, project.id)
|
||||||
|
g_config["webhooks_url"] = url
|
||||||
|
return g_config
|
||||||
|
|
||||||
|
|
||||||
|
def get_gitlab_user(user_email):
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if user_email:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=user_email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
user = User.objects.get(is_system=True, username__startswith="gitlab")
|
||||||
|
|
||||||
|
return user
|
|
@ -132,8 +132,11 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet
|
||||||
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
|
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
|
||||||
|
|
||||||
# GitHub webhooks
|
# 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")
|
router.register(r"github-hook", GitHubViewSet, base_name="github-hook")
|
||||||
|
|
||||||
|
from taiga.hooks.gitlab.api import GitLabViewSet
|
||||||
|
router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook")
|
||||||
|
|
||||||
# feedback
|
# feedback
|
||||||
# - see taiga.feedback.routers and taiga.feedback.apps
|
# - see taiga.feedback.routers and taiga.feedback.apps
|
||||||
|
|
|
@ -6,9 +6,9 @@ from unittest import mock
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
|
||||||
from taiga.github_hook.api import GitHubViewSet
|
from taiga.hooks.github import event_hooks
|
||||||
from taiga.github_hook import event_hooks
|
from taiga.hooks.github.api import GitHubViewSet
|
||||||
from taiga.github_hook.exceptions import ActionSyntaxException
|
from taiga.hooks.exceptions import ActionSyntaxException
|
||||||
from taiga.projects.issues.models import Issue
|
from taiga.projects.issues.models import Issue
|
||||||
from taiga.projects.tasks.models import Task
|
from taiga.projects.tasks.models import Task
|
||||||
from taiga.projects.userstories.models import UserStory
|
from taiga.projects.userstories.models import UserStory
|
|
@ -0,0 +1,341 @@
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
from taiga.hooks.gitlab import event_hooks
|
||||||
|
from taiga.hooks.gitlab.api import GitLabViewSet
|
||||||
|
from taiga.hooks.exceptions import ActionSyntaxException
|
||||||
|
from taiga.projects.issues.models import Issue
|
||||||
|
from taiga.projects.tasks.models import Task
|
||||||
|
from taiga.projects.userstories.models import UserStory
|
||||||
|
from taiga.projects.models import Membership
|
||||||
|
from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot
|
||||||
|
from taiga.projects.notifications.choices import NotifyLevel
|
||||||
|
from taiga.projects.notifications.models import NotifyPolicy
|
||||||
|
from taiga.projects import services
|
||||||
|
from .. import factories as f
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
def test_bad_signature(client):
|
||||||
|
project=f.ProjectFactory()
|
||||||
|
f.ProjectModulesConfigFactory(project=project, config={
|
||||||
|
"gitlab": {
|
||||||
|
"secret": "tpnIwJDz4e"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
url = reverse("gitlab-hook-list")
|
||||||
|
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
|
||||||
|
data = {}
|
||||||
|
response = client.post(url, json.dumps(data), content_type="application/json")
|
||||||
|
response_content = json.loads(response.content.decode("utf-8"))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Bad signature" in response_content["_error_message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ok_signature(client):
|
||||||
|
project=f.ProjectFactory()
|
||||||
|
f.ProjectModulesConfigFactory(project=project, config={
|
||||||
|
"gitlab": {
|
||||||
|
"secret": "tpnIwJDz4e"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
url = reverse("gitlab-hook-list")
|
||||||
|
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
|
||||||
|
data = {"test:": "data"}
|
||||||
|
response = client.post(url, json.dumps(data), content_type="application/json")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_detected(client):
|
||||||
|
project=f.ProjectFactory()
|
||||||
|
url = reverse("gitlab-hook-list")
|
||||||
|
url = "%s?project=%s"%(url, project.id)
|
||||||
|
data = {"commits": [
|
||||||
|
{"message": "test message"},
|
||||||
|
]}
|
||||||
|
|
||||||
|
GitLabViewSet._validate_signature = mock.Mock(return_value=True)
|
||||||
|
|
||||||
|
with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock:
|
||||||
|
response = client.post(url, json.dumps(data),
|
||||||
|
HTTP_X_GITHUB_EVENT="push",
|
||||||
|
content_type="application/json")
|
||||||
|
|
||||||
|
assert process_event_mock.call_count == 1
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_issue_processing(client):
|
||||||
|
creation_status = f.IssueStatusFactory()
|
||||||
|
role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
|
||||||
|
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
|
||||||
|
new_status = f.IssueStatusFactory(project=creation_status.project)
|
||||||
|
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-%s #%s ok
|
||||||
|
bye!
|
||||||
|
"""%(issue.ref, new_status.slug)},
|
||||||
|
]}
|
||||||
|
mail.outbox = []
|
||||||
|
ev_hook = event_hooks.PushEventHook(issue.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
issue = Issue.objects.get(id=issue.id)
|
||||||
|
assert issue.status.id == new_status.id
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_task_processing(client):
|
||||||
|
creation_status = f.TaskStatusFactory()
|
||||||
|
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
|
||||||
|
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
|
||||||
|
new_status = f.TaskStatusFactory(project=creation_status.project)
|
||||||
|
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-%s #%s ok
|
||||||
|
bye!
|
||||||
|
"""%(task.ref, new_status.slug)},
|
||||||
|
]}
|
||||||
|
mail.outbox = []
|
||||||
|
ev_hook = event_hooks.PushEventHook(task.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
task = Task.objects.get(id=task.id)
|
||||||
|
assert task.status.id == new_status.id
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_user_story_processing(client):
|
||||||
|
creation_status = f.UserStoryStatusFactory()
|
||||||
|
role = f.RoleFactory(project=creation_status.project, permissions=["view_us"])
|
||||||
|
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
|
||||||
|
new_status = f.UserStoryStatusFactory(project=creation_status.project)
|
||||||
|
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-%s #%s ok
|
||||||
|
bye!
|
||||||
|
"""%(user_story.ref, new_status.slug)},
|
||||||
|
]}
|
||||||
|
|
||||||
|
mail.outbox = []
|
||||||
|
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
user_story = UserStory.objects.get(id=user_story.id)
|
||||||
|
assert user_story.status.id == new_status.id
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_processing_case_insensitive(client):
|
||||||
|
creation_status = f.TaskStatusFactory()
|
||||||
|
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
|
||||||
|
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
|
||||||
|
new_status = f.TaskStatusFactory(project=creation_status.project)
|
||||||
|
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test tg-%s #%s ok
|
||||||
|
bye!
|
||||||
|
"""%(task.ref, new_status.slug.upper())},
|
||||||
|
]}
|
||||||
|
mail.outbox = []
|
||||||
|
ev_hook = event_hooks.PushEventHook(task.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
task = Task.objects.get(id=task.id)
|
||||||
|
assert task.status.id == new_status.id
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_task_bad_processing_non_existing_ref(client):
|
||||||
|
issue_status = f.IssueStatusFactory()
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-6666666 #%s ok
|
||||||
|
bye!
|
||||||
|
"""%(issue_status.slug)},
|
||||||
|
]}
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.PushEventHook(issue_status.project, payload)
|
||||||
|
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert str(excinfo.value) == "The referenced element doesn't exist"
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_us_bad_processing_non_existing_status(client):
|
||||||
|
user_story = f.UserStoryFactory.create()
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-%s #non-existing-slug ok
|
||||||
|
bye!
|
||||||
|
"""%(user_story.ref)},
|
||||||
|
]}
|
||||||
|
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
|
||||||
|
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert str(excinfo.value) == "The status doesn't exist"
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_event_bad_processing_non_existing_status(client):
|
||||||
|
issue = f.IssueFactory.create()
|
||||||
|
payload = {"commits": [
|
||||||
|
{"message": """test message
|
||||||
|
test TG-%s #non-existing-slug ok
|
||||||
|
bye!
|
||||||
|
"""%(issue.ref)},
|
||||||
|
]}
|
||||||
|
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.PushEventHook(issue.project, payload)
|
||||||
|
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert str(excinfo.value) == "The status doesn't exist"
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_issues_event_opened_issue(client):
|
||||||
|
issue = f.IssueFactory.create()
|
||||||
|
issue.project.default_issue_status = issue.status
|
||||||
|
issue.project.default_issue_type = issue.type
|
||||||
|
issue.project.default_severity = issue.severity
|
||||||
|
issue.project.default_priority = issue.priority
|
||||||
|
issue.project.save()
|
||||||
|
Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_owner=True)
|
||||||
|
notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project)
|
||||||
|
notify_policy.notify_level = NotifyLevel.watch
|
||||||
|
notify_policy.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"object_kind": "issue",
|
||||||
|
"object_attributes": {
|
||||||
|
"title": "test-title",
|
||||||
|
"description": "test-body",
|
||||||
|
"url": "http://gitlab.com/test/project/issues/11",
|
||||||
|
"action": "open",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert Issue.objects.count() == 2
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_issues_event_other_than_opened_issue(client):
|
||||||
|
issue = f.IssueFactory.create()
|
||||||
|
issue.project.default_issue_status = issue.status
|
||||||
|
issue.project.default_issue_type = issue.type
|
||||||
|
issue.project.default_severity = issue.severity
|
||||||
|
issue.project.default_priority = issue.priority
|
||||||
|
issue.project.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"object_kind": "issue",
|
||||||
|
"object_attributes": {
|
||||||
|
"title": "test-title",
|
||||||
|
"description": "test-body",
|
||||||
|
"url": "http://gitlab.com/test/project/issues/11",
|
||||||
|
"action": "update",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert Issue.objects.count() == 1
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_issues_event_bad_issue(client):
|
||||||
|
issue = f.IssueFactory.create()
|
||||||
|
issue.project.default_issue_status = issue.status
|
||||||
|
issue.project.default_issue_type = issue.type
|
||||||
|
issue.project.default_severity = issue.severity
|
||||||
|
issue.project.default_priority = issue.priority
|
||||||
|
issue.project.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"object_kind": "issue",
|
||||||
|
"object_attributes": {
|
||||||
|
"action": "open",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mail.outbox = []
|
||||||
|
|
||||||
|
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
|
||||||
|
|
||||||
|
with pytest.raises(ActionSyntaxException) as excinfo:
|
||||||
|
ev_hook.process_event()
|
||||||
|
|
||||||
|
assert str(excinfo.value) == "Invalid issue information"
|
||||||
|
|
||||||
|
assert Issue.objects.count() == 1
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_get_project_modules(client):
|
||||||
|
project = f.create_project()
|
||||||
|
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
|
||||||
|
|
||||||
|
url = reverse("projects-modules", args=(project.id,))
|
||||||
|
|
||||||
|
client.login(project.owner)
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
assert "gitlab" in content
|
||||||
|
assert content["gitlab"]["secret"] != ""
|
||||||
|
assert content["gitlab"]["webhooks_url"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_patch_project_modules(client):
|
||||||
|
project = f.create_project()
|
||||||
|
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
|
||||||
|
|
||||||
|
url = reverse("projects-modules", args=(project.id,))
|
||||||
|
|
||||||
|
client.login(project.owner)
|
||||||
|
data = {
|
||||||
|
"gitlab": {
|
||||||
|
"secret": "test_secret",
|
||||||
|
"url": "test_url",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = client.patch(url, json.dumps(data), content_type="application/json")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
config = services.get_modules_config(project).config
|
||||||
|
assert "gitlab" in config
|
||||||
|
assert config["gitlab"]["secret"] == "test_secret"
|
||||||
|
assert config["gitlab"]["webhooks_url"] != "test_url"
|
||||||
|
|
||||||
|
def test_replace_gitlab_references():
|
||||||
|
assert event_hooks.replace_gitlab_references("project-url", "#2") == "[GitLab#2](project-url/issues/2)"
|
||||||
|
assert event_hooks.replace_gitlab_references("project-url", "#2 ") == "[GitLab#2](project-url/issues/2) "
|
||||||
|
assert event_hooks.replace_gitlab_references("project-url", " #2 ") == " [GitLab#2](project-url/issues/2) "
|
||||||
|
assert event_hooks.replace_gitlab_references("project-url", " #2") == " [GitLab#2](project-url/issues/2)"
|
||||||
|
assert event_hooks.replace_gitlab_references("project-url", "#test") == "#test"
|
Loading…
Reference in New Issue