diff --git a/settings/common.py b/settings/common.py index fe66d2ba..57cc50dd 100644 --- a/settings/common.py +++ b/settings/common.py @@ -194,6 +194,7 @@ INSTALLED_APPS = [ "taiga.mdrender", "taiga.export_import", "taiga.feedback", + "taiga.github_hook", "rest_framework", "djmail", diff --git a/taiga/base/utils/slug.py b/taiga/base/utils/slug.py index c4a389e7..576ecfa3 100644 --- a/taiga/base/utils/slug.py +++ b/taiga/base/utils/slug.py @@ -39,6 +39,23 @@ def slugify_uniquely(value, model, slugfield="slug"): suffix += 1 +def slugify_uniquely_for_queryset(value, queryset, slugfield="slug"): + """ + Returns a slug on a name which doesn't exist in a queryset + """ + + suffix = 0 + potential = base = slugify(unidecode(value)) + if len(potential) == 0: + potential = 'null' + while True: + if suffix: + potential = "-".join([base, str(suffix)]) + if not queryset.filter(**{slugfield: potential}).exists(): + return potential + suffix += 1 + + def ref_uniquely(p, seq_field, model, field='ref'): project = p.__class__.objects.select_for_update().get(pk=p.pk) ref = getattr(project, seq_field) + 1 diff --git a/taiga/github_hook/__init__.py b/taiga/github_hook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/github_hook/api.py b/taiga/github_hook/api.py new file mode 100644 index 00000000..4149ab96 --- /dev/null +++ b/taiga/github_hook/api.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 json +import hmac +import hashlib + +from rest_framework.exceptions import ParseError +from rest_framework.response import Response +from rest_framework.exceptions import APIException + +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.api.viewsets import GenericViewSet +from taiga.projects.models import Project + +from . import event_hooks +from .exceptions import ActionSyntaxException + + +class Http401(APIException): + status_code = 401 + + +class GitHubViewSet(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, + } + + 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_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 create(self, request, *args, **kwargs): + project = self._get_project(request) + if not project: + raise Http401(_("The project doesn't exist")) + + if not self._validate_signature(project, request): + raise Http401(_("Bad signature")) + + event_name = request.META.get("HTTP_X_GITHUB_EVENT", None) + + try: + payload = json.loads(request.body.decode("utf-8")) + except ValueError as e: + raise Http401(_("The payload is not a valid json")) + + 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 Http401(e) + + return Response({}) diff --git a/taiga/github_hook/event_hooks.py b/taiga/github_hook/event_hooks.py new file mode 100644 index 00000000..10e7c6e1 --- /dev/null +++ b/taiga/github_hook/event_hooks.py @@ -0,0 +1,146 @@ +# 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 + +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 .exceptions import ActionSyntaxException +from .services import get_github_user + +class BaseEventHook(object): + + 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: + return + + github_user = self.payload.get('sender', {}).get('id', None) + + commits = self.payload.get("commits", []) + for commit in commits: + message = commit.get("message", None) + self._process_message(message, github_user) + + def _process_message(self, message, github_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) + if m: + ref = m.group(1) + status_slug = m.group(2) + self._change_status(ref, status_slug, github_user) + + def _change_status(self, ref, status_slug, github_user): + if Issue.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Issue + statusClass = IssueStatus + elif Task.objects.filter(ref=ref).exists(): + modelClass = Task + statusClass = TaskStatus + elif UserStory.objects.filter(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 IssueStatus.DoesNotExist: + raise ActionSyntaxException(_("The status doesn't exist")) + + element.status = status + element.save() + + snapshot = take_snapshot(element, comment="Status changed from Github commit", user=get_github_user(github_user)) + send_notifications(element, history=snapshot) + +class IssuesEventHook(BaseEventHook): + def process_event(self): + if self.payload.get('action', None) != "opened": + return + + subject = self.payload.get('issue', {}).get('title', None) + description = self.payload.get('issue', {}).get('body', None) + github_reference = self.payload.get('issue', {}).get('number', None) + github_user = self.payload.get('issue', {}).get('user', {}).get('id', None) + + if not all([subject, github_reference]): + raise ActionSyntaxException(_("Invalid issue information")) + + issue = Issue.objects.create( + project=self.project, + subject=subject, + description=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=['github', github_reference], + owner=get_github_user(github_user) + ) + take_snapshot(issue, user=get_github_user(github_user)) + + snapshot = take_snapshot(issue, comment="Created from Github", user=get_github_user(github_user)) + send_notifications(issue, history=snapshot) + +class IssueCommentEventHook(BaseEventHook): + def process_event(self): + if self.payload.get('action', None) != "created": + raise ActionSyntaxException(_("Invalid issue comment information")) + + github_reference = self.payload.get('issue', {}).get('number', None) + comment_message = self.payload.get('comment', {}).get('body', None) + github_user = self.payload.get('sender', {}).get('id', None) + + if not all([comment_message, github_reference]): + raise ActionSyntaxException(_("Invalid issue comment information")) + + issues = Issue.objects.filter(external_reference=["github", github_reference]) + tasks = Task.objects.filter(external_reference=["github", github_reference]) + uss = UserStory.objects.filter(external_reference=["github", github_reference]) + + for item in list(issues) + list(tasks) + list(uss): + snapshot = take_snapshot(item, comment="From Github: {}".format(comment_message), user=get_github_user(github_user)) + send_notifications(item, history=snapshot) diff --git a/taiga/github_hook/exceptions.py b/taiga/github_hook/exceptions.py new file mode 100644 index 00000000..697674d4 --- /dev/null +++ b/taiga/github_hook/exceptions.py @@ -0,0 +1,19 @@ +# 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 ActionSyntaxException(Exception): + pass diff --git a/taiga/github_hook/migrations/0001_initial.py b/taiga/github_hook/migrations/0001_initial.py new file mode 100644 index 00000000..65f10eaa --- /dev/null +++ b/taiga/github_hook/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="github-{}".format(random_hash), + email="github-{}@taiga.io".format(random_hash), + full_name="Github", + is_active=False, + is_system=True, + bio="", + ) + f = open("taiga/github_hook/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/github_hook/migrations/__init__.py b/taiga/github_hook/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/github_hook/migrations/logo.png b/taiga/github_hook/migrations/logo.png new file mode 100644 index 00000000..42f4046e Binary files /dev/null and b/taiga/github_hook/migrations/logo.png differ diff --git a/taiga/github_hook/models.py b/taiga/github_hook/models.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/github_hook/services.py b/taiga/github_hook/services.py new file mode 100644 index 00000000..96191709 --- /dev/null +++ b/taiga/github_hook/services.py @@ -0,0 +1,50 @@ +# 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 taiga.projects.models import ProjectModulesConfig +from taiga.users.models import User + + +def set_default_config(project): + if hasattr(project, "modules_config"): + if project.modules_config.config is None: + project.modules_config.config = {"github": {"secret": uuid.uuid4().hex }} + else: + project.modules_config.config["github"] = {"secret": uuid.uuid4().hex } + else: + project.modules_config = ProjectModulesConfig(project=project, config={ + "github": { + "secret": uuid.uuid4().hex + } + }) + project.modules_config.save() + + +def get_github_user(user_id): + user = None + + if user_id: + try: + user = User.objects.get(github_id=user_id) + except User.DoesNotExist: + pass + + if user is None: + user = User.objects.get(is_system=True, username__startswith="github") + + return user diff --git a/taiga/projects/issues/migrations/0002_issue_external_reference.py b/taiga/projects/issues/migrations/0002_issue_external_reference.py new file mode 100644 index 00000000..e88ddab2 --- /dev/null +++ b/taiga/projects/issues/migrations/0002_issue_external_reference.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='external_reference', + field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index 35aad2f1..0d591b3f 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -21,6 +21,8 @@ from django.utils import timezone from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ +from djorm_pgarray.fields import TextArrayField + from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin @@ -61,6 +63,7 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models. default=None, related_name="issues_assigned_to_me", verbose_name=_("assigned to")) attachments = generic.GenericRelation("attachments.Attachment") + external_reference = TextArrayField(default=None, verbose_name=_("external reference")) _importing = None class Meta: diff --git a/taiga/projects/migrations/0007_auto_20141024_1011.py b/taiga/projects/migrations/0007_auto_20141024_1011.py new file mode 100644 index 00000000..2e30cff8 --- /dev/null +++ b/taiga/projects/migrations/0007_auto_20141024_1011.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_auto_20141029_1040'), + ] + + operations = [ + migrations.AddField( + model_name='issuestatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='taskstatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='userstorystatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0008_auto_20141024_1012.py b/taiga/projects/migrations/0008_auto_20141024_1012.py new file mode 100644 index 00000000..a15b4713 --- /dev/null +++ b/taiga/projects/migrations/0008_auto_20141024_1012.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from unidecode import unidecode + +from django.db import models, migrations +from django.template.defaultfilters import slugify + +from taiga.projects.models import UserStoryStatus, TaskStatus, IssueStatus + +def update_many(objects, fields=[], using="default"): + """Update list of Django objects in one SQL query, optionally only + overwrite the given fields (as names, e.g. fields=["foo"]). + Objects must be of the same Django model. Note that save is not + called and signals on the model are not raised.""" + if not objects: + return + + import django.db.models + from django.db import connections + con = connections[using] + + names = fields + meta = objects[0]._meta + fields = [f for f in meta.fields if not isinstance(f, django.db.models.AutoField) and (not names or f.name in names)] + + if not fields: + raise ValueError("No fields to update, field names are %s." % names) + + fields_with_pk = fields + [meta.pk] + parameters = [] + for o in objects: + parameters.append(tuple(f.get_db_prep_save(f.pre_save(o, True), connection=con) for f in fields_with_pk)) + + table = meta.db_table + assignments = ",".join(("%s=%%s"% con.ops.quote_name(f.column)) for f in fields) + con.cursor().executemany( + "update %s set %s where %s=%%s" % (table, assignments, con.ops.quote_name(meta.pk.column)), + parameters) + + +def update_slug(apps, schema_editor): + update_qs = UserStoryStatus.objects.all() + for us_status in update_qs: + us_status.slug = slugify(unidecode(us_status.name)) + + update_many(update_qs, fields=["slug"]) + + update_qs = TaskStatus.objects.all() + for task_status in update_qs: + task_status.slug = slugify(unidecode(task_status.name)) + + update_many(update_qs, fields=["slug"]) + + update_qs = IssueStatus.objects.all() + for issue_status in update_qs: + issue_status.slug = slugify(unidecode(issue_status.name)) + + update_many(update_qs, fields=["slug"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0007_auto_20141024_1011'), + ] + + operations = [ + migrations.RunPython(update_slug) + ] diff --git a/taiga/projects/migrations/0009_auto_20141024_1037.py b/taiga/projects/migrations/0009_auto_20141024_1037.py new file mode 100644 index 00000000..4d25ecfc --- /dev/null +++ b/taiga/projects/migrations/0009_auto_20141024_1037.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0008_auto_20141024_1012'), + ] + + operations = [ + migrations.AlterField( + model_name='issuestatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterField( + model_name='taskstatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterField( + model_name='userstorystatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterUniqueTogether( + name='issuestatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='taskstatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='userstorystatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + ] diff --git a/taiga/projects/migrations/0010_project_modules_config.py b/taiga/projects/migrations/0010_project_modules_config.py new file mode 100644 index 00000000..49eaedb7 --- /dev/null +++ b/taiga/projects/migrations/0010_project_modules_config.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0009_auto_20141024_1037'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='modules_config', + field=django_pgjson.fields.JsonField(blank=True, null=True, verbose_name='modules config'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0011_auto_20141028_2057.py b/taiga/projects/migrations/0011_auto_20141028_2057.py new file mode 100644 index 00000000..fd9a0a33 --- /dev/null +++ b/taiga/projects/migrations/0011_auto_20141028_2057.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0010_project_modules_config'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectModulesConfig', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), + ('config', django_pgjson.fields.JsonField(null=True, verbose_name='modules config', blank=True)), + ('project', models.OneToOneField(to='projects.Project', verbose_name='project', related_name='modules_config')), + ], + options={ + 'verbose_name_plural': 'project modules configs', + 'verbose_name': 'project modules config', + 'ordering': ['project'], + }, + bases=(models.Model,), + ), + migrations.RemoveField( + model_name='project', + name='modules_config', + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 45944beb..e1e0ea8b 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -35,6 +35,7 @@ from taiga.users.models import Role from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.dicts import dict_sum from taiga.base.utils.sequence import arithmetic_progression +from taiga.base.utils.slug import slugify_uniquely_for_queryset from taiga.projects.notifications.services import create_notify_policy_if_not_exists from . import choices @@ -302,10 +303,23 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): return self._get_user_stories_points(self.user_stories.filter(milestone__isnull=False).prefetch_related('role_points', 'role_points__points')) +class ProjectModulesConfig(models.Model): + project = models.OneToOneField("Project", null=False, blank=False, + related_name="modules_config", verbose_name=_("project")) + config = JsonField(null=True, blank=True, verbose_name=_("modules config")) + + class Meta: + verbose_name = "project modules config" + verbose_name_plural = "project modules configs" + ordering = ["project"] + + # User Stories common Models class UserStoryStatus(models.Model): name = models.CharField(max_length=255, null=False, blank=False, verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) order = models.IntegerField(default=10, null=False, blank=False, verbose_name=_("order")) is_closed = models.BooleanField(default=False, null=False, blank=True, @@ -321,7 +335,7 @@ class UserStoryStatus(models.Model): verbose_name = "user story status" verbose_name_plural = "user story statuses" ordering = ["project", "order", "name"] - unique_together = ("project", "name") + unique_together = (("project", "name"), ("project", "slug")) permissions = ( ("view_userstorystatus", "Can view user story status"), ) @@ -329,6 +343,12 @@ class UserStoryStatus(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely_for_queryset(self.name, self.project.us_statuses) + + return super().save(*args, **kwargs) + class Points(models.Model): name = models.CharField(max_length=255, null=False, blank=False, @@ -358,6 +378,8 @@ class Points(models.Model): class TaskStatus(models.Model): name = models.CharField(max_length=255, null=False, blank=False, verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) order = models.IntegerField(default=10, null=False, blank=False, verbose_name=_("order")) is_closed = models.BooleanField(default=False, null=False, blank=True, @@ -371,7 +393,7 @@ class TaskStatus(models.Model): verbose_name = "task status" verbose_name_plural = "task statuses" ordering = ["project", "order", "name"] - unique_together = ("project", "name") + unique_together = (("project", "name"), ("project", "slug")) permissions = ( ("view_taskstatus", "Can view task status"), ) @@ -379,6 +401,12 @@ class TaskStatus(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely_for_queryset(self.name, self.project.task_statuses) + + return super().save(*args, **kwargs) + # Issue common Models @@ -431,6 +459,8 @@ class Severity(models.Model): class IssueStatus(models.Model): name = models.CharField(max_length=255, null=False, blank=False, verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) order = models.IntegerField(default=10, null=False, blank=False, verbose_name=_("order")) is_closed = models.BooleanField(default=False, null=False, blank=True, @@ -444,7 +474,7 @@ class IssueStatus(models.Model): verbose_name = "issue status" verbose_name_plural = "issue statuses" ordering = ["project", "order", "name"] - unique_together = ("project", "name") + unique_together = (("project", "name"), ("project", "slug")) permissions = ( ("view_issuestatus", "Can view issue status"), ) @@ -452,6 +482,12 @@ class IssueStatus(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely_for_queryset(self.name, self.project.issue_statuses) + + return super().save(*args, **kwargs) + class IssueType(models.Model): name = models.CharField(max_length=255, null=False, blank=False, diff --git a/taiga/projects/tasks/migrations/0003_task_external_reference.py b/taiga/projects/tasks/migrations/0003_task_external_reference.py new file mode 100644 index 00000000..8222116e --- /dev/null +++ b/taiga/projects/tasks/migrations/0003_task_external_reference.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0002_tasks_order_fields'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='external_reference', + field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py index 25225d42..4f652ae8 100644 --- a/taiga/projects/tasks/models.py +++ b/taiga/projects/tasks/models.py @@ -20,6 +20,8 @@ from django.conf import settings from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from djorm_pgarray.fields import TextArrayField + from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin @@ -62,6 +64,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M attachments = generic.GenericRelation("attachments.Attachment") is_iocaine = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("is iocaine")) + external_reference = TextArrayField(default=None, verbose_name=_("external reference")) _importing = None class Meta: diff --git a/taiga/projects/userstories/migrations/0007_userstory_external_reference.py b/taiga/projects/userstories/migrations/0007_userstory_external_reference.py new file mode 100644 index 00000000..3cbbceeb --- /dev/null +++ b/taiga/projects/userstories/migrations/0007_userstory_external_reference.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0006_auto_20141014_1524'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='external_reference', + field=djorm_pgarray.fields.TextArrayField(dbtype='text', verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index d0038441..f9688380 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -20,6 +20,8 @@ from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.utils import timezone +from djorm_pgarray.fields import TextArrayField + from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin @@ -97,6 +99,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod on_delete=models.SET_NULL, related_name="generated_user_stories", verbose_name=_("generated from issue")) + external_reference = TextArrayField(default=None, verbose_name=_("external reference")) _importing = None class Meta: diff --git a/taiga/routers.py b/taiga/routers.py index 32bb9001..db3e397f 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -131,8 +131,9 @@ 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 +router.register(r"github-hook", GitHubViewSet, base_name="github-hook") # feedback # - see taiga.feedback.routers and taiga.feedback.apps - - diff --git a/taiga/users/migrations/0006_auto_20141030_1132.py b/taiga/users/migrations/0006_auto_20141030_1132.py new file mode 100644 index 00000000..62f9338d --- /dev/null +++ b/taiga/users/migrations/0006_auto_20141030_1132.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_user_photo'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_system', + field=models.BooleanField(default=False), + preserve_default=True, + ), + migrations.AlterField( + model_name='user', + name='github_id', + field=models.IntegerField(blank=True, null=True, db_index=True, verbose_name='github ID'), + ), + ] diff --git a/taiga/users/models.py b/taiga/users/models.py index 0ebfc81d..69322589 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -129,7 +129,8 @@ class User(AbstractBaseUser, PermissionsMixin): new_email = models.EmailField(_('new email address'), null=True, blank=True) - github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID")) + github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"), db_index=True) + is_system = models.BooleanField(null=False, blank=False, default=False) USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] diff --git a/tests/factories.py b/tests/factories.py index c2d0f7cf..bc2dddc8 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -73,6 +73,14 @@ class ProjectFactory(Factory): creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory") +class ProjectModulesConfigFactory(Factory): + class Meta: + model = "projects.ProjectModulesConfig" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + + class RoleFactory(Factory): class Meta: model = "users.Role" diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index 90bf1f98..7e6db573 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) == 3 + assert len(users_data) == 4 assert response.status_code == 200 diff --git a/tests/integration/test_github_hook.py b/tests/integration/test_github_hook.py new file mode 100644 index 00000000..9c65b735 --- /dev/null +++ b/tests/integration/test_github_hook.py @@ -0,0 +1,347 @@ +import pytest +import json + +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.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 .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_bad_signature(client): + project=f.ProjectFactory() + url = reverse("github-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=badbadbad", + content_type="application/json") + response_content = json.loads(response.content.decode("utf-8")) + assert response.status_code == 401 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "github": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("github-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {"test:": "data"} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", + content_type="application/json") + + assert response.status_code == 200 + + +def test_push_event_detected(client): + project=f.ProjectFactory() + url = reverse("github-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {"commits": [ + {"message": "test message"}, + ]} + + GitHubViewSet._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() + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project) + 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() + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project) + 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() + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project) + 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_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_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 = { + "action": "opened", + "issue": { + "title": "test-title", + "body": "test-body", + "number": 10, + }, + "assignee": {}, + "label": {}, + } + + 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 = { + "action": "closed", + "issue": { + "title": "test-title", + "body": "test-body", + "number": 10, + }, + "assignee": {}, + "label": {}, + } + + 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 = { + "action": "opened", + "issue": {}, + "assignee": {}, + "label": {}, + } + 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_issue_comment_event_on_existing_issue_task_and_us(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(task, user=task.owner) + us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(us, user=us.owner) + + payload = { + "action": "created", + "issue": { + "number": 10, + }, + "comment": { + "body": "Test body", + }, + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(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 == "From Github: Test body" + + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment == "From Github: Test body" + + us_history = get_history_queryset_by_model_instance(us) + assert us_history.count() == 1 + assert us_history[0].comment == "From Github: Test body" + + assert len(mail.outbox) == 3 + + +def test_issue_comment_event_on_not_existing_issue_task_and_us(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(task, user=task.owner) + us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(us, user=us.owner) + + payload = { + "action": "created", + "issue": { + "number": 11, + }, + "comment": { + "body": "Test body", + }, + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_comment(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + + payload = { + "action": "other", + "issue": {}, + "comment": {}, + } + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + + mail.outbox = [] + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue comment information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0