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