Supybot-plugins/Redmine/plugin.py

316 lines
12 KiB
Python

###
# Copyright (c) 2012, Valentin Lorentz
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions, and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions, and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the author of this software nor the name of
# contributors to this software may be used to endorse or promote products
# derived from this software without specific prior written consent.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
###
import sys
import json
import time
from string import Template
import supybot.conf as conf
import supybot.utils as utils
from supybot.commands import *
import supybot.plugins as plugins
import supybot.ircmsgs as ircmsgs
import supybot.ircutils as ircutils
import supybot.callbacks as callbacks
from supybot.i18n import PluginInternationalization, internationalizeDocstring
_ = PluginInternationalization('Redmine')
class ResourceNotFound(Exception):
pass
class AmbiguousResource(Exception):
pass
class AccessDenied(Exception):
pass
def fetch(site, uri, **kwargs):
url = site['url'] + uri + '.json'
if kwargs:
url += '?' + utils.web.urlencode(kwargs).decode()
try:
data = utils.web.getUrl(url)
if sys.version_info[0] >= 3:
data = data.decode('utf8')
return json.loads(data)
except utils.web.Error:
raise ResourceNotFound()
def flatten_subdicts(dicts):
"""Change dict of dicts into a dict of strings/integers. Useful for
using in string formatting."""
flat = {}
for key, value in dicts.items():
if isinstance(value, dict):
for subkey, subvalue in value.items():
flat['%s__%s' % (key, subkey)] = subvalue
else:
flat[key] = value
return flat
def get_project(site, project):
projects = []
for variable in ('id', 'identifier', 'name'):
projects = list(filter(lambda x:x[variable] == project,
fetch(site, 'projects')['projects']))
if projects:
break
projects = list(projects)
if not projects:
raise ResourceNotFound()
elif len(projects) > 1:
raise AmbiguousResource()
else:
return projects[0]
def get_project_or_error(irc, site, project):
try:
return get_project(site, project)
except ResourceNotFound:
irc.error(_('Project not found.'), Raise=True)
except AmbiguousResource:
irc.error(_('Ambiguous project name.'), Raise=True)
def get_user(site, user):
if user.isdigit():
return fetch(site, 'users/%s' % user)
else:
# TODO: Find a way to get user data from their name...
# (authenticating as admin seems the only way)
raise AccessDenied()
def get_user_or_error(irc, site, user):
try:
return get_user(site, user)
except ResourceNotFound:
irc.error(_('User not found.'), Raise=True)
except AmbiguousResource:
irc.error(_('Ambiguous user name.'), Raise=True)
except AccessDenied:
irc.error(_('Cannot get a user id from their name.'), Raise=True)
def handle_site_arg(wrap_args):
"""Decorator for handling the <site> argument of all commands, because
I am lazy."""
if 'project' in wrap_args:
assert wrap_args[0] == 'project'
wrap_args[0] = 'somethingWithoutSpaces'
project = True
else:
project = False
assert 'project' not in wrap_args
wrap_args = [optional('somethingWithoutSpaces')] + wrap_args
def decorator(f):
def newf(self, irc, msg, args, site_name, *args2):
if not site_name:
site_name = self.registryValue('defaultsite', msg.args[0])
if not site_name:
irc.error(_('No default site.'), Raise=True)
sites = self.registryValue('sites')
if site_name not in sites:
irc.error(_('Invalid site name.'), Raise=True)
site = sites[site_name]
return f(self, irc, msg, args, site, *args2)
newf.__doc__ = """[<site>] %s
If <site> is not given, it defaults to the default set for this
channel, if any.
""" % f.__doc__
return wrap(newf, wrap_args)
return decorator
@internationalizeDocstring
class Redmine(callbacks.Plugin):
"""Add the help for "@plugin help Redmine" here
This should describe *how* to use this plugin."""
threaded = True
_last_fetch = {} # {site: (time, data)}
def __call__(self, irc, msg):
super(Redmine, self).__call__(irc, msg)
with self.registryValue('sites', value=False).editable() as sites:
assert isinstance(sites, dict), repr(sites)
for site_name, site in sites.items():
if 'interval' not in site:
site['interval'] = 60
if site_name in self._last_fetch:
last_time, last_data = self._last_fetch[site_name]
if last_time>time.time()-site['interval']:
continue
data = fetch(site, 'issues', sort='updated_on:desc')
self._last_fetch[site_name] = (time.time(), data)
if 'last_time' not in locals():
continue
try:
last_update = last_data['issues'][0]['updated_on']
except IndexError:
# There was no issue submitted before
last_update = ''
announces = []
for issue in data['issues']:
if issue['updated_on'] <= last_update:
break
announces.append(issue)
for channel in irc.state.channels:
if site_name in self.registryValue('announce.sites',
channel):
format_ = self.registryValue('format.announces.issue',
channel)
for issue in announces:
repl = flatten_subdicts(issue)
s = Template(format_).safe_substitute(repl)
if sys.version_info[0] < 3:
s = s.encode('utf8', errors='replace')
msg = ircmsgs.privmsg(channel, s)
irc.sendMsg(msg)
class site(callbacks.Commands):
conf = conf.supybot.plugins.Redmine
@internationalizeDocstring
def add(self, irc, msg, args, name, url):
"""<name> <base url>
Add a site to the list of known redmine sites."""
if not url.endswith('/'):
url += '/'
if not name:
irc.error(_('Invalid site name.'), Raise=True)
if name in self.conf.sites():
irc.error(_('This site name is already registered.'), Raise=True)
data = utils.web.getUrl(url + 'projects.json')
if sys.version_info[0] >= 3:
data = data.decode('utf8')
data = json.loads(data)
assert 'total_count' in data
#try:
# data = json.load(utils.web.getUrlFd(url + 'projects.json'))
# assert 'total_count' in data
#except:
# irc.error(_('This is not a valid Redmine site.'), Raise=True)
with self.conf.sites.editable() as sites:
sites[name] = {'url': url}
irc.replySuccess()
add = wrap(add, ['admin', 'somethingWithoutSpaces', 'url'])
@internationalizeDocstring
def remove(self, irc, msg, args, name):
"""<name>
Remove a site form the list of known redmine sites."""
if name not in self.conf.sites():
irc.error(_('This site name does not exist.'), Raise=True)
with self.conf.sites.editable() as sites:
del sites[name]
irc.replySuccess()
remove = wrap(remove, ['admin', 'somethingWithoutSpaces'])
@internationalizeDocstring
def list(self, irc, msg, args):
"""takes no arguments
Return the list of known redmine sites."""
sites = self.conf.sites().keys()
if sites:
irc.reply(format('%L', list(sites)))
else:
irc.reply(_('No registered Redmine site.'))
list = wrap(list, [])
@internationalizeDocstring
@handle_site_arg([])
def projects(self, irc, msg, args, site):
"""
Return the list of projects of the Redmine <site>."""
repl = Template(self.registryValue('format.projects')).safe_substitute
projects = map(repl, fetch(site, 'projects')['projects'])
irc.reply(format('%L', projects))
@internationalizeDocstring
@handle_site_arg([getopts({'project': 'something',
'author': 'something',
'assignee': 'something',
})])
def issues(self, irc, msg, args, site, optlist):
"""[--project <project>] [--author <username>] \
[--assignee <username>]
Return a list of issues on the Redmine <site>, filtered with
given parameters."""
fetch_args = {}
for (key, value) in optlist:
if key == 'project':
fetch_args['project_id'] = get_project_or_error(irc, site, value)['id']
elif key == 'author':
fetch_args['author_id'] = get_user_or_error(irc, site, value)['user']['id']
elif key == 'assignee':
fetch_args['assigned_to_id'] = get_user_or_error(irc, site, value)['user']['id']
else:
raise AssertionError((key, value))
issues = fetch(site, 'issues', sort='updated_on:desc', **fetch_args)
issues = issues['issues']
new_issues = []
for issue in issues:
new_issues.append(flatten_subdicts(issue))
repl = Template(self.registryValue('format.issues')).safe_substitute
issues = map(repl, new_issues)
irc.reply(format('%L', issues))
@internationalizeDocstring
@handle_site_arg(['positiveInt'])
def issue(self, irc, msg, args, site, issueid):
"""<issue id>
Return informations on an issue."""
try:
issue = fetch(site, 'issues/%i' % issueid)['issue']
issue = flatten_subdicts(issue)
irc.reply(Template(self.registryValue('format.issue')) \
.safe_substitute(issue))
except ResourceNotFound:
irc.error(_('Issue not found.'), Raise=True)
except KeyError as e:
irc.error(_('Bad format in plugins.Redmine.format.issue: '
'%r is an unknown key.') % e.args[0])
Class = Redmine
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: