Skip to content
Browse files

Adding support for GitHub repo hooks

  • Loading branch information...
1 parent b3cac89 commit 2ced2dd3e0088409c900cbd351dd4ffcf6b8dea2 @davidjb davidjb committed May 25, 2012
Showing with 221 additions and 11 deletions.
  1. +60 −2 README.rst
  2. +25 −1 example.cfg
  3. +29 −4 githubcollective/config.py
  4. +12 −0 githubcollective/github.py
  5. +23 −0 githubcollective/hook.py
  6. +16 −1 githubcollective/repo.py
  7. +56 −3 githubcollective/sync.py
View
62 README.rst
@@ -22,17 +22,70 @@ Features
* Repositories: create and modify repositories within an organization
- * Configure all repository properties as per the `GitHub Repos API`_,
+ * Configure all repository properties as per the `GitHub Rapes API`_,
including privacy (public/private), description, and other metadata.
- * After inital repository creation happens, updated values in your
+ * After the initial repository creation happens, updated values in your
configuration will replace those on GitHub.
+* Service hooks: add and modify service hooks for repositories.
+
+ * GitHub repositories have support for sending information upon
+ certain events taking place (for instance, pushes being made to a
+ repository or a fork being taken).
+ * After the initial repo creation process takes place, updated values in your
+ hook configuration will `replace` those on GitHub.
+ * Hooks not present in your configuration (such as those manually added
+ on GitHub or those removed from local configuration) will *not* be
+ deleted.
+
* Teams: automatically create teams and modify members
* Control permissions for teams (for example: push, pull or admin)
* Automatically syncs all of the above with GitHub when the tool is run.
+Configuration
+=============
+
+Service hooks
+-------------
+
+Configure service hooks in your configuration as per the `GitHub Hooks API`_
+like so::
+
+ [hook:my-hook]
+ name = web
+ config =
+ {"url": "http://plone.org",
+ "insecure_ssl": "1"
+ }
+ events = push issues fork
+ active = true
+
+ [repo:my.project]
+ ...
+ hooks = my-hook
+
+Values provided here will be coerced into suitable values for posting
+to GitHub's API. For specifications, refer to `https://api.github.com/hooks`_.
+
+ `name` (required)
+ String identifier for a service hook. Refer to specification for
+ available identifiers.
+
+ `config` (required)
+ JSON consisting of key/value pairs relating to configuration
+ of this service. Refer to specifications for applicable config for each
+ service. *Note*: in order to prevent this script from attempting
+ to update GitHub every run, record Boolean values as string "1" or "0"
+ in this JSON - this is how values are stored by GitHub.
+
+ `events` (optional)
+ List of events the hook should apply to. Different services can
+ respond to different events. Refer to API specification for information.
+
+ `active` (optional)
+ Boolean value of whether the hook is enabled or not.
How to install
==============
@@ -149,6 +202,10 @@ Changelog
0.1.4 - unreleased
------------------
+ - Allow service hooks to be specified within the configuration.
+ For samples, see the example configuration. Any GitHub supported
+ hook can be associated with repos.
+ [davidjb]
- Allowing repo properties to be set on creation and editing of config.
For available options, see http://developer.github.com/v3/repos/#create.
This facilities private repo creation (if quota available), amongst other
@@ -194,6 +251,7 @@ Changelog
.. _`GitHub organizations`: https://github.com/blog/674-introducing-organizations
.. _`GitHub Repos API`: http://developer.github.com/v3/repos/#create
+.. _`GitHub Hooks API`: http://developer.github.com/v3/repos/hooks/
.. _`Python2.6`: http://www.python.org/download/releases/2.6/
.. _`argparse`: http://pypi.python.org/pypi/argparse
.. _`requests`: http://python-requests.org
View
26 example.cfg
@@ -7,6 +7,27 @@ members =
repos =
snipmate-snippets
+[hook:my-jenkins]
+name = jenkins
+#JSON object as per schema @ https://api.github.com/hooks
+config =
+ {"jenkins_hook_url": "https://eresearch.jcu.edu.au/ci/github-webhook/"}
+events =
+ push
+active = true
+
+[hook:my-web]
+name = web
+#JSON object as per schema @ https://api.github.com/hooks
+#Boolean objects should be represented as string "1" or "0" as this is
+#what GitHub stores.
+config =
+ {"url": "new-url",
+ "insecure_ssl": "1"
+ }
+events = push
+active = true
+
[repo:vim-snipmate]
fork = garbas/vim-snipmate
owners = garbas MarcWeber
@@ -19,6 +40,9 @@ owners = honza
[repo:demo]
owners = davidjb
teams = contributors
+hooks =
+ my-jenkins
+ my-web
description = My awesome repo
homepage = http://plone.org
has_issues = false
@@ -54,5 +78,5 @@ private = true
# - 3 repos:
# [{'name': 'vim-snipmate'},
# {'name': 'snipmate-snippets'}
-# {'name': 'demo'}
+# {'name': 'demo', 'hooks': ['web', 'web']}
# ]
View
33 githubcollective/config.py
@@ -9,7 +9,9 @@
import ConfigParser
import StringIO
from githubcollective.team import Team
-from githubcollective.repo import Repo, REPO_BOOL_OPTIONS
+from githubcollective.repo import Repo, REPO_BOOL_OPTIONS, \
+ REPO_RESERVED_OPTIONS
+from githubcollective.hook import Hook, HOOK_BOOL_OPTIONS
BASE_URL = 'https://api.github.com'
@@ -55,6 +57,8 @@ def parse(self, data):
teams[team.name] = team
for repo in data['repos']:
+ if repo['hooks']:
+ repo['hooks'] = [Hook(**hook) for hook in repo['hooks']]
repo = Repo(**repo)
repos[repo.name] = repo
@@ -120,15 +124,33 @@ def parse(self, data):
# load configuration for repo
repo_config = dict(config.items(section))
# remove reserved properties
- for option in ('fork', 'owners', 'teams'):
+ for option in REPO_RESERVED_OPTIONS:
if option in repo_config:
del repo_config[option]
# coerce boolean values
for option in REPO_BOOL_OPTIONS:
if option in repo_config:
repo_config[option] = config.getboolean(section,
option)
- repos[name] = Repo(name=name, **repo_config)
+ # load hooks for repo
+ hooks = []
+ if config.has_option(section, 'hooks'):
+ for hook in config.get(section, 'hooks').split():
+ hook_section = 'hook:%s' % hook
+ hook_config = dict(config.items(hook_section))
+ # coerce values into correct formats
+ hook_config['config'] = \
+ hook_config['config'].replace('\n', '')
+ if 'events' in hook_config:
+ hook_config['events'] = \
+ hook_config['events'].split()
+ for option in HOOK_BOOL_OPTIONS:
+ if option in hook_config:
+ hook_config[option] = config.getboolean(
+ hook_section, option)
+ hooks.append(Hook(**hook_config))
+
+ repos[name] = Repo(name=name, hooks=hooks, **repo_config)
# add fork
if config.has_option(section, 'fork'):
fork_urls[name] = config.get(section, 'fork')
@@ -207,7 +229,10 @@ def _get_repos(self):
not self._github['repos']:
self._github['repos'] = {}
for item in self.github._gh_org_repos():
- repo = Repo(**item)
+ hooks = []
+ for hook in self.github._gh_org_repo_hooks(item['name']):
+ hooks.append(Hook(**hook))
+ repo = Repo(hooks=hooks, **item)
self._github['repos'][repo.name] = repo
return self._github['repos']
def _set_repos(self, value):
View
12 githubcollective/github.py
@@ -102,6 +102,9 @@ def _gh_team_repos(self, team_id):
def _gh_org_repos(self):
return self._get_request('/orgs/%s/repos' % self.org)
+ def _gh_org_repo_hooks(self, repo):
+ return self._get_request('/repos/%s/%s/hooks' % (self.org, repo))
+
def _gh_org_fork_repo(self, fork_url):
return self._post_request('/repos/%s/forks' % fork_url,
{'org': self.org})
@@ -110,11 +113,20 @@ def _gh_org_create_repo(self, repo):
return self._post_request('/orgs/%s/repos' % self.org,
json.dumps(repo.dumps()))
+ def _gh_org_create_repo_hook(self, repo, hook):
+ return self._post_request('/repos/%s/%s/hooks' % (self.org, repo.name),
+ json.dumps(hook.dumps()))
+
def _gh_org_edit_repo(self, repo, changes):
changes.update({'name': repo.name}) #Required by API
return self._patch_request('/repos/%s/%s' % (self.org, repo.name),
json.dumps(changes))
+ def _gh_org_edit_repo_hook(self, repo, hook_id, hook):
+ return self._patch_request('/repos/%s/%s/hooks/%i' % \
+ (self.org, repo.name, hook_id),
+ json.dumps(hook.dumps()))
+
def _gh_org_create_team(self, name, permission='pull'):
assert permission in ['pull', 'push', 'admin']
return self._post_request('/orgs/%s/teams' % self.org, json.dumps({
View
23 githubcollective/hook.py
@@ -0,0 +1,23 @@
+import json
+
+HOOK_BOOL_OPTIONS = ('active',)
+
+class Hook(object):
+
+ config = ''
+
+ def __init__(self, **kw):
+ self.__dict__.update(kw)
+ #Handle case of unicode coming from GitHub
+ self.name = unicode(self.name)
+ if isinstance(self.config, str):
+ self.config = json.loads(self.config)
+
+ def __repr__(self):
+ return '<Hook "%s">' % self.name
+
+ def __str__(self):
+ return self.__repr__()
+
+ def dumps(self):
+ return self.__dict__
View
17 githubcollective/repo.py
@@ -1,3 +1,4 @@
+REPO_RESERVED_OPTIONS = ('fork', 'owners', 'teams', 'hooks')
REPO_BOOL_OPTIONS = ('private', 'has_issues', 'has_wiki', 'has_downloads')
class Repo(object):
@@ -12,4 +13,18 @@ def __str__(self):
return self.__repr__()
def dumps(self):
- return self.__dict__
+ dump = self.__dict__.copy()
+ dump['hooks'] = [hook.dumps() for hook in dump['hooks']]
+ return dump
+
+ def getGroupedHooks(self):
+ """GitHub repos can only have 1 of each hook, except `web`."""
+ hooks = {}
+ for hook in self.hooks:
+ if hook.name != 'web':
+ hooks[hook.name] = [hook]
+ else:
+ if 'web' not in hooks:
+ hooks['web'] = []
+ hooks['web'].append(hook)
+ return hooks
View
59 githubcollective/sync.py
@@ -5,6 +5,7 @@
import json
from githubcollective.team import Team
+from githubcollective.repo import REPO_RESERVED_OPTIONS
class Sync(object):
@@ -22,15 +23,24 @@ def run(self, new, old):
to_add = new.repos - old.repos
for repo in to_add:
fork_url = new.get_fork_url(repo)
+ new_repo = new.get_repo(repo)
if fork_url is None:
- self.add_repo(old, new.get_repo(repo))
+ self.add_repo(old, new_repo)
if self.verbose:
print ' - %s' % repo
else:
- self.fork_repo(old, fork_url, new.get_repo(repo))
+ self.fork_repo(old, fork_url, new_repo)
if self.verbose:
print ' - %s - FORK OF %s' % (repo, fork_url)
+ for hook in new_repo.hooks:
+ import ipdb; ipdb.set_trace()
+ self.add_repo_hook(old, new_repo, hook)
+ if self.verbose:
+ print ' - %s - ADDED HOOK: %s (%r)' % (repo,
+ hook.name,
+ hook.config)
+
# REMOVE REPOS
if self.verbose:
print 'REPOS TO BE REMOVED:'
@@ -53,7 +63,8 @@ def run(self, new, old):
changes = {}
#Go through differences and create dict of changes
#Settings removed from config are not modified
- for setting in vars(new_repo).keys():
+ for setting in set(vars(new_repo).keys()) \
+ - set(REPO_RESERVED_OPTIONS):
if not hasattr(old_repo, setting) or \
getattr(old_repo, setting) != getattr(new_repo, setting):
changes[setting] = getattr(new_repo, setting)
@@ -62,6 +73,39 @@ def run(self, new, old):
if self.verbose:
print ' - %s' % repo
+ #Go through hooks for changes
+ #Hooks removed from configuration are left alone as
+ #they may have been manually added upstream.
+ if new_repo.hooks:
+ old_hooks = old_repo.getGroupedHooks()
+ new_hooks = new_repo.getGroupedHooks()
+ if old_hooks != new_hooks:
+ #Conditional updating if change has happened in cfg
+ for hook_type, hooks in new_hooks.items():
+ for position_id, hook in enumerate(hooks):
+ if not hook_type in old_hooks or \
+ position_id < len(old_hooks[hook_type]):
+ #If attempting to update existing hook
+ old_hook = old_hooks[hook_type][position_id]
+ old_hook_config = vars(old_hook)
+ new_hook_config = old_hook_config.copy()
+ new_hook_config.update(vars(hook))
+ if old_hook_config != new_hook_config:
+ #If any difference at all, update server
+ self.edit_repo_hook(old,
+ old_repo,
+ old_hook.id,
+ hook)
+ if self.verbose:
+ print ' - %s - EDITED HOOK: %s (%r)' \
+ % (repo, hook.name, hook.config)
+ else:
+ #Adding a new hook
+ self.add_repo_hook(old, old_repo, hook)
+ if self.verbose:
+ print ' - %s - ADDED HOOK: %s (%r)' \
+ % (repo, hook.name, hook.config)
+
# CREATE TEAMS
if self.verbose:
print 'CREATED TEAMS:'
@@ -150,6 +194,9 @@ def add_repo(self, config, repo):
config._repos[repo.name] = repo
return self.github._gh_org_create_repo(repo)
+ def add_repo_hook(self, config, repo, hook):
+ return self.github._gh_org_create_repo_hook(repo, hook)
+
def remove_repo(self, config, repo):
pass
#del config._repos[repo.name]
@@ -158,6 +205,12 @@ def remove_repo(self, config, repo):
def edit_repo(self, config, repo, changes):
return self.github._gh_org_edit_repo(repo, changes)
+ def edit_repo_hook(self, config, repo, hook_id, hook):
+ hook_ids = [h.id for h in config._repos[repo.name].hooks]
+ hook_index = hook_ids.index(hook_id)
+ config._repos[repo.name].hooks[hook_index] = hook
+ return self.github._gh_org_edit_repo_hook(repo, hook_id, hook)
+
def fork_repo(self, config, fork_url, repo):
config._repos[repo.name] = repo
return self.github._gh_org_fork_repo(fork_url)

0 comments on commit 2ced2dd

Please sign in to comment.
Something went wrong with that request. Please try again.