From 0fd7142802ef4509c5b81347db213dad8a97ec6c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 23 Oct 2014 11:59:26 +0200 Subject: [PATCH] US#90 Github webhooks integration --- settings/common.py | 1 + taiga/base/utils/slug.py | 17 + taiga/github_hook/__init__.py | 0 taiga/github_hook/api.py | 102 +++++ taiga/github_hook/event_hooks.py | 146 ++++++++ taiga/github_hook/exceptions.py | 19 + taiga/github_hook/migrations/0001_initial.py | 36 ++ taiga/github_hook/migrations/__init__.py | 0 taiga/github_hook/migrations/logo.png | Bin 0 -> 35407 bytes taiga/github_hook/models.py | 0 taiga/github_hook/services.py | 50 +++ .../0002_issue_external_reference.py | 21 ++ taiga/projects/issues/models.py | 3 + .../migrations/0007_auto_20141024_1011.py | 32 ++ .../migrations/0008_auto_20141024_1012.py | 70 ++++ .../migrations/0009_auto_20141024_1037.py | 41 +++ .../migrations/0010_project_modules_config.py | 21 ++ .../migrations/0011_auto_20141028_2057.py | 33 ++ taiga/projects/models.py | 42 ++- .../0003_task_external_reference.py | 21 ++ taiga/projects/tasks/models.py | 3 + .../0007_userstory_external_reference.py | 21 ++ taiga/projects/userstories/models.py | 3 + taiga/routers.py | 5 +- .../migrations/0006_auto_20141030_1132.py | 25 ++ taiga/users/models.py | 3 +- tests/factories.py | 8 + .../test_users_resources.py | 2 +- tests/integration/test_github_hook.py | 347 ++++++++++++++++++ 29 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 taiga/github_hook/__init__.py create mode 100644 taiga/github_hook/api.py create mode 100644 taiga/github_hook/event_hooks.py create mode 100644 taiga/github_hook/exceptions.py create mode 100644 taiga/github_hook/migrations/0001_initial.py create mode 100644 taiga/github_hook/migrations/__init__.py create mode 100644 taiga/github_hook/migrations/logo.png create mode 100644 taiga/github_hook/models.py create mode 100644 taiga/github_hook/services.py create mode 100644 taiga/projects/issues/migrations/0002_issue_external_reference.py create mode 100644 taiga/projects/migrations/0007_auto_20141024_1011.py create mode 100644 taiga/projects/migrations/0008_auto_20141024_1012.py create mode 100644 taiga/projects/migrations/0009_auto_20141024_1037.py create mode 100644 taiga/projects/migrations/0010_project_modules_config.py create mode 100644 taiga/projects/migrations/0011_auto_20141028_2057.py create mode 100644 taiga/projects/tasks/migrations/0003_task_external_reference.py create mode 100644 taiga/projects/userstories/migrations/0007_userstory_external_reference.py create mode 100644 taiga/users/migrations/0006_auto_20141030_1132.py create mode 100644 tests/integration/test_github_hook.py 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 0000000000000000000000000000000000000000..42f4046ef7efc92dfdc93993118eb21d2758bc6a GIT binary patch literal 35407 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

j!Iu?6 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

