You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by je...@apache.org on 2015/02/16 12:44:47 UTC

[16/37] allura git commit: [#4542] ticket:714 Basic webhooks framework & repo-push hook

[#4542] ticket:714 Basic webhooks framework & repo-push hook


Project: http://git-wip-us.apache.org/repos/asf/allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/allura/commit/0eb35a94
Tree: http://git-wip-us.apache.org/repos/asf/allura/tree/0eb35a94
Diff: http://git-wip-us.apache.org/repos/asf/allura/diff/0eb35a94

Branch: refs/heads/ib/4542
Commit: 0eb35a94ff74dc7d456f21a0bfa334763955916b
Parents: 0d6a443
Author: Igor Bondarenko <je...@gmail.com>
Authored: Wed Jan 28 10:15:57 2015 +0000
Committer: Igor Bondarenko <je...@gmail.com>
Committed: Mon Feb 16 10:16:47 2015 +0000

----------------------------------------------------------------------
 Allura/allura/ext/admin/admin_main.py           |  29 ++++
 .../ext/admin/templates/webhooks_list.html      |  48 ++++++
 Allura/allura/lib/utils.py                      |   8 +
 Allura/allura/model/__init__.py                 |   1 +
 Allura/allura/model/repo_refresh.py             |   2 +
 Allura/allura/model/webhook.py                  |  42 ++++++
 .../allura/templates/webhooks/create_form.html  |  90 +++++++++++
 Allura/allura/webhooks.py                       | 148 +++++++++++++++++++
 Allura/setup.py                                 |   3 +
 9 files changed, 371 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/ext/admin/admin_main.py
----------------------------------------------------------------------
diff --git a/Allura/allura/ext/admin/admin_main.py b/Allura/allura/ext/admin/admin_main.py
index 26b058f..52b80f9 100644
--- a/Allura/allura/ext/admin/admin_main.py
+++ b/Allura/allura/ext/admin/admin_main.py
@@ -30,6 +30,7 @@ from webob import exc
 from bson import ObjectId
 from ming.orm.ormsession import ThreadLocalORMSession
 from ming.odm import session
+from ming.utils import LazyProperty
 from allura.app import Application, DefaultAdminController, SitemapEntry
 from allura.lib import helpers as h
 from allura import version
@@ -147,6 +148,7 @@ class AdminApp(Application):
                     SitemapEntry('Categorization', admin_url + 'trove')
                 ]
         links.append(SitemapEntry('Tools', admin_url + 'tools'))
+        links.append(SitemapEntry('Webhooks', admin_url + 'webhooks'))
         if asbool(config.get('bulk_export_enabled', True)):
             links.append(SitemapEntry('Export', admin_url + 'export'))
         if c.project.is_root and has_access(c.project, 'admin')():
@@ -191,6 +193,32 @@ class AdminExtensionLookup(object):
         raise exc.HTTPNotFound, name
 
 
+class WebhooksLookup(BaseController):
+
+    @LazyProperty
+    def _webhooks(self):
+        webhooks = h.iter_entry_points('allura.webhooks')
+        webhooks = [ep.load() for ep in webhooks]
+        return webhooks
+
+    @with_trailing_slash
+    @expose('jinja:allura.ext.admin:templates/webhooks_list.html')
+    def index(self):
+        webhooks = self._webhooks
+        configured_hooks = {}
+        for hook in webhooks:
+            configured_hooks[hook.type] = M.Webhook.find(hook.type, c.project)
+        return {'webhooks': webhooks,
+                'configured_hooks': configured_hooks}
+
+    @expose()
+    def _lookup(self, name, *remainder):
+        for hook in self._webhooks:
+            if hook.type == name and hook.controller:
+                return hook.controller(hook), remainder
+        raise exc.HTTPNotFound, name
+
+
 class ProjectAdminController(BaseController):
     def _check_security(self):
         require_access(c.project, 'admin')
@@ -200,6 +228,7 @@ class ProjectAdminController(BaseController):
         self.groups = GroupsController()
         self.audit = AuditController()
         self.ext = AdminExtensionLookup()
+        self.webhooks = WebhooksLookup()
 
     @with_trailing_slash
     @expose('jinja:allura.ext.admin:templates/project_admin.html')

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/ext/admin/templates/webhooks_list.html
----------------------------------------------------------------------
diff --git a/Allura/allura/ext/admin/templates/webhooks_list.html b/Allura/allura/ext/admin/templates/webhooks_list.html
new file mode 100644
index 0000000..9bb2476
--- /dev/null
+++ b/Allura/allura/ext/admin/templates/webhooks_list.html
@@ -0,0 +1,48 @@
+{#-
+       Licensed to the Apache Software Foundation (ASF) under one
+       or more contributor license agreements.  See the NOTICE file
+       distributed with this work for additional information
+       regarding copyright ownership.  The ASF licenses this file
+       to you under the Apache License, Version 2.0 (the
+       "License"); you may not use this file except in compliance
+       with the License.  You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+       Unless required by applicable law or agreed to in writing,
+       software distributed under the License is distributed on an
+       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+       KIND, either express or implied.  See the License for the
+       specific language governing permissions and limitations
+       under the License.
+-#}
+{% extends g.theme.master %}
+
+{% block title %}{{c.project.name}} / Webhooks{% endblock %}
+{% block header %}Webhooks{% endblock %}
+
+{% block content %}
+  {% for hook in webhooks %}
+    <h1>{{ hook.type }}</h1>
+    <p><a href="{{ hook.type }}">Create</a></p>
+    {% if configured_hooks[hook.type] %}
+      <table>
+        {% for h in configured_hooks[hook.type] %}
+        <tr>
+          <td>
+            <a href="{{ h.url() }}">{{ h.hook_url }}</a>
+          </td>
+          <td>
+            <a href="{{ h.app_config.url() }}">{{ h.app_config.options.mount_label }}</a>
+          </td>
+          <td>
+            <a href="#" title="Delete">
+              <b data-icon="{{g.icons['delete'].char}}" class="ico {{g.icons['delete'].css}}" title="Delete"></b>
+            </a>
+          </td>
+        </tr>
+        {% endfor %}
+      </table>
+    {% endif %}
+  {% endfor %}
+{% endblock %}

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/lib/utils.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/utils.py b/Allura/allura/lib/utils.py
index 7a56603..83950c6 100644
--- a/Allura/allura/lib/utils.py
+++ b/Allura/allura/lib/utils.py
@@ -32,6 +32,7 @@ import collections
 
 import tg
 import pylons
+import json
 import webob.multidict
 from formencode import Invalid
 from tg.decorators import before_validate
@@ -592,3 +593,10 @@ class EmptyCursor(ODMCursor):
 
     def sort(self, *args, **kw):
         return self
+
+
+class DateJSONEncoder(json.JSONEncoder):
+    def default(self, obj):
+        if isinstance(obj, datetime.datetime):
+            return obj.strftime('%Y-%m-%dT%H:%M:%SZ')
+        return json.JSONEncoder.default(self, obj)

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/model/__init__.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index a74bff6..784d1af 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -34,6 +34,7 @@ from .repository import MergeRequest, GitLikeTree
 from .stats import Stats
 from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken
 from .monq_model import MonQTask
+from .webhook import Webhook
 
 from .types import ACE, ACL, EVERYONE, ALL_PERMISSIONS, DENY_ALL, MarkdownCache
 from .session import main_doc_session, main_orm_session

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/model/repo_refresh.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/repo_refresh.py b/Allura/allura/model/repo_refresh.py
index 3dbad4a..86953cc 100644
--- a/Allura/allura/model/repo_refresh.py
+++ b/Allura/allura/model/repo_refresh.py
@@ -146,6 +146,8 @@ def refresh_repo(repo, all_commits=False, notify=True, new_clone=False):
             g.director.create_activity(actor, 'committed', new,
                                        related_nodes=[repo.app_config.project],
                                        tags=['commit', repo.tool.lower()])
+        from allura.webhooks import RepoPushWebhookSender
+        RepoPushWebhookSender().send(commit_ids=commit_ids)
 
     log.info('Refresh complete for %s', repo.full_fs_path)
     g.post_event('repo_refreshed', len(commit_ids), all_commits, new_clone)

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/model/webhook.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/webhook.py b/Allura/allura/model/webhook.py
new file mode 100644
index 0000000..73b9540
--- /dev/null
+++ b/Allura/allura/model/webhook.py
@@ -0,0 +1,42 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from ming.odm import FieldProperty
+from allura.model import Artifact
+
+
+class Webhook(Artifact):
+
+    class __mongometa__:
+        name = 'webhook'
+        unique_indexes = [('app_config_id', 'type', 'hook_url')]
+
+    type = FieldProperty(str)
+    hook_url = FieldProperty(str)
+
+    def url(self):
+        return '{}{}/{}/{}'.format(
+            self.app_config.project.url(),
+            'admin/webhooks',
+            self.type,
+            self._id)
+
+    @classmethod
+    def find(cls, type, project):
+        ac_ids = [ac._id for ac in project.app_configs]
+        hooks = cls.query.find(dict(type=type, app_config_id={'$in': ac_ids}))
+        return hooks.all()

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/templates/webhooks/create_form.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/webhooks/create_form.html b/Allura/allura/templates/webhooks/create_form.html
new file mode 100644
index 0000000..a44bb5b
--- /dev/null
+++ b/Allura/allura/templates/webhooks/create_form.html
@@ -0,0 +1,90 @@
+{#-
+       Licensed to the Apache Software Foundation (ASF) under one
+       or more contributor license agreements.  See the NOTICE file
+       distributed with this work for additional information
+       regarding copyright ownership.  The ASF licenses this file
+       to you under the Apache License, Version 2.0 (the
+       "License"); you may not use this file except in compliance
+       with the License.  You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+       Unless required by applicable law or agreed to in writing,
+       software distributed under the License is distributed on an
+       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+       KIND, either express or implied.  See the License for the
+       specific language governing permissions and limitations
+       under the License.
+-#}
+{% extends g.theme.master %}
+
+{% block title %}{{c.project.name}} / Create {{webhook.type}} webhook{% endblock %}
+
+{% block header %}Create {{webhook.type}} webhook{% endblock %}
+
+{% block extra_css %}
+  <style type="text/css">
+    form {
+      padding: 0 20px 20px 20px;
+    }
+    form label {
+      display: inline-block;
+      width: 30%;
+      vertical-align: top;
+    }
+    form > div {
+      margin-bottom: 10px;
+    }
+    form > div input {
+      width: 30%;
+      vertical-align: top;
+    }
+    form > div input[type="checkbox"] {
+      -moz-box-shadow: none;
+      -webkit-box-shadow: none;
+      -o-box-shadow: none;
+      box-shadow: none;
+      width: 1em;
+    }
+    form .error {
+      display: inline-block;
+      color: #f00;
+      background: none;
+      border: none;
+      margin: 0;
+      width: 30%;
+    }
+  </style>
+{% endblock %}
+
+{%- macro error(field_name) %}
+  {% if c.form_errors[field_name] %}
+  <div class="error">{{c.form_errors[field_name]}}</div>
+  {% endif %}
+{%- endmacro %}
+
+{% block content %}
+<form action="create" method="post" enctype="multipart/form-data">
+  <div>
+    <label for="url">url</label>
+    <input name="url" value="{{ c.form_values['url'] }}">
+    {{ error('url') }}
+  </div>
+  <div>
+    <label for="app">app</label>
+    <select name="app">
+      {% for ac in form.triggered_by %}
+        <option value="{{ac._id}}"{% if h.really_unicode(ac._id) == c.form_values['app'] %} selected{% endif %}>
+          {{ ac.options.mount_label }}
+        </option>
+      {% endfor %}
+    </select>
+    {{ error('app') }}
+  </div>
+
+  {% block additional_fields %}{% endblock %}
+
+  <input type="submit" value="Create">
+  {{lib.csrf_token()}}
+</form>
+{% endblock %}

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/allura/webhooks.py
----------------------------------------------------------------------
diff --git a/Allura/allura/webhooks.py b/Allura/allura/webhooks.py
new file mode 100644
index 0000000..c5dc37e
--- /dev/null
+++ b/Allura/allura/webhooks.py
@@ -0,0 +1,148 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+import json
+
+import requests
+from bson import ObjectId
+from tg import expose, validate, redirect
+from tg.decorators import with_trailing_slash
+from pylons import tmpl_context as c
+from formencode import validators as fev, schema
+from ming.odm import session
+
+from allura.controllers import BaseController
+from allura.lib import helpers as h
+from allura.lib.decorators import require_post, task
+from allura.lib.utils import DateJSONEncoder
+from allura import model as M
+
+
+log = logging.getLogger(__name__)
+
+
+class WebhookCreateForm(schema.Schema):
+    def __init__(self, hook):
+        super(WebhookCreateForm, self).__init__()
+        self.triggered_by = [ac for ac in c.project.app_configs
+                             if ac.tool_name.lower() in hook.triggered_by]
+        self.add_field('app', fev.OneOf(
+            [unicode(ac._id) for ac in self.triggered_by]))
+
+    url = fev.URL(not_empty=True)
+
+
+class WebhookControllerMeta(type):
+    def __call__(cls, hook, *args, **kw):
+        """Decorate the `create` post handler with a validator that references
+        the appropriate webhook sender for this controller.
+        """
+        if hasattr(cls, 'create'):
+            cls.create = validate(
+                cls.create_form(hook),
+                error_handler=cls.index.__func__,
+            )(cls.create)
+        return type.__call__(cls, hook, *args, **kw)
+
+
+class WebhookController(BaseController):
+    __metaclass__ = WebhookControllerMeta
+    create_form = WebhookCreateForm
+
+    def __init__(self, hook):
+        super(WebhookController, self).__init__()
+        self.webhook = hook
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/webhooks/create_form.html')
+    def index(self, **kw):
+        return {'webhook': self.webhook,
+                'form': self.create_form(self.webhook)}
+
+    @expose()
+    @require_post()
+    def create(self, url, app):
+        # TODO: catch DuplicateKeyError
+        wh = M.Webhook(
+            hook_url=url,
+            app_config_id=ObjectId(app),
+            type=self.webhook.type)
+        session(wh).flush(wh)
+        redirect(c.project.url() + 'admin/webhooks/')
+
+
+@task()
+def send_webhook(webhook_id, payload):
+    webhook = M.Webhook.query.get(_id=webhook_id)
+    url = webhook.hook_url
+    headers = {'content-type': 'application/json'}
+    json_payload = json.dumps(payload, cls=DateJSONEncoder)
+    # TODO: catch
+    # TODO: configurable timeout
+    r = requests.post(url, data=json_payload, headers=headers, timeout=30)
+    if r.status_code >= 200 and r.status_code <= 300:
+        log.info('Webhook successfully sent: %s %s %s',
+                 webhook.type, webhook.hook_url, webhook.app_config.url())
+    else:
+        # TODO: retry
+        # TODO: configurable retries
+        log.error('Webhook send error: %s %s %s %s %s',
+                  webhook.type, webhook.hook_url,
+                  webhook.app_config.url(),
+                  r.status_code, r.reason)
+
+
+class WebhookSender(object):
+    """Base class for webhook senders.
+
+    Subclasses are required to implement :meth:`get_payload()` and set
+    :attr:`type` and :attr:`triggered_by`.
+    """
+
+    type = None
+    triggered_by = []
+    controller = WebhookController
+
+    def get_payload(self, **kw):
+        """Return a dict with webhook payload"""
+        raise NotImplementedError('get_payload')
+
+    def send(self, **kw):
+        """Post a task that will send webhook payload"""
+        webhooks = M.Webhook.query.find(dict(
+            app_config_id=c.app.config._id,
+            type=self.type,
+        )).all()
+        if webhooks:
+            payload = self.get_payload(**kw)
+            for webhook in webhooks:
+                send_webhook.post(webhook._id, payload)
+
+
+class RepoPushWebhookSender(WebhookSender):
+    type = 'repo-push'
+    triggered_by = ['git', 'hg', 'svn']
+
+    def get_payload(self, commit_ids, **kw):
+        app = kw.get('app') or c.app
+        payload = {
+            'url': h.absurl(app.url),
+            'count': len(commit_ids),
+            'revisions': [app.repo.commit(ci).info for ci in commit_ids],
+        }
+        return payload

http://git-wip-us.apache.org/repos/asf/allura/blob/0eb35a94/Allura/setup.py
----------------------------------------------------------------------
diff --git a/Allura/setup.py b/Allura/setup.py
index 8eabf60..e44c0c2 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -135,6 +135,9 @@ setup(
     tools = allura.ext.user_profile.user_main:ToolsSection
     social = allura.ext.user_profile.user_main:SocialSection
 
+    [allura.webhooks]
+    repo-push = allura.webhooks:RepoPushWebhookSender
+
     [paste.paster_command]
     taskd = allura.command.taskd:TaskdCommand
     taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand