US#90 Github webhooks integration

remotes/origin/enhancement/email-actions
Alejandro Alonso 2014-10-23 11:59:26 +02:00 committed by David Barragán Merino
parent 6d792a0e91
commit 0fd7142802
29 changed files with 1065 additions and 7 deletions

View File

@ -194,6 +194,7 @@ INSTALLED_APPS = [
"taiga.mdrender", "taiga.mdrender",
"taiga.export_import", "taiga.export_import",
"taiga.feedback", "taiga.feedback",
"taiga.github_hook",
"rest_framework", "rest_framework",
"djmail", "djmail",

View File

@ -39,6 +39,23 @@ def slugify_uniquely(value, model, slugfield="slug"):
suffix += 1 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'): def ref_uniquely(p, seq_field, model, field='ref'):
project = p.__class__.objects.select_for_update().get(pk=p.pk) project = p.__class__.objects.select_for_update().get(pk=p.pk)
ref = getattr(project, seq_field) + 1 ref = getattr(project, seq_field) + 1

View File

102
taiga/github_hook/api.py Normal file
View File

@ -0,0 +1,102 @@
# 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 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({})

View File

@ -0,0 +1,146 @@
# 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
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)

View File

@ -0,0 +1,19 @@
# 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 ActionSyntaxException(Exception):
pass

View File

@ -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),
]

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

View File

@ -0,0 +1,50 @@
# 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 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

View File

@ -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,
),
]

View File

@ -21,6 +21,8 @@ from django.utils import timezone
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from djorm_pgarray.fields import TextArrayField
from taiga.projects.occ import OCCModelMixin from taiga.projects.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin 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", default=None, related_name="issues_assigned_to_me",
verbose_name=_("assigned to")) verbose_name=_("assigned to"))
attachments = generic.GenericRelation("attachments.Attachment") attachments = generic.GenericRelation("attachments.Attachment")
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
_importing = None _importing = None
class Meta: class Meta:

View File

@ -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,
),
]

View File

@ -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)
]

View File

@ -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')]),
),
]

View File

@ -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,
),
]

View File

@ -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',
),
]

View File

@ -35,6 +35,7 @@ from taiga.users.models import Role
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.dicts import dict_sum from taiga.base.utils.dicts import dict_sum
from taiga.base.utils.sequence import arithmetic_progression 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 taiga.projects.notifications.services import create_notify_policy_if_not_exists
from . import choices 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')) 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 # User Stories common Models
class UserStoryStatus(models.Model): class UserStoryStatus(models.Model):
name = models.CharField(max_length=255, null=False, blank=False, name = models.CharField(max_length=255, null=False, blank=False,
verbose_name=_("name")) 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, order = models.IntegerField(default=10, null=False, blank=False,
verbose_name=_("order")) verbose_name=_("order"))
is_closed = models.BooleanField(default=False, null=False, blank=True, 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 = "user story status"
verbose_name_plural = "user story statuses" verbose_name_plural = "user story statuses"
ordering = ["project", "order", "name"] ordering = ["project", "order", "name"]
unique_together = ("project", "name") unique_together = (("project", "name"), ("project", "slug"))
permissions = ( permissions = (
("view_userstorystatus", "Can view user story status"), ("view_userstorystatus", "Can view user story status"),
) )
@ -329,6 +343,12 @@ class UserStoryStatus(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class Points(models.Model):
name = models.CharField(max_length=255, null=False, blank=False, name = models.CharField(max_length=255, null=False, blank=False,
@ -358,6 +378,8 @@ class Points(models.Model):
class TaskStatus(models.Model): class TaskStatus(models.Model):
name = models.CharField(max_length=255, null=False, blank=False, name = models.CharField(max_length=255, null=False, blank=False,
verbose_name=_("name")) 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, order = models.IntegerField(default=10, null=False, blank=False,
verbose_name=_("order")) verbose_name=_("order"))
is_closed = models.BooleanField(default=False, null=False, blank=True, is_closed = models.BooleanField(default=False, null=False, blank=True,
@ -371,7 +393,7 @@ class TaskStatus(models.Model):
verbose_name = "task status" verbose_name = "task status"
verbose_name_plural = "task statuses" verbose_name_plural = "task statuses"
ordering = ["project", "order", "name"] ordering = ["project", "order", "name"]
unique_together = ("project", "name") unique_together = (("project", "name"), ("project", "slug"))
permissions = ( permissions = (
("view_taskstatus", "Can view task status"), ("view_taskstatus", "Can view task status"),
) )
@ -379,6 +401,12 @@ class TaskStatus(models.Model):
def __str__(self): def __str__(self):
return self.name 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 # Issue common Models
@ -431,6 +459,8 @@ class Severity(models.Model):
class IssueStatus(models.Model): class IssueStatus(models.Model):
name = models.CharField(max_length=255, null=False, blank=False, name = models.CharField(max_length=255, null=False, blank=False,
verbose_name=_("name")) 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, order = models.IntegerField(default=10, null=False, blank=False,
verbose_name=_("order")) verbose_name=_("order"))
is_closed = models.BooleanField(default=False, null=False, blank=True, is_closed = models.BooleanField(default=False, null=False, blank=True,
@ -444,7 +474,7 @@ class IssueStatus(models.Model):
verbose_name = "issue status" verbose_name = "issue status"
verbose_name_plural = "issue statuses" verbose_name_plural = "issue statuses"
ordering = ["project", "order", "name"] ordering = ["project", "order", "name"]
unique_together = ("project", "name") unique_together = (("project", "name"), ("project", "slug"))
permissions = ( permissions = (
("view_issuestatus", "Can view issue status"), ("view_issuestatus", "Can view issue status"),
) )
@ -452,6 +482,12 @@ class IssueStatus(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class IssueType(models.Model):
name = models.CharField(max_length=255, null=False, blank=False, name = models.CharField(max_length=255, null=False, blank=False,

View File

@ -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,
),
]

View File

@ -20,6 +20,8 @@ from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from djorm_pgarray.fields import TextArrayField
from taiga.projects.occ import OCCModelMixin from taiga.projects.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.mixins.blocked import BlockedMixin
@ -62,6 +64,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M
attachments = generic.GenericRelation("attachments.Attachment") attachments = generic.GenericRelation("attachments.Attachment")
is_iocaine = models.BooleanField(default=False, null=False, blank=True, is_iocaine = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is iocaine")) verbose_name=_("is iocaine"))
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
_importing = None _importing = None
class Meta: class Meta:

View File

@ -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,
),
]

View File

@ -20,6 +20,8 @@ from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from djorm_pgarray.fields import TextArrayField
from taiga.projects.occ import OCCModelMixin from taiga.projects.occ import OCCModelMixin
from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin
from taiga.projects.mixins.blocked import BlockedMixin from taiga.projects.mixins.blocked import BlockedMixin
@ -97,6 +99,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="generated_user_stories", related_name="generated_user_stories",
verbose_name=_("generated from issue")) verbose_name=_("generated from issue"))
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
_importing = None _importing = None
class Meta: class Meta:

View File

@ -131,8 +131,9 @@ 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
from taiga.github_hook.api import GitHubViewSet
router.register(r"github-hook", GitHubViewSet, base_name="github-hook")
# feedback # feedback
# - see taiga.feedback.routers and taiga.feedback.apps # - see taiga.feedback.routers and taiga.feedback.apps

View File

@ -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'),
),
]

View File

@ -129,7 +129,8 @@ class User(AbstractBaseUser, PermissionsMixin):
new_email = models.EmailField(_('new email address'), null=True, blank=True) 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' USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email'] REQUIRED_FIELDS = ['email']

View File

@ -73,6 +73,14 @@ class ProjectFactory(Factory):
creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory") 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 RoleFactory(Factory):
class Meta: class Meta:
model = "users.Role" model = "users.Role"

View File

@ -103,7 +103,7 @@ def test_user_list(client, data):
response = client.get(url) response = client.get(url)
users_data = json.loads(response.content.decode('utf-8')) users_data = json.loads(response.content.decode('utf-8'))
assert len(users_data) == 3 assert len(users_data) == 4
assert response.status_code == 200 assert response.status_code == 200

View File

@ -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