D}JUfFe-)5E&n9g1hFr55ttyV(E z67FY{eNi>%ymn4`)pCjmqPO0wJ+`@N_yC2pM+7aeN?{z@aEmeT$|SSc5rz-RX|Vl; z&Xl>F3Lb|QsQ9hNT&j!O(hFJZX2G4AKG{mr+3h$zyJ|VjdOG;+ltKU6(cQZuV~!Ec zJhJho!81q(8%U6imMmc3mwZT*L+8 zGRmFPxWaP!Q^J$a6{o|$+x&jjUbH~BoPE?4yUB}M0H|b=9*qvaACV1TFM6U9ag{`c4gXPd$8H6!wTX{#jCvX+%}sN&rvk9>sDsP>FkS4gAv2{Kw_HP zfYlRE*(2(uxBq^bTx=QgemR523&GfkF zIFS&}sOg+pZ?Z!y>6>hhK zq_a0J{Uj4NY$fP&42q~Gc4l9#1uh2Y+jn>_1{5HE2+qWu%zr>Hyg$7-c3Qh9S&_5B zUi=V19SGtjsoNh6y=8eI&9lL=`DCxrJka>5_;&H5jP5Ktamq&m5t~mRG9Ywf=X^@- zXVNu{DGm_XUISG~>WkMm;?hCJjoUghiKF!`{itzNp z9snw-pgcNf9Tk~ZNB^9De(9J~C3X7Iw+>3IcaZ+HO)IvTW0H&hF{MtB{Jh;&W<;9| zzfF0ivq2tw@!1GM+iObLzTo8+>WMbIW~UghxNAYST_{3I3$m1oeBnk1RJ}@eKXZ z=McS^gm|K+cj=f}Ea|P-^E0%4+g8XI{k(xuI6ysAui5-nz;~}le$Rrk1R3k=A7MSF z(5V7rk+e!)J5E_V;tKBncgT1F27baUnDOX--A%lSNdy7Hl&b_gPYEL?&FEjpw z3|FY&k*Cp>dG4q27Vq@&Mamuco$`#1NArFadNIBU6(u*MW|ENE;bphjNPk^2)oH~w z5UdMKd9MpRmELu|K|>E%J#mRb1$!m%M6#-9BY&w4tvOdeIGOzzs9h3iN;s-=+fLd; zak$;!kb$)H7Hep)-pzB7@mwo`kl2P8+76ohhxg&y2{^!l!{0=Gq*@-MR!YXsZTOAR z7P)h4+P#Lr_?1f_SqZ~Q$66c7KpIPG2U0Uusg#5@715VtG+@M71x$ms)w$kacLi+w$qO>TvLc0bTOKs40RUPf->vwdI9=JvD}L@G+tYm{sU zF_B;GRXzBVR7@uc2Uu{0XrA%uYIsxy80r7q`VksGBhN)x?`iF4S~8EQF? z^}ta;6rYYoY;j+;iVMtE*lE;UT#YZ?*KTfjscfS0uMk!xci13aN0?pibwNh02X7>; zxHOY7k8@3W8B(A{1Y)@OG*Q-VLrsvxi}PgNg1Ztr)Y1;*-Fy$cMh$hX0BnkwHJaPs zvx3z2@%;XiQ#I?oo8I%?Iu#q$#q@BT?4>NXpPo#P8Bs~s$45Bf7U@dTC*`gQ(h5}a z>#RJF0CX2W{)$@qVq$S~=8l}v;Bm#)*beVeOCnq+oD98Vob{>lQHZ~8S!Nc=Ao+{d zb@}kPy6DrRmN)Pl{~2B6zJEx(rf?7%)|F=#RNI!TTc^ZaTaFXdY&9KW(jKeoSy*5m z)Yl8qKAC0l-_c9-#gOLj)u6A&HsTLHznRJT4;(?wv$>J=dzu;pDzEFaC#&H_{#hUE zstyCi5wpc(qZt&=;*u1@y zd)8QeuXEk@fiKC@Sw+1=pVRggA#+XD(jextuMG;>vY)a*^CTTd7L@vHRQ1&I%$I)H zuG9 z+0m^`Nm|Zl>Yu2J?3OStGv9$36!*{$fgXnY^v1k+(B3~KriioKNw)#(DXy}S3%O!E zk~RC=^NmBSZSPrr?wt#>6AP*qfq%nv1dW#{s=i>9yQyDjv)4pUMt}02u*d2(;ZQ`PQ)M|iJ!TRFnrb#|fJPsm1Vk<`R)UTV_) z)F~5Z;ZT+50f6q_h@b6Tp_Z?AkhZDUi+Q;mTEddw|yt-Af?{Lxu~7kDMOL|hquy+^uc0O0n-EaODw z7|*yr{kbBzQJ-Tw=a!9?F!p&D;<$iT3hPQthxuQG5fw59`64kiUa@`9M0Ou;mez!4&jsvW8tn+6cbze zS+xudf5f&E%r(A{50b2k=rgyNtYJ!{ocUF2a5G3zpU9@)2YUZnEULp=m_$A>g020% zh*#ZYFV5K)ST}r6^^h(rf!pDg*Lj;FJE4~eP3jp2oKi?0ZeG$Cr3+^{X`l74G(+gJ z#p;(A>`uib%zZkUmeq8PuqJTG#{$i!SFPM5GIXnDk3={|scrgGw4@9UERl-M32aYv zUjN#G3!vI;t6oue z3vvl9LZyRBo~Q1U8q0rHo8)LQEssUlExwCyb7H#H5fC6QFiSUsGJfhW?JU(_Z?C`3 zXZZ6dFi00+!`l%~2KWCZ^-_w57XVH==xAj0{WPbkS6`XNzTFI*<;+w2W;S(&E=bbd zQV+`aPD_%>WnH=oYh5}yh|%2AN+1u~F3RNBG!X>a@+<5%My_Xm*)ytu@k-byoQBkL36XYQ*P*txHS1$0$R`06(O&a?9yw)CwsBuM z{Obk#sh=)mqin#81I%}C#aZ{XJBxG|GZZh|`r?d3ul&NQWbo=AbkeZ^MMZWFc7Mgj zS>PSdZrARcUCytTn;DB--&iaPXHoOFH*5WZ9-&*%6b#Z-rzx8GCDyBD+5f4q#Z2mE zj#5I0iz&mv6iw~U!5)G{?-1QyC?;J|oatQ)>rLYici6j#Z!%xFAgtcd)Mz%ADU zDc1cETfDXUb#k|m_Ij6GdRDzzrRBr3n*eYVvkgTmap!x^Ivtwvrg!UCPZpzdZAw@O z)pSJ&V-2v2gpAsp)r37MJobg}+h#H`QB0;vXqhJ8M=!?!vao^q$c{qY&%O%x4r_Tl2_R%F?&AKE&)4t)OfwXcZ)sTSl;dd2j9hXl zZ<&0P5$m*pXb>BL&;B!`USd|VAoYHz*3F5ACFgIeSNR^x^;!O#WuY_U&~15CqAyk{ zUG3bzBe`9!Lgdo-S)}yd57oGpQy{XnaRKzPoHDJ8NY|vaW7^Lw6EKoXS|_rJ7V$D& zYqeiOq}@!%B_bKla1$^1j0d0%@;#)>)KTT8Gs4C3CSYXy`d8h(*1eoownkdtnjf`` z1NlJPixQ`k?IJQ+2kUa6WaEsPdb?~NH!^o5auBXykCdt%nNHy{DMwT>~q8SU>DN&D)Kg=>%n`|ft66gZu}s0g1T z7yys~$kDfk#GL1Vm=m_CQ|@R#F7JY_<1ZK`^c^I|!N<5W5Z=NCFn+dGo=TyvRIof0 zt14QN^jbt_Z9`+qlUeoiUh*yn?4NI$wD!@-+Su;Cb*zczbF!7Odte#yBd{(*iegtB z(mFibg=E2poLENBbNy|M>_#-wY=wC?au3E?kjY?W{FC|lvHi-{hVQc!`q846x-)GqJ`fnFMeeh}R+q;EhO#3AZRjLwlH* zm2So-wAub%OI?0wFwuy&a%qndIRFQ8kl#G&c@0$1mL|yjjbB!mz<e&xamHlm7Vp*ZEBbJ zhXQIbh_Sf)kUxnC01J_bIc`sjY+r{P@slKT%ugb!m)tKrzmK9q;0TsmJ$M#UhvGcs z0DKRUT_vmDkmO*0NDXx`m7v(vZ$aiy)qhfq6QF<&{M-^9S%s~y{e&64Fnq%Bgs*$AH;0B{}LGhkurA{G`hji`x9G`W z+hX=WM=O5(l&ML%7+P8BE)D?7hLA?NOF0vD%cX%EqrX6ohAj$(+}ScJ55JEa!S$J& zb_pVjby5=~=q{%(doz$W)5#LfK(8Jzv~8)D(VekCO=Lpi!E%jF=iB8Ey2RL=#;}A^ z>x>?01_-KNtM(xbzbdd}(t{5lqR~9T$JZVmg^hAev${5(RXz>|a)9_N|2mol>Yl3s z*kB6ZtC#Orf-!88#x4;DdGuaaD?S8QnG-L<%pM!84DqSSeVY==C56rXmgS6jp?QZv zHJq{O4%A^zmM0#JIvtwf65();Gt&l9m{SNY{U*a^``>QV;^Vqf125_LaEWj_-*W4( z4Af}X4HE!x0KMI4Udk=N1`C0U2kpI)h6!g?rvo_<`LVk5*>Z+t5e@(?j8Ch4`QJ}b zL$}pkWnjV=e;-iZY)I#NgiQ$yOCb0gy5K}_s}W23nsw{SVWUeXx1a7)_eJG)QQ<>y zBl>VUCq4{IQA4jp!tu97n8XhLo`hTNP!_L@pHiK}#y1*hp(i`+Fon4RYl#DRSB<0! zHjC)%%T>xlt;(jy)4%oth*?KSSRLsopcao|cSOSR^r=}^ZHckzn>RTPqR+ePmKkuB z9SGw^Nr+h=(n4t?)jE^A`h3{25ffotl^cgtLZ`GaAz1rKa5)uhL@X)>_M~GCm9x%ulyCeM!`-m?igrIE<=*( zloX-vj@~1MkM=o8t^O4Zn)GEl!TL_Xy{q)>GQsi>pb0Vt534CDX58_!yM!lmseWDd z1~jviWZ94-eq6u|G$-dI@AW0~ZT_s6(X~fat9&^bRjL@5d}U(tU|~K{SUB%S@SkHj zt*3W^{LsMBKFMTLcCg^hlPgxQVg31g`AO@tE3-EIRbf;yVq9|dk1fjtfr#Qe_22vM zO*}30ZD)i`f}80VH9r21l|RA36XA9EWktgPxeer{`7Bc&i<792iC2wSzPt=>=oxGHjqQ6PiQl4Z<+k* z63e#<1xC(Nht8_anWNf}6a49(g}lR;TT?PvC+xV$v)6j|pEvch&)?h|wQN=IyA37r z!JF4KTu6Gam6_R%%dL zc!z)AS^g(-mP++?-7P&gKgIpImiKX={27m9hpSF!+696qokfH^@}{;K&u5vEb!o-- zN%=dEpWYw6?E1VD8m9y^z4~Wc{sGM+bgWUlTk!m8NAvSw_&-<@zmeNr}pojXDtCT!=~Hh&-K&WKlGc<-1V9 zKWv$_@AS64*ID_5+T&LI+1~w#o%v%+*rAJooSgrqzI!Iz(#@NinI)}ip)>u#1`npI zIUn^u`g02YJ-Sb5%dO7Rr=>N@|NnUW+rH=1eLF4t|9XbD;nLthq$)_7_n-X@XYmw0 SVQJ8EWCl-HKbLh*2~7Z8m^w-T literal 0 HcmV?d00001 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